From 63c183d537b9240d3d2478a6afa5a43055d49894 Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Mon, 22 Jun 2026 14:51:53 -0600 Subject: [PATCH 1/6] docs(button): plan reusable pending primitives (SWC-2296) Rescoped plan to extract pending (busy) state into reusable, decoupled 2nd-gen primitives, built on top of main's current implementation (state in ButtonBase + shared renderPendingSpinner used by button and action-button). Confirmed direction: - PendingController (state) + thin PendingMixin (opt-in) in core - pendingSpinner directive in core/directives (render-only, token-free) - ButtonBase stays pending-agnostic; swc-button and swc-action-button apply the mixin; non-pending subclasses (e.g. CloseButton) unaffected - single PR Supersedes the exploration on caseyisonit/pending-controller (PR #6393). Co-Authored-By: Claude Opus 4.8 --- 2nd-gen/packages/swc/.storybook/preview.ts | 1 + .../03_components/README.md | 1 + .../pending-reusable-primitives-plan.md | 108 ++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/button/pending-reusable-primitives-plan.md diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 3fa046b671e..b952f3da960 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -370,6 +370,7 @@ const preview = { [ 'Accessibility migration analysis', 'Migration plan', + 'Pending reusable primitives plan', 'Rendering and styling migration analysis', ], 'Button group', diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index 80ff9b53469..976ac3809fe 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -45,6 +45,7 @@ - Button - [Button accessibility migration analysis](button/accessibility-migration-analysis.md) - [Button Migration Plan](button/migration-plan.md) + - [Pending reusable primitives plan](button/pending-reusable-primitives-plan.md) - [Button migration roadmap](button/rendering-and-styling-migration-analysis.md) - Button Group - [Button group accessibility migration analysis](button-group/accessibility-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/pending-reusable-primitives-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/pending-reusable-primitives-plan.md new file mode 100644 index 00000000000..6fe81acbcdb --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/button/pending-reusable-primitives-plan.md @@ -0,0 +1,108 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Button / Pending reusable primitives plan + + + +# Pending reusable primitives plan + + + +
+In this doc + +- [Goal (unchanged)](#goal-unchanged) +- [Current state on `main` (starting point)](#current-state-on-main-starting-point) + - [Gap vs. goal](#gap-vs-goal) +- [Proposed primitives](#proposed-primitives) +- [Behavior parity (must hold)](#behavior-parity-must-hold) +- [Scope](#scope) +- [Decisions (confirmed)](#decisions-confirmed) +- [Risks](#risks) +- [Checklist](#checklist) +- [References](#references) + +
+ + + +Plan to extract the pending (busy) state into reusable, decoupled 2nd-gen primitives so any pending-capable component can adopt it, not just `ButtonBase` subclasses. Tracked as [SWC-2296](https://jira.corp.adobe.com/browse/SWC-2296). + +Status: **planned — decisions confirmed, ready to implement.** This plan starts from the current `main` and supersedes the earlier exploration on `caseyisonit/pending-controller` / [PR #6393](https://github.com/adobe/spectrum-web-components/pull/6393), which diverged from a competing pending refactor that has since landed on `main`. Implementation branch: `caseyisonit/refactor-pending-primitives-swc-2296`. + +## Goal (unchanged) + +Provide reusable, decoupled pending primitives in 2nd-gen so any component with a busy state gets the agreed behavior without re-implementing it: + +- 1-second delayed busy visual (no flash for quick operations). +- Inline-size freeze so the host does not resize when its label/icon hide. +- Derived busy accessible name (`", busy"`) with `pending-label` override. +- `aria-disabled` while focusable (not native `disabled`); activation suppressed. +- An animated, themeable spinner. + +## Current state on `main` (starting point) + +`main` already shares pending three ways, but two of them are coupled to button: + +| Concern | Where it lives on `main` | Reusable by non-button components? | +| --- | --- | --- | +| State (delay timer, `pendingActive`, inline-size freeze, `getPendingAccessibleName`, `aria-disabled`, click suppression) | `core/components/button/Button.base.ts` (`ButtonBase`) | ❌ only via `extends ButtonBase` | +| Spinner render | `swc/components/button/pending-spinner.ts` → `renderPendingSpinner(pending, pendingActive)` | ⚠️ only by importing from the button package | +| Spinner styles | `swc/stylesheets/_lit-styles/pending-spinner.css` (`pendingSpinnerStyles`) | ✅ already in a neutral shared dir | + +Consumers today: `swc-button` and `swc-action-button` both `extends ButtonBase` and call `renderPendingSpinner(...)` + import `pendingSpinnerStyles`. + +### Gap vs. goal + +1. The **state logic is inheritance-locked** to `ButtonBase`. A pending-capable component that is not button-like (or that already has a different base) cannot reuse it. +2. The **render helper lives in the button component package**, so reusing it from elsewhere couples that component to button. + +## Proposed primitives + +1. **`PendingController`** (`core/controllers/pending-controller`) — owns the reusable *state*: the delayed `pendingActive` flag, the timer, the inline-size freeze (writes `--swc-pending-inline-size` on a configurable target), and `getPendingAccessibleName()` (via a `resolveAccessibleName` option). Rendering-agnostic; no token/styles dependency. +2. **`pendingSpinner` directive** (`core/directives/pending-spinner`) — move the render out of `swc/components/button/` into a render-only core directive (no token dependency) so any component can consume it without importing from the button package. The token-based spinner CSS stays as the `swc` `_lit-styles/pending-spinner.css` fragment. +3. **`PendingMixin`** (`core/mixins`) — a thin mixin that declares the `pending` / `pending-label` reactive properties, instantiates the controller, and wires click suppression, so a host opts in with one mixin. +4. **`ButtonBase` stays pending-agnostic** — it gains no pending logic. `swc-button` and `swc-action-button` each apply `PendingMixin` to opt in. Other `ButtonBase` subclasses that do not need pending (e.g. `CloseButton`) are unaffected. + +## Behavior parity (must hold) + +- ARIA (`aria-disabled`, busy `aria-label`) stays synchronous on `pending`; the visual treatment keys off the delayed `pendingActive`. +- Focusable while busy; activation suppressed; no native `disabled`. +- Static-color, reduced-motion, and forced-colors spinner treatments preserved. +- No public API change to `swc-button` / `swc-action-button` (`pending` / `pending-label` keep identical reflected attributes). + +## Scope + +In scope: the three primitives above; refactor `ButtonBase`; verify `swc-button` + `swc-action-button`; controller stories + per-unit MDX; tests; regenerate `global-*.css`. + +Out of scope (follow-ups): migrating other pending-capable components (Combobox, Picker); localizing the `", busy"` suffix. + +## Decisions (confirmed) + +1. **State primitive shape:** `PendingController` (state) **plus** a thin `PendingMixin` (one-line opt-in). Non-button hosts may use the controller directly. +2. **Spinner render home:** a **`core` directive** (`core/directives/pending-spinner`), render-only and token-free; the token-based CSS stays as the `swc` `_lit-styles/pending-spinner.css` fragment. +3. **`ButtonBase` role:** **stays fully pending-agnostic.** `swc-button` and `swc-action-button` each apply `PendingMixin`; subclasses that do not need pending (e.g. `CloseButton`) are unaffected. (This is the key change from `main`, where pending state lives in `ButtonBase` and is inherited by all subclasses.) +4. **Delivery:** **one PR** — controller + directive + mixin + both consumers together, so `main` is never left half-migrated. + +## Risks + +- Two components (`swc-button`, `swc-action-button`) and their generated `global-*.css` must stay visually identical — VRT/tests gate this. +- Relocating `renderPendingSpinner` touches both components' imports; the shared CSS fragment path may also move. +- `core` has no `token()`/`lit-css` build step, so any styles that move to `core` must be token-free; the token-based spinner CSS therefore stays in `swc` `_lit-styles`. + +## Checklist + +- [ ] `PendingController` in `core/controllers/pending-controller` (+ barrel + `core/package.json` exports + `typesVersions`) +- [ ] `pendingSpinner` directive in `core/directives/pending-spinner` (+ barrel + exports); `swc-button` + `swc-action-button` import it instead of `swc/components/button/pending-spinner.ts`; remove the old button-package helper +- [ ] `PendingMixin` in `core/mixins` (declares `pending`/`pending-label`, instantiates the controller, wires click suppression) +- [ ] `ButtonBase` left pending-agnostic (no pending code); `swc-button` + `swc-action-button` apply `PendingMixin`; confirm `CloseButton` and other non-pending subclasses are unaffected +- [ ] Controller stories + per-unit MDX; tests for controller, directive, and the two consumers +- [ ] core + swc build; `global-button.css` / `global-action-button.css` regenerated; lint + `lint:docs-pages` clean; VRT/behavior parity confirmed +- [ ] `SWC-2296` updated; this plan's checklist completed + +## References + +- Jira: [SWC-2296](https://jira.corp.adobe.com/browse/SWC-2296) +- Superseded exploration: [PR #6393](https://github.com/adobe/spectrum-web-components/pull/6393) (branch `caseyisonit/pending-controller`) +- Button migration plan: [migration-plan.md](./migration-plan.md) +- Current `main` implementation: `core/components/button/Button.base.ts`, `swc/components/button/pending-spinner.ts`, `swc/stylesheets/_lit-styles/pending-spinner.css` From 0a32eefa676365ccd28fe480d32c4fec2082be01 Mon Sep 17 00:00:00 2001 From: Casey Eickhoff Date: Mon, 22 Jun 2026 16:10:16 -0600 Subject: [PATCH 2/6] feat(button): extract pending state into reusable core primitives (SWC-2296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the pending (busy) state off ButtonBase into reusable, decoupled 2nd-gen core primitives so any pending-capable component can adopt it, and refactor button + action-button to consume them. - PendingController (core/controllers/pending-controller): state only — the 1s-delayed pendingActive flag, inline-size freeze via --swc-pending-inline-size, and the derived busy accessible name. - renderPendingSpinner (core/directives/pending-spinner): render-only, token-free directive; relocated out of the swc button package. The token-based pending-spinner.css fragment stays in swc _lit-styles. - PendingMixin (core/mixins): declares pending / pending-label, wires the controller, and suppresses activation while pending. - ButtonBase is now fully pending-agnostic; swc-button and swc-action-button apply PendingMixin. Non-pending ButtonBase subclasses (e.g. CloseButton) are unaffected. - Generalized the inline-size freeze custom property from the button-specific --_swc-button-pending-inline-size to --swc-pending-inline-size. - Controller stories + per-unit MDX; regenerated global-*.css. Wires directives as a new core entry-point category (vite.config + package.json exports/typesVersions). Behavior parity: aria-disabled, the derived busy aria-label, and click suppression stay synchronous on `pending`; the visual treatment keys off the delayed `pendingActive`. Co-Authored-By: Claude Opus 4.8 --- .../core/components/button/Button.base.ts | 87 +------ 2nd-gen/packages/core/controllers/index.ts | 5 + .../controllers/pending-controller/index.ts | 17 ++ .../pending-controller/pending-controller.mdx | 139 +++++++++++ .../src/pending-controller.ts | 215 ++++++++++++++++++ .../pending-controller/stories/demo-hosts.ts | 167 ++++++++++++++ .../stories/pending-controller.stories.ts | 135 +++++++++++ 2nd-gen/packages/core/directives/index.ts | 17 ++ .../core/directives/pending-spinner/index.ts | 13 ++ .../pending-spinner/src}/pending-spinner.ts | 17 +- 2nd-gen/packages/core/mixins/index.ts | 1 + 2nd-gen/packages/core/mixins/pending-mixin.ts | 144 ++++++++++++ 2nd-gen/packages/core/package.json | 42 ++++ 2nd-gen/packages/core/vite.config.js | 6 + .../components/action-button/ActionButton.ts | 11 +- .../action-button/action-button.css | 6 +- .../packages/swc/components/button/Button.ts | 11 +- .../packages/swc/components/button/button.css | 6 +- 18 files changed, 938 insertions(+), 101 deletions(-) create mode 100644 2nd-gen/packages/core/controllers/pending-controller/index.ts create mode 100644 2nd-gen/packages/core/controllers/pending-controller/pending-controller.mdx create mode 100644 2nd-gen/packages/core/controllers/pending-controller/src/pending-controller.ts create mode 100644 2nd-gen/packages/core/controllers/pending-controller/stories/demo-hosts.ts create mode 100644 2nd-gen/packages/core/controllers/pending-controller/stories/pending-controller.stories.ts create mode 100644 2nd-gen/packages/core/directives/index.ts create mode 100644 2nd-gen/packages/core/directives/pending-spinner/index.ts rename 2nd-gen/packages/{swc/components/button => core/directives/pending-spinner/src}/pending-spinner.ts (66%) create mode 100644 2nd-gen/packages/core/mixins/pending-mixin.ts diff --git a/2nd-gen/packages/core/components/button/Button.base.ts b/2nd-gen/packages/core/components/button/Button.base.ts index 783f7939c99..daf4e69e9b6 100644 --- a/2nd-gen/packages/core/components/button/Button.base.ts +++ b/2nd-gen/packages/core/components/button/Button.base.ts @@ -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,13 +60,6 @@ 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 ` + `; + } +} +``` + +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. + + + +### 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`. + + + +## 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). + + + +## 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 `", busy"`, else `"Busy"`. | + +### 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` | Render the `aria-hidden` animated spinner from the state. | +| `PendingMixin` | `@spectrum-web-components/core/mixins` | Wire the controller + properties + click suppression. | + + diff --git a/2nd-gen/packages/core/controllers/pending-controller/src/pending-controller.ts b/2nd-gen/packages/core/controllers/pending-controller/src/pending-controller.ts new file mode 100644 index 00000000000..71915d11586 --- /dev/null +++ b/2nd-gen/packages/core/controllers/pending-controller/src/pending-controller.ts @@ -0,0 +1,215 @@ +/** + * 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. + */ + +import type { ReactiveController, ReactiveElement } from 'lit'; + +// ───────────────────────── +// TYPES +// ───────────────────────── + +/** Minimum interface required from any element that hosts a {@link PendingController}. */ +export interface PendingControllerHost extends ReactiveElement { + /** Whether the host is in a pending (busy) state. */ + pending: boolean; + + /** + * Optional explicit accessible label used during the pending state. When + * omitted, the controller derives a busy label from + * {@link PendingControllerOptions.resolveAccessibleName}. + */ + pendingLabel?: string; +} + +/** Configuration options for {@link PendingController}. */ +export interface PendingControllerOptions { + /** + * Milliseconds to wait after `pending` becomes true before activating the + * pending visual, so the host does not flash to its busy appearance for + * operations that complete quickly. Defaults to `1000`. + */ + delay?: number; + + /** + * CSS selector, resolved within the host's `renderRoot`, for the element + * whose inline size should be frozen while pending (via + * `--swc-pending-inline-size`) so the host does not resize when its label or + * icon is hidden. Defaults to `'button'`. Pass `null` to skip the freeze. + */ + targetSelector?: string | null; + + /** + * Returns the host's non-busy accessible name, used to derive the default + * busy label (`", busy"`). Keeps the controller decoupled from how the + * host resolves its own name. + */ + resolveAccessibleName?: () => string | null; +} + +const DEFAULT_DELAY = 1000; + +/** + * Custom property the controller sets on the freeze target to lock its measured + * inline size while pending. Consumers apply it via `inline-size: var(...)`. + */ +const INLINE_SIZE_PROPERTY = '--swc-pending-inline-size'; + +// ───────────────────────── +// CONTROLLER +// ───────────────────────── + +/** + * A Lit {@link ReactiveController} that manages the pending (busy) *state* of a + * host element: the delayed visual activation, freezing the host's inline size + * while busy, and deriving the pending accessible name. Rendering is handled + * separately by the `renderPendingSpinner` directive + * (`@spectrum-web-components/core/directives/pending-spinner`); wiring (the + * `pending` / `pending-label` properties and click suppression) is handled by + * `PendingMixin` (`@spectrum-web-components/core/mixins`). + * + * The accessible name and `aria-disabled` are applied by the host's own + * template, keyed off `host.pending` for an immediate (non-delayed) response; + * this controller exposes {@link getPendingAccessibleName} for the host to read. + * + * @example + * ```ts + * const pendingController = new PendingController(this, { + * resolveAccessibleName: () => this.textContent?.trim() || null, + * }); + * // in render(): class=${classMap({ active: pendingController.pendingActive })} + * ``` + */ +export class PendingController implements ReactiveController { + private readonly _host: PendingControllerHost; + private readonly _delay: number; + private readonly _targetSelector: string | null; + private readonly _resolveAccessibleName: () => string | null; + + private _timer: ReturnType | null = null; + + /** Tracks the last-seen `pending` value so transitions can be detected in `hostUpdate`. */ + private _wasPending = false; + + /** + * Whether the pending visual is currently active. Becomes `true` only after + * the configured delay so the host does not flash to its busy appearance for + * operations that complete quickly. Read this from the host's `render`. + */ + public get pendingActive(): boolean { + return this._pendingActive; + } + private _pendingActive = false; + + constructor( + host: PendingControllerHost, + options: PendingControllerOptions = {} + ) { + this._host = host; + this._delay = options.delay ?? DEFAULT_DELAY; + this._targetSelector = + options.targetSelector === undefined ? 'button' : options.targetSelector; + this._resolveAccessibleName = options.resolveAccessibleName ?? (() => null); + host.addController(this); + } + + // ───────────────────────── + // PUBLIC API + // ───────────────────────── + + /** + * Derives the pending-state accessible label. Prefers an explicit + * `pendingLabel`, then the resolved non-busy name plus a ", busy" suffix, + * then a fixed "Busy" fallback. + */ + public getPendingAccessibleName(): string { + if (this._host.pendingLabel) { + return this._host.pendingLabel; + } + const resolvedName = this._resolveAccessibleName(); + return resolvedName ? `${resolvedName}, busy` : 'Busy'; + } + + // ───────────────────────── + // LIFECYCLE + // ───────────────────────── + + public hostUpdate(): void { + if (this._host.pending === this._wasPending) { + return; + } + this._wasPending = this._host.pending; + if (this._host.pending) { + this._activateAfterDelay(); + } else { + this._deactivate(); + } + } + + public hostDisconnected(): void { + this._clearTimer(); + this._wasPending = false; + this._pendingActive = false; + } + + // ───────────────────────── + // IMPLEMENTATION + // ───────────────────────── + + private _activateAfterDelay(): void { + this._timer = setTimeout(() => { + this._timer = null; + // Guard against `pending` flipping back to false after the timer fired + // but before this callback ran. + if (!this._host.pending) { + return; + } + this._freezeInlineSize(); + this._pendingActive = true; + this._host.requestUpdate(); + }, this._delay); + } + + private _deactivate(): void { + this._clearTimer(); + this._releaseInlineSize(); + if (this._pendingActive) { + this._pendingActive = false; + this._host.requestUpdate(); + } + } + + private _clearTimer(): void { + if (this._timer !== null) { + clearTimeout(this._timer); + this._timer = null; + } + } + + private get _freezeTarget(): HTMLElement | null { + if (!this._targetSelector) { + return null; + } + return this._host.renderRoot.querySelector( + this._targetSelector + ); + } + + private _freezeInlineSize(): void { + const target = this._freezeTarget; + if (target) { + target.style.setProperty(INLINE_SIZE_PROPERTY, `${target.offsetWidth}px`); + } + } + + private _releaseInlineSize(): void { + this._freezeTarget?.style.removeProperty(INLINE_SIZE_PROPERTY); + } +} diff --git a/2nd-gen/packages/core/controllers/pending-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/pending-controller/stories/demo-hosts.ts new file mode 100644 index 00000000000..d7d8b2f291e --- /dev/null +++ b/2nd-gen/packages/core/controllers/pending-controller/stories/demo-hosts.ts @@ -0,0 +1,167 @@ +/** + * 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. + */ + +import { css, html, LitElement, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import { renderPendingSpinner } from '../../../directives/pending-spinner/index.js'; +import { PendingController, type PendingControllerHost } from '../index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'demo-pending-host': DemoPendingHost; + } +} + +/** + * Short demo delay so the stories show the delayed activation without + * multi-second waits. Production hosts use the controller default (1000 ms). + */ +const DEMO_DELAY = 250; + +/** + * @internal + * + * Storybook-only host that pairs {@link PendingController} (state) with the + * `renderPendingSpinner` directive (render) directly, the way a non-button + * component would consume the primitives. It owns `pending` / `pendingLabel` + * (required by {@link PendingControllerHost}) plus a `label` used as the visible + * text and the resolved accessible name, applies the busy class from + * `pendingController.pendingActive`, keeps `aria-disabled` / `aria-label` + * synchronous with `pending`, and consumes `--swc-pending-inline-size` to freeze + * its width. Spinner styles are inlined here because the shared + * `pending-spinner.css` fragment lives in the `swc` package and is not reachable + * from `core`. + */ +@customElement('demo-pending-host') +export class DemoPendingHost + extends LitElement + implements PendingControllerHost +{ + static override styles = css` + button { + display: inline-flex; + gap: 8px; + align-items: center; + justify-content: center; + box-sizing: border-box; + min-inline-size: 6ch; + padding: 6px 16px; + font: inherit; + color: #fff; + background: #2680eb; + border: none; + border-radius: 16px; + cursor: pointer; + } + + button.is-pendingActive { + inline-size: var(--swc-pending-inline-size); + background: #b3b3b3; + cursor: default; + } + + button.is-pendingActive .label { + display: none; + } + + @keyframes demo-pending-spinner-rotate { + 0% { + transform: rotate(-90deg); + } + + 100% { + transform: rotate(270deg); + } + } + + @keyframes demo-pending-spinner-dashoffset { + 0%, + 100% { + stroke-dashoffset: 75px; + } + + 30% { + stroke-dashoffset: 20px; + } + } + + .swc-PendingSpinner { + display: none; + inline-size: 18px; + block-size: 18px; + } + + .swc-PendingSpinner--active { + display: inline-block; + } + + .swc-PendingSpinner-track, + .swc-PendingSpinner-fill { + inline-size: 18px; + block-size: 18px; + stroke-width: 2px; + } + + .swc-PendingSpinner-track { + stroke: rgb(255 255 255 / 40%); + } + + .swc-PendingSpinner-fill { + stroke: #fff; + transform: rotate(-90deg); + transform-origin: center; + animation: + demo-pending-spinner-rotate 1s cubic-bezier(0.6, 0.1, 0.3, 0.9) infinite, + demo-pending-spinner-dashoffset 1s cubic-bezier(0.25, 0.1, 0.25, 1.3) + infinite; + } + `; + + /** Whether the host is in a pending (busy) state. */ + @property({ type: Boolean, reflect: true }) + public pending = false; + + /** Explicit busy label; overrides the derived "