diff --git a/.circleci/config.yml b/.circleci/config.yml index 4fc83654ef..1f14cfe7c5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ executors: parameters: current_golden_images_hash: type: string - default: 1bee3571a815481151a0d4fad5a225cb0e8d36a1 + default: 05cb901762d5af33e21e113ed598cecea3488def wireit_cache_name: type: string default: wireit diff --git a/packages/button/src/Button.ts b/packages/button/src/Button.ts index b10e7b1a86..abafb55240 100644 --- a/packages/button/src/Button.ts +++ b/packages/button/src/Button.ts @@ -20,7 +20,7 @@ import { import { property } from '@spectrum-web-components/base/src/decorators.js'; import { ButtonBase } from './ButtonBase.js'; import buttonStyles from './button.css.js'; -import { when } from '@spectrum-web-components/base/src/directives.js'; +import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; export type DeprecatedButtonVariants = 'cta' | 'overBackground'; export type ButtonStatics = 'white' | 'black'; @@ -61,7 +61,16 @@ export class Button extends SizedMixin(ButtonBase, { noDefaultSize: true }) { @property({ type: Boolean, reflect: true, attribute: true }) public pending = false; - private cachedAriaLabel: string | null = null; + public pendingStateController: PendingStateController; + + /** + * Initializes the `PendingStateController` for the Button component. + * The `PendingStateController` manages the pending state of the Button. + */ + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } public override click(): void { if (this.pending) { @@ -158,61 +167,23 @@ export class Button extends SizedMixin(ButtonBase, { noDefaultSize: true }) { if (!this.hasAttribute('variant')) { this.setAttribute('variant', this.variant); } + if (this.pending) { + this.pendingStateController.hostUpdated(); + } + } + + protected override update(changes: PropertyValues): void { + super.update(changes); } protected override updated(changed: PropertyValues): void { super.updated(changed); - - if (changed.has('pending')) { - if ( - this.pending && - this.pendingLabel !== this.getAttribute('aria-label') - ) { - if (!this.disabled) { - this.cachedAriaLabel = - this.getAttribute('aria-label') || ''; - this.setAttribute('aria-label', this.pendingLabel); - } - } else if (!this.pending && this.cachedAriaLabel) { - this.setAttribute('aria-label', this.cachedAriaLabel); - } else if (!this.pending && this.cachedAriaLabel === '') { - this.removeAttribute('aria-label'); - } - } - - if (changed.has('disabled')) { - if ( - !this.disabled && - this.pendingLabel !== this.getAttribute('aria-label') - ) { - if (this.pending) { - this.cachedAriaLabel = - this.getAttribute('aria-label') || ''; - this.setAttribute('aria-label', this.pendingLabel); - } - } else if (this.disabled && this.cachedAriaLabel) { - this.setAttribute('aria-label', this.cachedAriaLabel); - } else if (this.disabled && this.cachedAriaLabel == '') { - this.removeAttribute('aria-label'); - } - } } protected override renderButton(): TemplateResult { return html` ${this.buttonContent} - ${when(this.pending, () => { - import( - '@spectrum-web-components/progress-circle/sp-progress-circle.js' - ); - return html` - - `; - })} + ${this.pendingStateController.renderPendingState()} `; } } diff --git a/packages/button/src/ButtonBase.ts b/packages/button/src/ButtonBase.ts index 1c7f993fde..e38418871e 100644 --- a/packages/button/src/ButtonBase.ts +++ b/packages/button/src/ButtonBase.ts @@ -205,6 +205,13 @@ export class ButtonBase extends ObserveSlotText(LikeAnchor(Focusable), '', [ if (!this.hasAttribute('tabindex')) { this.setAttribute('tabindex', '0'); } + if (changed.has('label')) { + if (this.label) { + this.setAttribute('aria-label', this.label); + } else { + this.removeAttribute('aria-label'); + } + } this.manageAnchor(); this.addEventListener('keydown', this.handleKeydown); this.addEventListener('keypress', this.handleKeypress); @@ -215,12 +222,20 @@ export class ButtonBase extends ObserveSlotText(LikeAnchor(Focusable), '', [ if (changed.has('href')) { this.manageAnchor(); } - if (changed.has('label')) { - this.setAttribute('aria-label', this.label || ''); - } + if (this.anchorElement) { this.anchorElement.addEventListener('focus', this.proxyFocus); this.anchorElement.tabIndex = -1; } } + protected override update(changes: PropertyValues): void { + super.update(changes); + if (changes.has('label')) { + if (this.label) { + this.setAttribute('aria-label', this.label); + } else { + this.removeAttribute('aria-label'); + } + } + } } diff --git a/packages/combobox/src/Combobox.ts b/packages/combobox/src/Combobox.ts index 065a6955ac..b3fc05f8d7 100644 --- a/packages/combobox/src/Combobox.ts +++ b/packages/combobox/src/Combobox.ts @@ -27,13 +27,13 @@ import { ifDefined, live, repeat, - when, } from '@spectrum-web-components/base/src/directives.js'; import '@spectrum-web-components/overlay/sp-overlay.js'; import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js'; import '@spectrum-web-components/popover/sp-popover.js'; import '@spectrum-web-components/menu/sp-menu.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; +import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; import '@spectrum-web-components/picker-button/sp-picker-button.js'; import { Textfield } from '@spectrum-web-components/textfield'; import type { Tooltip } from '@spectrum-web-components/tooltip'; @@ -83,6 +83,17 @@ export class Combobox extends Textfield { @property({ type: String, attribute: 'pending-label' }) public pendingLabel = 'Pending'; + public pendingStateController: PendingStateController; + + /** + * Initializes the `PendingStateController` for the Combobox component. + * When the pending state changes to `true`, the `open` property of the Combobox is set to `false`. + */ + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } + @query('slot:not([name])') private optionSlot!: HTMLSlotElement; @@ -415,10 +426,7 @@ export class Combobox extends Textfield { ?required=${this.required} ?readonly=${this.readonly} /> - ${when( - this.pending && !this.disabled && !this.readonly, - this.renderLoader - )} + ${this.pendingStateController.renderPendingState()} `; } diff --git a/packages/picker/src/Picker.ts b/packages/picker/src/Picker.ts index 6b8e7592f7..0d85652047 100644 --- a/packages/picker/src/Picker.ts +++ b/packages/picker/src/Picker.ts @@ -25,7 +25,6 @@ import { ifDefined, StyleInfo, styleMap, - when, } from '@spectrum-web-components/base/src/directives.js'; import { property, @@ -52,6 +51,7 @@ import { MatchMediaController, } from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js'; import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js'; +import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; import { Overlay } from '@spectrum-web-components/overlay/src/Overlay.js'; import type { SlottableRequestEvent } from '@spectrum-web-components/overlay/src/slottable-request-event.js'; import type { FieldLabel } from '@spectrum-web-components/field-label'; @@ -154,6 +154,17 @@ export class PickerBase extends SizedMixin(Focusable, { noDefaultSize: true }) { return this._selectedItem; } + public pendingStateController: PendingStateController; + + /** + * Initializes the `PendingStateController` for the Picker component. + * The `PendingStateController` manages the pending state of the Picker. + */ + constructor() { + super(); + this.pendingStateController = new PendingStateController(this); + } + public set selectedItem(selectedItem: MenuItem | undefined) { this.selectedItemContent = selectedItem ? selectedItem.itemChildren @@ -422,21 +433,7 @@ export class PickerBase extends SizedMixin(Focusable, { noDefaultSize: true }) { > ` : nothing} - ${when(this.pending, () => { - import( - '@spectrum-web-components/progress-circle/sp-progress-circle.js' - ); - // aria-valuetext is a workaround for aria-valuenow being applied in Firefox even in indeterminate mode. - return html` - - `; - })} + ${this.pendingStateController.renderPendingState()} ; +} + +/** + * Represents a controller for managing the pending state of a reactive element. + * + * @template T - The type of the reactive element. + */ +export class PendingStateController + implements ReactiveController +{ + /** + * The host element that this controller is attached to. + */ + public host: T; + + /** + * Creates an instance of PendingStateController. + * @param host - The host element that this controller is attached to. + */ + constructor(host: T) { + this.host = host; + this.host.addController(this); + } + + public cachedAriaLabel: string | null = null; + /** + * Renders the pending state UI. + * @returns A TemplateResult representing the pending state UI. + */ + public renderPendingState(): TemplateResult { + const pendingLabel = this.host.pendingLabel || 'Pending'; + return this.host.pending + ? html` + + ` + : html``; + } + + /** + * Updates the ARIA label of the host element based on the pending state. + * Manages Cached Aria Label + */ + private updateAriaLabel(): void { + const { pending, disabled, pendingLabel } = this.host; + const currentAriaLabel = this.host.getAttribute('aria-label'); + + if (pending && !disabled && currentAriaLabel !== pendingLabel) { + // Cache the current `aria-label` to be restored when no longer `pending` + this.cachedAriaLabel = currentAriaLabel; + // Since it is pending, we set the aria-label to `pendingLabel` or "Pending" + this.host.setAttribute('aria-label', pendingLabel || 'Pending'); + } else if (!pending || disabled) { + // Restore the cached `aria-label` if it exists + if (this.cachedAriaLabel) { + this.host.setAttribute('aria-label', this.cachedAriaLabel); + } else if (!pending) { + // If no cached `aria-label` and not `pending`, remove the `aria-label` + this.host.removeAttribute('aria-label'); + } + } + } + + hostConnected(): void { + if (!this.cachedAriaLabel) + this.cachedAriaLabel = this.host.getAttribute('aria-label'); + this.updateAriaLabel(); + } + + hostUpdated(): void { + this.updateAriaLabel(); + } +} diff --git a/tools/reactive-controllers/test/pending-state.test.ts b/tools/reactive-controllers/test/pending-state.test.ts new file mode 100644 index 0000000000..b48e9cba95 --- /dev/null +++ b/tools/reactive-controllers/test/pending-state.test.ts @@ -0,0 +1,173 @@ +/* +Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +/* +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { expect, fixture, html } from '@open-wc/testing'; +import { + HostWithPendingState, + PendingStateController, +} from '@spectrum-web-components/reactive-controllers/src/PendingState.js'; + +import '@spectrum-web-components/progress-circle/sp-progress-circle.js'; +import '@spectrum-web-components/picker/sp-picker.js'; + +describe('PendingStateController', () => { + let host: HostWithPendingState; + let controller: PendingStateController; + + beforeEach(async () => { + host = await fixture(html` + + `); + controller = host.pendingStateController; + }); + + describe('renderPendingState', () => { + it('should change aria-label of host when pending and when not pending', async () => { + host = await fixture(html` + + `); + controller = host.pendingStateController; + + host.setAttribute('pending', 'true'); + await host.updateComplete; + + let ariaLabel = host.getAttribute('aria-label'); + expect(ariaLabel).to.equal('Pending'); + + host.removeAttribute('pending'); + await host.updateComplete; + + ariaLabel = host.getAttribute('aria-label'); + expect(ariaLabel).to.equal(null); + + host.setAttribute('aria-label', 'clickable'); + await host.updateComplete; + ariaLabel = host.getAttribute('aria-label'); + expect(ariaLabel).to.equal('clickable'); + host.setAttribute('pending', 'true'); + + await host.updateComplete; + ariaLabel = host.getAttribute('aria-label'); + expect(ariaLabel).to.equal('Pending'); + + host.removeAttribute('pending'); + await host.updateComplete; + ariaLabel = host.getAttribute('aria-label'); + expect(ariaLabel).to.equal('clickable'); + + host.setAttribute('pending', 'true'); + await host.updateComplete; + ariaLabel = host.getAttribute('aria-label'); + expect(ariaLabel).to.equal('Pending'); + }); + + it('should render the pending state UI', async () => { + const pendingLabel = 'Custom Pending Label'; + host.pendingLabel = pendingLabel; + const templateResult = controller.renderPendingState(); + + const renderedElement = await fixture(html` + ${templateResult} + `); + const expectedElement = await fixture(html` + + `); + + expect(renderedElement.outerHTML === expectedElement.outerHTML).to + .be.true; + }); + + it('should render the default pending state UI if no label is provided', async () => { + host.pendingLabel = undefined; + const templateResult = controller.renderPendingState(); + const renderedElement = await fixture(html` + ${templateResult} + `); + const expectedElement = await fixture(html` + + `); + + const renderedAttributes = renderedElement.attributes; + const expectedAttributes = expectedElement.attributes; + + expect(renderedAttributes.length === expectedAttributes.length).to + .be.true; + + for (let i = 0; i < renderedAttributes.length; i++) { + const renderedAttr = renderedAttributes[i]; + const expectedAttr = expectedAttributes.getNamedItem( + renderedAttr.name + ); + + expect(renderedAttr.value === expectedAttr?.value).to.be.true; + } + expect(host.pending).to.be.true; + }); + + it('should toggle the pending state on and off and preserve the component state correctly', async () => { + // Set initial pending state to true + host.setAttribute('pending', 'true'); + await host.updateComplete; + let progressCircle = + host.shadowRoot?.querySelector('sp-progress-circle'); + expect(progressCircle).to.not.be.null; + host.removeAttribute('pending'); + await host.updateComplete; + progressCircle = + host.shadowRoot?.querySelector('sp-progress-circle'); + expect(progressCircle).to.be.null; + host.setAttribute('pending', 'true'); + await host.updateComplete; + progressCircle = + host.shadowRoot?.querySelector('sp-progress-circle'); + expect(progressCircle).to.not.be.null; + const expectedElement = await fixture(html` + + `); + + const renderedAttributes = progressCircle?.attributes; + const expectedAttributes = expectedElement.attributes; + expect(renderedAttributes?.length === expectedAttributes.length).to + .be.true; + if (renderedAttributes) { + for (let i = 0; i < renderedAttributes.length; i++) { + const renderedAttr = renderedAttributes[i]; + const expectedAttr = expectedAttributes.getNamedItem( + renderedAttr.name + ); + + expect(renderedAttr.value === expectedAttr?.value).to.be + .true; + } + } + }); + }); +});