Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/pending-reusable-primitives.md
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.
4 changes: 2 additions & 2 deletions 1st-gen/tools/base/src/version.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
/**
* The version of the 1st-gen Spectrum Web Components library.
*/
export declare const version = "1.12.1";
export declare const version = "1.12.2";
/**
* The version of the core base package.
*/
export declare const coreVersion = "0.1.0";
export declare const coreVersion = "0.3.0";
4 changes: 2 additions & 2 deletions 1st-gen/tools/base/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
/**
* The version of the 1st-gen Spectrum Web Components library.
*/
export const version = '1.12.1';
export const version = '1.12.2';

/**
* The version of the core base package.
*/
export const coreVersion = '0.1.0';
export const coreVersion = '0.3.0';
87 changes: 3 additions & 84 deletions 2nd-gen/packages/core/components/button/Button.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Expand All @@ -143,7 +102,6 @@ export abstract class ButtonBase extends SizedMixin(
> {
return {
disabled: this.disabled,
'aria-disabled': this.pending && !this.disabled ? 'true' : undefined,
};
}

Expand All @@ -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`.

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor Author

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

*
* 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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,
Expand Down
5 changes: 5 additions & 0 deletions 2nd-gen/packages/core/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export {
LanguageResolutionController,
languageResolverUpdatedSymbol,
} from './language-resolution.js';
export {
PendingController,
type PendingControllerHost,
type PendingControllerOptions,
} from './pending-controller/index.js';
export {
ALL_PLACEMENTS,
fromFloatingPlacement,
Expand Down
17 changes: 17 additions & 0 deletions 2nd-gen/packages/core/controllers/pending-controller/index.ts
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 />
Loading
Loading