Skip to content

Commit 7d5bda7

Browse files
committed
Make stepper accessible
As the stepper inherits from IronMenubarBehavior, it can be navigated through keyboard. Role of header is `tablist`, each stepper button has a `tab` role. When a step is completed, `aria-checked` of its corresponding button is set to true. Steps not accessible have `aria-disabled` set to true. `aria-invalid` is set to true when a step has an error.
1 parent 0baf15f commit 7d5bda7

File tree

4 files changed

+122
-49
lines changed

4 files changed

+122
-49
lines changed

bower.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"name": "ud-stepper",
3-
"version": "0.5.0",
3+
"version": "0.6.0",
44
"description": "Material Design Stepper",
55
"main": "ud-stepper.html",
66
"dependencies": {
77
"polymer": "Polymer/polymer#^2.0.0",
88
"paper-styles": "^2.0.0",
99
"neon-animation": "^2.2.0",
10-
"iron-selector": "^2.0.1",
10+
"iron-menu-behavior": "^2.0.1",
1111
"paper-button": "^2.0.0",
1212
"iron-iconset-svg": "^2.1.0",
1313
"iron-icon": "^2.0.1"

demo/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ <h3>Linear Stepper</h3>
6666
</div>
6767
<demo-snippet>
6868
<template>
69-
<ud-stepper linear sizing="contain" animate>
69+
<ud-stepper id="steps" linear sizing="contain" animate>
7070
<ud-step title="Step 1">
7171
<div> Step 1 Content</div>
72+
<paper-button raised>Content button</paper-button>
7273
</ud-step>
7374
<ud-step title="Step 2 with veeeeeery long title">
7475
<div>Step 2 Content</div>
@@ -224,6 +225,7 @@ <h3>Custom action implementation </h3>
224225
window.addEventListener('WebComponentsReady', function() {
225226
console.info('ready');
226227
app = document.querySelector('#app');
228+
steps = document.querySelector('#steps');
227229

228230
app.next = e => {
229231
e.target.dispatchEvent(new CustomEvent('step-action', { detail: 'next', bubbles: true, composed: true }));

ud-step.html

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141

4242
paper-button,
4343
#actions ::slotted(paper-button) {
44-
@apply --paper-font-button;
4544
color: rgba(0, 0, 0, 0.83);
4645
}
4746

@@ -249,10 +248,6 @@
249248
return ['_updateActionsButtons(_actionButtons.*,_linearActionButtons.*,actions.*)'];
250249
}
251250

252-
ready() {
253-
super.ready();
254-
}
255-
256251
connectedCallback() {
257252
super.connectedCallback();
258253
if (this.hideActions) return;
@@ -311,7 +306,7 @@
311306
_errorChanged(invalid) {
312307
this.dispatchEvent(new CustomEvent('step-error', {
313308
detail: {
314-
stpe: this
309+
step: this
315310
},
316311
bubbles: true
317312
}));
@@ -320,6 +315,12 @@
320315
_reset() {
321316
this.completed = false;
322317
}
318+
319+
setFocus() {
320+
const content = this.$.content;
321+
content.setAttribute('tabindex', '0');
322+
content.focus();
323+
}
323324
}
324325

325326
window.customElements.define(UdStepElement.is, UdStepElement);

ud-stepper.html

Lines changed: 110 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,40 @@
66
<link rel="import" href="../paper-styles/element-styles/paper-material-styles.html">
77
<link rel="import" href="../paper-styles/color.html">
88
<link rel="import" href="../paper-styles/typography.html">
9-
<link rel="import" href="../iron-selector/iron-selector.html">
9+
<link rel="import" href="../iron-menu-behavior/iron-menubar-behavior.html">
1010
<link rel="import" href="../iron-icon/iron-icon.html">
1111
<link rel="import" href="../polymer/lib/mixins/mutable-data.html">
1212
<link rel="import" href="ud-step.html">
1313
<link rel="import" href="ud-iconset.html">
14+
15+
<dom-module id="ud-stepper-button">
16+
<template>
17+
<style>
18+
host: {
19+
display: block
20+
}
21+
</style>
22+
<slot></slot>
23+
</template>
24+
<script>
25+
(function() {
26+
/**
27+
* `ud-stepper-button`
28+
* private element to make sure we capture 'spacebar' downkey event when the
29+
* element has focus. This is similar to how paper-tab work.
30+
*/
31+
class UdStepperButton extends
32+
Polymer.mixinBehaviors(Polymer.IronButtonState, Polymer.Element) {
33+
static get is() {
34+
return 'ud-stepper-button';
35+
}
36+
}
37+
customElements.define(UdStepperButton.is, UdStepperButton);
38+
})();
39+
40+
</script>
41+
</dom-module>
42+
1443
<dom-module id="ud-stepper">
1544
<template>
1645
<style include="paper-material-styles">
@@ -45,7 +74,7 @@
4574
flex: 1;
4675
}
4776

48-
:host(:not([vertical])) .header[selected] {
77+
:host(:not([vertical])) .header[aria-selected] {
4978
background: var(--ud-stepper-selected-header-background, var(--google-grey-300));
5079
@apply --ud-stepper-selected-header;
5180
}
@@ -120,36 +149,36 @@
120149

121150

122151
:host(:not([vertical])) .header:hover,
123-
:host(:not([vertical])) .header[selected] {
152+
:host(:not([vertical])) .header[aria-selected] {
124153
overflow: visible;
125154
}
126155

127156
:host(:not([vertical])) .header:hover .label-text .main,
128-
:host(:not([vertical])) .header[selected] .label-text .main
157+
:host(:not([vertical])) .header[aria-selected] .label-text .main
129158
{
130159
max-width: fit-content;
131160
}
132161

133-
.header[completed] .label-circle {
162+
.header[aria-checked] .label-circle {
134163
background-color: var(--ud-stepper-icon-completed-color, var(--google-blue-500));
135164
}
136-
.header.selected .label-circle {
165+
.header[aria-selected] .label-circle {
137166
background-color: var(--ud-stepper-icon-selected-color, var(--google-blue-500));
138167
}
139168

140-
.header.selected .label-text,
141-
.header[completed] .label-text {
169+
.header[aria-selected] .label-text,
170+
.header[aria-checked] .label-text {
142171
color: rgba(0, 0, 0, 0.87);
143172
}
144173

145-
.header.selected .label-text {
174+
.header[aria-selected] .label-text {
146175
@apply --paper-font-body2;
147176
}
148177

149-
.header[error] .label-circle {
178+
.header[aria-invalid] .label-circle {
150179
background-color: var(--ud-stepper-icon-error-color, var(--paper-deep-orange-a700));
151180
}
152-
.header[error] .label-text {
181+
.header[aria-invalid] .label-text {
153182
color: var(--ud-stepper-icon-error-color, var(--paper-deep-orange-a700));
154183
}
155184

@@ -247,10 +276,10 @@
247276
display: none;
248277
}
249278
</style>
250-
<iron-selector id="selector" class="header-container" selectable=".header.selectable" selected-class="selected" selected="{{selected}}" on-iron-activate="_handleStepActivate" selected-attribute="selected">
279+
<div class="header-container" role="tablist">
251280
<dom-repeat items="[[_steps]]" mutable-data>
252281
<template>
253-
<div class="header selectable" completed$="[[item.completed]]" error$="[[item.error]]">
282+
<ud-stepper-button class="header selectable" role="tab" aria-checked="[[item.completed]]" aria-disabled="[[item.disabled]]" aria-invalid$="[[item.error]]">
254283
<div class="label">
255284
<div class="label-circle">
256285
<dom-if if="[[item._currentIcon]]">
@@ -281,10 +310,10 @@
281310
</div>
282311
</template>
283312
</dom-if>
284-
</div>
313+
</ud-stepper-button>
285314
</template>
286315
</dom-repeat>
287-
</iron-selector>
316+
</div>
288317
<dom-if if="[[!vertical]]" restamp>
289318
<template>
290319
<dom-if if="[[animate]]" restamp>
@@ -324,12 +353,24 @@
324353
* `--ud-stepper-selected-header-background` | The color of a selected header label | `--google-grey-300`
325354
* `--ud-stepper-selected-header` | Style mixin for selected header | `{}`
326355
*
356+
* ### Accesibility
357+
*
358+
* As the stepper inherits from IronMenubarBehavior, it can be navigated through keyboard.
359+
* Role of header is `tablist`, each stepper button has a `tab` role.
360+
* When a step is completed, `aria-checked` of its corresponding button is set to true.
361+
* Steps not accesible have `aria-disabled` set to true.
362+
* `aria-invalid` is set to true when a step has an error.
363+
*
327364
* @customElement
328365
* @polymer
329366
* @memberof UrDeveloper
330367
* @demo demo/index.html
331368
*/
332-
class UdStepperElement extends Polymer.MutableData(Polymer.Element) {
369+
class UdStepperElement extends
370+
Polymer.mixinBehaviors(Polymer.IronMenubarBehavior,
371+
Polymer.MutableData(
372+
Polymer.Element)) {
373+
333374
static get is() {
334375
return 'ud-stepper';
335376
}
@@ -402,6 +443,16 @@
402443
containerHeight: {
403444
type: Number
404445
},
446+
447+
selectable: {
448+
type: String,
449+
value: '.header.selectable'
450+
},
451+
452+
selectedAttribute: {
453+
type: String,
454+
value: 'active'
455+
}
405456
};
406457
}
407458

@@ -412,32 +463,35 @@
412463
];
413464
}
414465

415-
constructor() {
416-
super();
466+
connectedCallback() {
467+
super.connectedCallback();
468+
this.addEventListener('step-action', evt => this._handleStepAction(evt));
469+
this.addEventListener('step-error', evt => this.notifyPath('_steps'));
470+
// this.addEventListener('iron-activate', evt => this._handleStepActivate(evt));
471+
417472
this._templateObserver = new Polymer.FlattenedNodesObserver(this, info => {
418473
if (info.addedNodes.filter(this._isStep).length > 0 ||
419474
info.removedNodes.filter(this._isStep).length > 0) {
420475
this._steps = this._findSteps();
421476
this._prepareSteps(this._steps);
422477
Polymer.RenderStatus.afterNextRender(this, () => {
423478
this._setHeight(this.sizing, this._maxHeight, this.vertical, this.animate);
479+
const items = this.shadowRoot.querySelectorAll(this.selectable)
480+
this._setItems([...items]);
481+
if (!this.selected) {
482+
this.selected = 0;
483+
}
484+
// Note(cg): without call to _selectionChanges, selected step is not marked as selected.
485+
this._selectionChanged(this.selected);
424486
});
425487

426-
if (!this.selected) {
427-
this.selected = 0;
428-
}
429-
// Note(cg): without call to _selectionChanges, selected step is not marked as selected.
430-
this._selectionChanged(this.selected);
431488
}
432489
});
433-
}
490+
}
434491

435-
ready() {
436-
super.ready();
437-
this.addEventListener('step-action', evt => this._handleStepAction(evt));
438-
this.addEventListener('step-error', evt => {
439-
this.notifyPath('_steps');
440-
});
492+
disconnectedCallback() {
493+
super.disconnectedCallback();
494+
this._templateObserver.disconnect();
441495
}
442496

443497
_findSteps() {
@@ -449,6 +503,7 @@
449503
steps.forEach((step, i) => {
450504
//don't overwrite the step's actions, if they're already set
451505
if (step.actions) return;
506+
step.disabled = i !== 0;
452507

453508
//By default all steps have continue and cancel action
454509
const actions = [{
@@ -471,6 +526,7 @@
471526
}
472527
step.actions = actions;
473528

529+
474530
const stepHeight = parseInt(getComputedStyle(step).height);
475531
if (stepHeight > maxHeight) {
476532
maxHeight = stepHeight;
@@ -543,6 +599,11 @@
543599
continue (step) {
544600
if (this.linear && step.error) return;
545601
step.completed = true;
602+
// Note(cg): reset disabled to true if not editable or alwaysSelectable
603+
// step was marked as displabled = false on _selectionChanged.
604+
if (!(step.alwaysSelectable || step.editable)) {
605+
step.disabled = true;
606+
}
546607
this.notifyPath('_steps');
547608
this.selected = this._findNextStep(this.selected);
548609
}
@@ -587,7 +648,8 @@
587648
this.animate = !this.animate;
588649
}
589650

590-
_handleStepActivate(evt) {
651+
// @overide iron-menutab-behavior
652+
_activateHandler(e) {
591653
/*
592654
* the logic here:
593655
* User is allowed to select any step if stepper is not linear
@@ -596,19 +658,23 @@
596658
* - allow user to revist an optional step as long as is not completed
597659
* - bypass this logic for `always-selectable`
598660
*/
599-
if (this.linear) {
600-
const step = this._steps[evt.detail.selected];
601-
if (!step) return;
602-
if ((step.completed && step.editable) || (!step.completed && step.optional) || step.alwaysSelectable) {
661+
const tap = e.composedPath().find(el => el.role === 'tab');
662+
const index = this.items.indexOf(tap);
663+
const step = this._steps[index];
664+
if (step && index > -1) {
665+
e.stopPropagation();
666+
console.info('handle', step, step.disabled);
667+
if(this.linear) {
668+
// if ((step.completed && step.editable) || (!step.completed && step.optional) || step.alwaysSelectable) {
669+
if (!step.disabled) {
670+
this._itemActivate(index, step);
671+
}
603672
return;
604673
}
605-
// Only call preventDefault() if the event comes from our own iron-selector
606-
if (evt.target.id === "selector" && evt.target.selectable === ".header.selectable") {
607-
evt.preventDefault();
608-
}
674+
this._itemActivate(index, step);
609675
}
610676
}
611-
677+
612678
_setSlotNames(steps, vertical) {
613679
if (!this._steps) return;
614680
steps.forEach((step, i) => {
@@ -620,6 +686,10 @@
620686
if (!this._steps) return;
621687
this._steps.forEach((step, i) => {
622688
step.selected = i == selected;
689+
if (i === selected) {
690+
step.disabled = false;
691+
step.setFocus();
692+
}
623693
});
624694
}
625695

0 commit comments

Comments
 (0)