|
6 | 6 | <link rel="import" href="../paper-styles/element-styles/paper-material-styles.html"> |
7 | 7 | <link rel="import" href="../paper-styles/color.html"> |
8 | 8 | <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"> |
10 | 10 | <link rel="import" href="../iron-icon/iron-icon.html"> |
11 | 11 | <link rel="import" href="../polymer/lib/mixins/mutable-data.html"> |
12 | 12 | <link rel="import" href="ud-step.html"> |
13 | 13 | <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 | + |
14 | 43 | <dom-module id="ud-stepper"> |
15 | 44 | <template> |
16 | 45 | <style include="paper-material-styles"> |
|
45 | 74 | flex: 1; |
46 | 75 | } |
47 | 76 |
|
48 | | - :host(:not([vertical])) .header[selected] { |
| 77 | + :host(:not([vertical])) .header[aria-selected] { |
49 | 78 | background: var(--ud-stepper-selected-header-background, var(--google-grey-300)); |
50 | 79 | @apply --ud-stepper-selected-header; |
51 | 80 | } |
|
120 | 149 |
|
121 | 150 |
|
122 | 151 | :host(:not([vertical])) .header:hover, |
123 | | - :host(:not([vertical])) .header[selected] { |
| 152 | + :host(:not([vertical])) .header[aria-selected] { |
124 | 153 | overflow: visible; |
125 | 154 | } |
126 | 155 |
|
127 | 156 | :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 |
129 | 158 | { |
130 | 159 | max-width: fit-content; |
131 | 160 | } |
132 | 161 |
|
133 | | - .header[completed] .label-circle { |
| 162 | + .header[aria-checked] .label-circle { |
134 | 163 | background-color: var(--ud-stepper-icon-completed-color, var(--google-blue-500)); |
135 | 164 | } |
136 | | - .header.selected .label-circle { |
| 165 | + .header[aria-selected] .label-circle { |
137 | 166 | background-color: var(--ud-stepper-icon-selected-color, var(--google-blue-500)); |
138 | 167 | } |
139 | 168 |
|
140 | | - .header.selected .label-text, |
141 | | - .header[completed] .label-text { |
| 169 | + .header[aria-selected] .label-text, |
| 170 | + .header[aria-checked] .label-text { |
142 | 171 | color: rgba(0, 0, 0, 0.87); |
143 | 172 | } |
144 | 173 |
|
145 | | - .header.selected .label-text { |
| 174 | + .header[aria-selected] .label-text { |
146 | 175 | @apply --paper-font-body2; |
147 | 176 | } |
148 | 177 |
|
149 | | - .header[error] .label-circle { |
| 178 | + .header[aria-invalid] .label-circle { |
150 | 179 | background-color: var(--ud-stepper-icon-error-color, var(--paper-deep-orange-a700)); |
151 | 180 | } |
152 | | - .header[error] .label-text { |
| 181 | + .header[aria-invalid] .label-text { |
153 | 182 | color: var(--ud-stepper-icon-error-color, var(--paper-deep-orange-a700)); |
154 | 183 | } |
155 | 184 |
|
|
247 | 276 | display: none; |
248 | 277 | } |
249 | 278 | </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"> |
251 | 280 | <dom-repeat items="[[_steps]]" mutable-data> |
252 | 281 | <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]]"> |
254 | 283 | <div class="label"> |
255 | 284 | <div class="label-circle"> |
256 | 285 | <dom-if if="[[item._currentIcon]]"> |
|
281 | 310 | </div> |
282 | 311 | </template> |
283 | 312 | </dom-if> |
284 | | - </div> |
| 313 | + </ud-stepper-button> |
285 | 314 | </template> |
286 | 315 | </dom-repeat> |
287 | | - </iron-selector> |
| 316 | + </div> |
288 | 317 | <dom-if if="[[!vertical]]" restamp> |
289 | 318 | <template> |
290 | 319 | <dom-if if="[[animate]]" restamp> |
|
324 | 353 | * `--ud-stepper-selected-header-background` | The color of a selected header label | `--google-grey-300` |
325 | 354 | * `--ud-stepper-selected-header` | Style mixin for selected header | `{}` |
326 | 355 | * |
| 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 | + * |
327 | 364 | * @customElement |
328 | 365 | * @polymer |
329 | 366 | * @memberof UrDeveloper |
330 | 367 | * @demo demo/index.html |
331 | 368 | */ |
332 | | - class UdStepperElement extends Polymer.MutableData(Polymer.Element) { |
| 369 | + class UdStepperElement extends |
| 370 | + Polymer.mixinBehaviors(Polymer.IronMenubarBehavior, |
| 371 | + Polymer.MutableData( |
| 372 | + Polymer.Element)) { |
| 373 | + |
333 | 374 | static get is() { |
334 | 375 | return 'ud-stepper'; |
335 | 376 | } |
|
402 | 443 | containerHeight: { |
403 | 444 | type: Number |
404 | 445 | }, |
| 446 | + |
| 447 | + selectable: { |
| 448 | + type: String, |
| 449 | + value: '.header.selectable' |
| 450 | + }, |
| 451 | + |
| 452 | + selectedAttribute: { |
| 453 | + type: String, |
| 454 | + value: 'active' |
| 455 | + } |
405 | 456 | }; |
406 | 457 | } |
407 | 458 |
|
|
412 | 463 | ]; |
413 | 464 | } |
414 | 465 |
|
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 | + |
417 | 472 | this._templateObserver = new Polymer.FlattenedNodesObserver(this, info => { |
418 | 473 | if (info.addedNodes.filter(this._isStep).length > 0 || |
419 | 474 | info.removedNodes.filter(this._isStep).length > 0) { |
420 | 475 | this._steps = this._findSteps(); |
421 | 476 | this._prepareSteps(this._steps); |
422 | 477 | Polymer.RenderStatus.afterNextRender(this, () => { |
423 | 478 | 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); |
424 | 486 | }); |
425 | 487 |
|
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); |
431 | 488 | } |
432 | 489 | }); |
433 | | - } |
| 490 | + } |
434 | 491 |
|
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(); |
441 | 495 | } |
442 | 496 |
|
443 | 497 | _findSteps() { |
|
449 | 503 | steps.forEach((step, i) => { |
450 | 504 | //don't overwrite the step's actions, if they're already set |
451 | 505 | if (step.actions) return; |
| 506 | + step.disabled = i !== 0; |
452 | 507 |
|
453 | 508 | //By default all steps have continue and cancel action |
454 | 509 | const actions = [{ |
|
471 | 526 | } |
472 | 527 | step.actions = actions; |
473 | 528 |
|
| 529 | + |
474 | 530 | const stepHeight = parseInt(getComputedStyle(step).height); |
475 | 531 | if (stepHeight > maxHeight) { |
476 | 532 | maxHeight = stepHeight; |
|
543 | 599 | continue (step) { |
544 | 600 | if (this.linear && step.error) return; |
545 | 601 | 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 | + } |
546 | 607 | this.notifyPath('_steps'); |
547 | 608 | this.selected = this._findNextStep(this.selected); |
548 | 609 | } |
|
587 | 648 | this.animate = !this.animate; |
588 | 649 | } |
589 | 650 |
|
590 | | - _handleStepActivate(evt) { |
| 651 | + // @overide iron-menutab-behavior |
| 652 | + _activateHandler(e) { |
591 | 653 | /* |
592 | 654 | * the logic here: |
593 | 655 | * User is allowed to select any step if stepper is not linear |
|
596 | 658 | * - allow user to revist an optional step as long as is not completed |
597 | 659 | * - bypass this logic for `always-selectable` |
598 | 660 | */ |
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 | + } |
603 | 672 | return; |
604 | 673 | } |
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); |
609 | 675 | } |
610 | 676 | } |
611 | | - |
| 677 | + |
612 | 678 | _setSlotNames(steps, vertical) { |
613 | 679 | if (!this._steps) return; |
614 | 680 | steps.forEach((step, i) => { |
|
620 | 686 | if (!this._steps) return; |
621 | 687 | this._steps.forEach((step, i) => { |
622 | 688 | step.selected = i == selected; |
| 689 | + if (i === selected) { |
| 690 | + step.disabled = false; |
| 691 | + step.setFocus(); |
| 692 | + } |
623 | 693 | }); |
624 | 694 | } |
625 | 695 |
|
|
0 commit comments