-
Notifications
You must be signed in to change notification settings - Fork 254
feat(button): extract pending state into reusable core primitives #6439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63c183d
0a32eef
2968b25
ca546d7
83dacab
8b289d0
a27aa58
f3d6d23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@spectrum-web-components/core': minor | ||
| '@adobe/spectrum-wc': patch | ||
| --- | ||
|
|
||
| Extract the pending (busy) state into reusable, decoupled 2nd-gen core primitives so any pending-capable component can adopt it. | ||
|
|
||
| - **`@spectrum-web-components/core`**: adds `PendingController` (`/controllers/pending-controller`) for the pending state (delayed activation, inline-size freeze, derived busy accessible name), the render-only `renderPendingSpinner` directive (`/directives/pending-spinner`), and `PendingMixin` (`/mixins`) which wires the controller, the `pending` / `pending-label` properties, and click suppression. `ButtonBase` no longer owns pending state. | ||
| - **`@adobe/spectrum-wc`**: `swc-button` and `swc-action-button` now consume these primitives via `PendingMixin`. No public API change — `pending` / `pending-label` and the busy behavior are unchanged. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ | |
| */ | ||
|
|
||
| import { PropertyValues } from 'lit'; | ||
| import { property, state } from 'lit/decorators.js'; | ||
| import { property } from 'lit/decorators.js'; | ||
|
|
||
| import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; | ||
| import { | ||
|
|
@@ -60,43 +60,17 @@ export abstract class ButtonBase extends SizedMixin( | |
| @property({ type: Boolean, reflect: true }) | ||
| public disabled: boolean = false; | ||
|
|
||
| /** | ||
| * Whether the button is in a pending (busy) state. The button remains | ||
| * focusable but activation is suppressed. | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public pending: boolean = false; | ||
|
|
||
| /** | ||
| * Accessible label forwarded to the internal `<button>` element as | ||
| * `aria-label`. Required for icon-only buttons, which have no visible text. | ||
| */ | ||
| @property({ type: String, attribute: 'accessible-label' }) | ||
| public accessibleLabel?: string; | ||
|
|
||
| /** | ||
| * Custom accessible label used during the pending state. When omitted, | ||
| * the pending label is derived from the resolved non-busy accessible name | ||
| * plus a busy suffix (e.g. "Save, busy"). | ||
| */ | ||
| @property({ type: String, attribute: 'pending-label' }) | ||
| public pendingLabel?: string; | ||
|
|
||
| /** | ||
| * Tracks whether the pending visual (disabled colors + spinner) is currently | ||
| * active. Set to `true` after a 1-second delay once `pending` becomes true, | ||
| * so the button does not immediately flash to its unavailable appearance. | ||
| * Protected so subclasses can reference it in their `classMap` binding. | ||
| */ | ||
| @state() | ||
| protected pendingActive: boolean = false; | ||
|
|
||
| // ────────────────────── | ||
| // IMPLEMENTATION | ||
| // ────────────────────── | ||
|
|
||
| private _pendingTimer: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| protected get hasIcon(): boolean { | ||
| return this.slotContentIsPresent; | ||
| } | ||
|
|
@@ -116,21 +90,6 @@ export abstract class ButtonBase extends SizedMixin( | |
| return this.accessibleLabel ?? (this.textContent?.trim() || null); | ||
| } | ||
|
|
||
| /** | ||
| * Derives the pending-state accessible label. Prefers an explicit | ||
| * `pendingLabel`, then falls back to the resolved non-busy accessible | ||
| * name plus a ", busy" suffix, then a fixed "Busy" fallback. | ||
| * | ||
| * @internal | ||
| */ | ||
| protected getPendingAccessibleName(): string { | ||
| if (this.pendingLabel) { | ||
| return this.pendingLabel; | ||
| } | ||
| const resolvedName = this.getResolvedAccessibleName(); | ||
| return resolvedName ? `${resolvedName}, busy` : 'Busy'; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the set of attributes that should be forwarded to the internal | ||
| * semantic `<button>` element, if not otherwise directly managed. | ||
|
|
@@ -143,7 +102,6 @@ export abstract class ButtonBase extends SizedMixin( | |
| > { | ||
| return { | ||
| disabled: this.disabled, | ||
| 'aria-disabled': this.pending && !this.disabled ? 'true' : undefined, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -156,65 +114,26 @@ export abstract class ButtonBase extends SizedMixin( | |
|
|
||
| public override disconnectedCallback(): void { | ||
| this.removeEventListener('click', this.handleClick, true); | ||
| if (this._pendingTimer !== null) { | ||
| clearTimeout(this._pendingTimer); | ||
| this._pendingTimer = null; | ||
| } | ||
| this.pendingActive = false; | ||
| super.disconnectedCallback(); | ||
| } | ||
|
|
||
| /** | ||
| * Suppresses click activation while the button is `disabled` or `pending`. | ||
| * Suppresses click activation while the button is `disabled`. | ||
| * | ||
| * Slotted icon content lives in the light DOM, so pointer clicks on icons | ||
| * bypass the disabled inner `<button>` and bubble on the host. The host | ||
| * listener (capture) and inner `@click` binding both call this handler. | ||
| */ | ||
| protected readonly handleClick = (event: Event): void => { | ||
| if (this.disabled || this.pending) { | ||
| if (this.disabled) { | ||
| event.preventDefault(); | ||
| event.stopImmediatePropagation(); | ||
| } | ||
| }; | ||
|
|
||
| protected override update(changedProperties: PropertyValues): void { | ||
| if (changedProperties.has('pending')) { | ||
| if (this.pending) { | ||
| this._pendingTimer = setTimeout(() => { | ||
| if (this.pending) { | ||
| const internalButton = this.renderRoot.querySelector('button'); | ||
| if (internalButton) { | ||
| internalButton.style.setProperty( | ||
| '--_swc-button-pending-inline-size', | ||
| `${internalButton.offsetWidth}px` | ||
| ); | ||
| } | ||
| this.pendingActive = true; | ||
| } | ||
| this._pendingTimer = null; | ||
| }, 1000); | ||
| } else { | ||
| if (this._pendingTimer !== null) { | ||
| clearTimeout(this._pendingTimer); | ||
| this._pendingTimer = null; | ||
| } | ||
| this.renderRoot | ||
| .querySelector('button') | ||
| ?.style.removeProperty('--_swc-button-pending-inline-size'); | ||
| this.pendingActive = false; | ||
| } | ||
| } | ||
| super.update(changedProperties); | ||
| if (window.__swc?.DEBUG) { | ||
| if (this.pending && this.disabled) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this warning still has merit since it's about Button behavior expectations?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is present in the mixin, please check if that satisifies your concern |
||
| window.__swc.warn( | ||
| this, | ||
| `<${this.localName}> should not set both "pending" and "disabled" simultaneously. Use "pending" to keep the button focusable while unavailable, or "disabled" to fully remove it from the tab order.`, | ||
| 'https://opensource.adobe.com/spectrum-web-components/components/button/#pending', | ||
| { issues: ['pending + disabled'] } | ||
| ); | ||
| } | ||
| if (this.hasIcon && !this.hasLabel && !this.accessibleLabel) { | ||
| window.__swc.warn( | ||
| this, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /** | ||
| * Copyright 2026 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. | ||
| */ | ||
|
|
||
| export { | ||
| PendingController, | ||
| type PendingControllerHost, | ||
| type PendingControllerOptions, | ||
| } from './src/pending-controller.js'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| import { Canvas, Meta } from '@storybook/addon-docs/blocks'; | ||
| import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks'; | ||
|
|
||
| import * as Stories from './stories/pending-controller.stories'; | ||
|
|
||
| <Meta of={Stories} /> | ||
|
|
||
| <DocsHeader /> | ||
|
|
||
| ## Usage | ||
|
|
||
| `PendingController` manages a host element's pending (busy) _state_ so components do not reimplement the timing, layout, and accessibility-name concerns. It is one of three decoupled primitives: | ||
|
|
||
| - **`PendingController`** (`@spectrum-web-components/core/controllers/pending-controller`) — state (delayed activation, inline-size freeze, derived accessible name) and `renderPendingState()`, which renders the spinner so the host needs no separate directive import. | ||
| - **`renderPendingSpinner`** (`@spectrum-web-components/core/directives/pending-spinner`) — the underlying render-only directive. The controller owns and exposes it; use it directly only for a stateless spinner without a controller. | ||
| - **`PendingMixin`** (`@spectrum-web-components/core/mixins`) — wires the controller, the `pending` / `pending-label` properties, and click suppression onto a host in one step, and re-exposes `renderPendingState()`. | ||
|
|
||
| `swc-button` and `swc-action-button` opt in via `PendingMixin`. Components that cannot use the mixin can use the controller and directive directly. | ||
|
|
||
| ### What it does | ||
|
|
||
| - **Delayed activation** — `pendingActive` becomes `true` only after `delay` ms (default 1000). Operations that finish quickly never flash the busy appearance. ARIA, by contrast, is applied immediately by the host keyed off `pending`. | ||
| - **Inline-size freeze** — when the visual activates, the controller records the freeze target's measured width into `--swc-pending-inline-size`, which the host applies via `inline-size` so hiding the label or icon does not resize the host. | ||
| - **Derived accessible name** — `getPendingAccessibleName()` returns an explicit `pendingLabel`, else `"<resolved name>, busy"`, else `"Busy"`. | ||
|
|
||
| ### Basic usage | ||
|
|
||
| 1. Implement `PendingControllerHost` on your element (`pending`, optional `pendingLabel`). | ||
| 2. Construct the controller, passing `resolveAccessibleName` so it can derive the busy name. | ||
| 3. Read `pendingActive` in `render()` to apply busy styling, and render the spinner with the controller's own `renderPendingState()` — no separate directive import. | ||
| 4. Apply the busy ARIA in the host's template, keyed off `pending` for an immediate response. | ||
|
|
||
| ```typescript | ||
| import { html, LitElement } from 'lit'; | ||
| import { property } from 'lit/decorators.js'; | ||
| import { ifDefined } from 'lit/directives/if-defined.js'; | ||
| import { | ||
| PendingController, | ||
| type PendingControllerHost, | ||
| } from '@spectrum-web-components/core/controllers/pending-controller'; | ||
|
|
||
| class SwcThing extends LitElement implements PendingControllerHost { | ||
| @property({ type: Boolean, reflect: true }) pending = false; | ||
| @property({ attribute: 'pending-label' }) pendingLabel?: string; | ||
|
|
||
| private readonly pendingController = new PendingController(this, { | ||
| resolveAccessibleName: () => this.textContent?.trim() || null, | ||
| }); | ||
|
|
||
| render() { | ||
| return html` | ||
| <button | ||
| aria-disabled=${ifDefined(this.pending ? 'true' : undefined)} | ||
| aria-label=${ifDefined( | ||
| this.pending | ||
| ? this.pendingController.getPendingAccessibleName() | ||
| : undefined | ||
| )} | ||
| > | ||
| <slot></slot> | ||
| ${this.pendingController.renderPendingState()} | ||
| </button> | ||
| `; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Include the shared `pending-spinner.css` fragment (`swc/stylesheets/_lit-styles/pending-spinner.css`) in the host's styles so the rendered spinner is themed with Spectrum tokens. | ||
|
|
||
| ## Behaviors | ||
|
|
||
| ### Delayed activation | ||
|
|
||
| When `pending` becomes `true`, the controller waits `delay` ms before setting `pendingActive`. If `pending` returns to `false` before the delay elapses, the visual never activates. This avoids a distracting flash for fast operations while still announcing the busy state to assistive technology right away. The demo below uses a shortened 250 ms delay. | ||
|
|
||
| <Canvas of={Stories.DelayedActivation} /> | ||
|
|
||
| ### Derived accessible name | ||
|
|
||
| `getPendingAccessibleName()` prefers an explicit `pendingLabel`. When none is set, it appends `", busy"` to the name returned by `resolveAccessibleName` (for example, `"Save, busy"`), and falls back to `"Busy"` when no name is resolvable. The second host below overrides the derived name with an explicit `pending-label`. | ||
|
|
||
| <Canvas of={Stories.DerivedAccessibleName} /> | ||
|
|
||
| ### Focus retained on re-render | ||
|
|
||
| When `pendingActive` transitions from `false` to `true`, the controller calls `host.requestUpdate()`, which triggers a Lit re-render. Because lit-html patches the DOM in place rather than replacing elements, the focused `<button>` element is not destroyed; its class and content are updated around it. Focus remains on the button throughout the re-render. | ||
|
|
||
| Click the button below to start a pending operation. The button receives focus via the click and re-renders after the controller's delay. Focus is preserved. | ||
|
|
||
| <Canvas of={Stories.FocusRetained} /> | ||
|
|
||
| ## Accessibility | ||
|
|
||
| ### Features | ||
|
|
||
| - **Immediate busy announcement** — the host applies `aria-disabled="true"` and the busy `aria-label` keyed off `pending`, not the delayed `pendingActive`, so screen readers learn the element is busy without waiting for the visual. | ||
| - **Focus is retained** — pending differs from disabled: the element stays focusable so focus is not lost when it becomes busy. Activation is suppressed instead (by `PendingMixin`). | ||
| - **Presentational indicator** — the spinner markup is `aria-hidden` and not focusable; the busy state is conveyed through ARIA, not the spinner. | ||
|
|
||
| ### Best practices | ||
|
|
||
| - Provide a meaningful `resolveAccessibleName` (or an explicit `pendingLabel`) so the busy announcement is specific, e.g. "Save, busy" rather than "Busy". | ||
| - Do not set both `pending` and `disabled` on the same element. Use `pending` to keep it focusable while unavailable, or `disabled` to remove it from the tab order. | ||
| - Keep the host's busy styling on `pendingActive` (delayed), but its ARIA on `pending` (immediate). | ||
|
|
||
| <Canvas of={Stories.Accessibility} /> | ||
|
|
||
| ## API | ||
|
|
||
| ### Constructor | ||
|
|
||
| `new PendingController(host, options?)` — registers the controller on the host. | ||
|
|
||
| ### Members | ||
|
|
||
| | Member | Description | | ||
| | ---------------------------- | ------------------------------------------------------------------------------------------------------ | | ||
| | `pendingActive` | Read-only. `true` once the visual is active (after `delay`). Read this in `render()` to apply styling. | | ||
| | `getPendingAccessibleName()` | Returns the busy label: `pendingLabel`, else `"<resolved name>, busy"`, else `"Busy"`. | | ||
| | `renderPendingState()` | Renders the spinner for the current state (via the `renderPendingSpinner` directive), else `nothing`. | | ||
|
|
||
| ### Options (`PendingControllerOptions`) | ||
|
|
||
| | Option | Type | Default | Description | | ||
| | ----------------------- | ---------------------- | ------------ | ---------------------------------------------------------------------------------------------------- | | ||
| | `delay` | `number` | `1000` | Milliseconds to wait after `pending` becomes true before activating the visual. | | ||
| | `targetSelector` | `string \| null` | `'button'` | Selector (within `renderRoot`) for the element whose inline size is frozen. `null` skips the freeze. | | ||
| | `resolveAccessibleName` | `() => string \| null` | `() => null` | Returns the host's non-busy accessible name, used to derive the default busy label. | | ||
|
|
||
| ### Host interface (`PendingControllerHost`) | ||
|
|
||
| | Member | Type | Description | | ||
| | -------------- | --------- | --------------------------------------------------------- | | ||
| | `pending` | `boolean` | Whether the host is in a pending (busy) state. | | ||
| | `pendingLabel` | `string` | Optional explicit busy label; overrides the derived name. | | ||
|
|
||
| ### Related primitives | ||
|
|
||
| | Export | Module | Purpose | | ||
| | ---------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- | | ||
| | `renderPendingSpinner` | `@spectrum-web-components/core/directives/pending-spinner` | Underlying spinner render; exposed via the controller's `renderPendingState()`. | | ||
| | `PendingMixin` | `@spectrum-web-components/core/mixins` | Wire the controller + properties + click suppression; re-exposes the render. | | ||
|
|
||
| <DocsFooter /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to bring back click suppression while pending
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so this is in the base, i will make sure in the component its overridden