feat(button): extract pending state into reusable core primitives#6439
feat(button): extract pending state into reusable core primitives#6439caseyisonit wants to merge 8 commits into
Conversation
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 <noreply@anthropic.com>
…C-2296) 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: f3d6d23 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📚 Branch Preview Links🔍 Gen1 Visual Regression Test ResultsWhen a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:
Deployed to Azure Blob Storage: If the changes are expected, update the |
Coverage Report for CI Build 28885046307Coverage increased (+0.002%) to 96.257%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
…r (SWC-2296) Ports the focus-retention coverage from #6411 (authored by Nikki Massaro), which was merged onto the abandoned caseyisonit/pending-controller branch and not carried into this rescoped branch. Adapted to the new primitives design: - demo-pending-host gains `click-pending` (click starts pending, focusing the button, auto-clears after 2s) to demonstrate focus retention across the controller-triggered re-render. - FocusRetained story + per-unit MDX section. - pending-controller.test.ts play test verifies aria is applied immediately on `pending`, the spinner + busy class appear after the delay, and shadowRoot.activeElement stays on the same internal button throughout (class assertion updated to the demo host's `is-pendingActive`). Co-Authored-By: Nikki Massaro <5090492+nikkimk@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cdransf
left a comment
There was a problem hiding this comment.
A few small things. Looks rad! ✨
|
@caseyisonit I think the direction here is right. Decoupling pending state behavior from But my main concern is that The rationale for exposing the controller as an escape hatch also seems a bit weak. Components using the controller still need to satisfy I wonder if a simpler approach would be to move the controller logic directly into A few smaller observations in your PR:
Overall, I like the goal and most of the implementation. My suggestion would be to collapse |
|
Overall, I agree this is the most idiomatic approach, but I also agree with Rajdeep that we could simplify it a bit 😄 RN, I can’t really see a strong reason to introduce a controller here, especially since we know this exact behaviour is only needed by two components. LMK what you think! |
…-2296) Have PendingController own and expose the spinner render through a new renderPendingState() method (delegating to the renderPendingSpinner directive), and re-expose it from PendingMixin. swc-button, swc-action-button, and the controller demo host now call this.renderPendingState() instead of importing the directive directly — one less import per consumer. The directive remains a standalone export for stateless use. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
After several discussion we are going to keep the controller and directive and its now wired up through the mixin and the controller owns the directive |
…pending-primitives-swc-2296 # Conflicts: # 2nd-gen/packages/swc/stylesheets/global/global-button.css
- Changeset bumps @spectrum-web-components/core to minor (new public exports), keeping @adobe/spectrum-wc at patch (internal refactor only). - PendingMixin guards the `disabled` read with `'disabled' in this` instead of a cast, and points the pending+disabled warning at the project root rather than a button-specific URL (the mixin is component-agnostic). - PendingController adds hostConnected() to re-arm the delay timer when an element reconnects while still pending (Lit 3 does not schedule an update on reconnect, so hostUpdate would not restart it). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pending-primitives-swc-2296
|
thanks for the thorough review @Rajdeepc 🙏 talked it over and we're going to keep the controller + mixin + directive split rather than collapse into one mixin, so passing on #6440 — but genuinely appreciate you writing it out, it made the tradeoff way easier to reason about. the way im thinking about it, each piece does one job and stands on its own:
on the "extra abstraction" point — youre right the mixin mostly delegates today, but the controller earning its keep isnt really the escape-hatch framing. its that its instance-based, so a component can run more than one pending region each tied to its own prop, instead of the single on the smaller notes are all in the latest push: changeset bumped to minor, the |
| implements PendingControllerHost | ||
| { | ||
| static override styles = css` | ||
| button { |
There was a problem hiding this comment.
[nit] can this use the shared pending spinner styles?
| } | ||
|
|
||
| /** The busy accessible name derives from the button's resolved name. */ | ||
| protected override resolvePendingAccessibleName(): string | null { |
There was a problem hiding this comment.
This doesn't seem to be used, aria-label is still calling this.getPendingAccessibleName() (same in ActionButton). I might not be understanding the hops, though.
|
|
||
| /** | ||
| * Suppresses click activation while the button is `disabled` or `pending`. | ||
| * Suppresses click activation while the button is `disabled`. |
There was a problem hiding this comment.
We need to bring back click suppression while pending
There was a problem hiding this comment.
so this is in the base, i will make sure in the component its overridden
| } | ||
| super.update(changedProperties); | ||
| if (window.__swc?.DEBUG) { | ||
| if (this.pending && this.disabled) { |
There was a problem hiding this comment.
I think this warning still has merit since it's about Button behavior expectations?
There was a problem hiding this comment.
this is present in the mixin, please check if that satisifies your concern
Description
Extracts the pending (busy) state out of
ButtonBaseinto reusable, decoupled 2nd-gen core primitives so any pending-capable component can adopt it, then refactorsswc-buttonandswc-action-buttonto consume them. Builds onmain's current implementation (sharedrenderPendingSpinner+ base-owned state) and supersedes the earlier exploration in #6393.PendingController(@spectrum-web-components/core/controllers/pending-controller) — state only: the 1-second-delayedpendingActiveflag, inline-size freeze via--swc-pending-inline-size, and the derived busy accessible name.renderPendingSpinner(@spectrum-web-components/core/directives/pending-spinner) — render-only, token-free directive, relocated out of theswcbutton package so non-button components can reuse it. The token-basedpending-spinner.cssfragment stays inswc_lit-styles. (Wiresdirectivesin as a new core entry-point category.)PendingMixin(@spectrum-web-components/core/mixins) — declarespending/pending-label, instantiates the controller, and suppresses activation while pending.ButtonBaseis now fully pending-agnostic.swc-buttonandswc-action-buttonapplyPendingMixin; non-pendingButtonBasesubclasses (e.g.CloseButton) are unaffected.Behavior parity
aria-disabled, the derived busyaria-label, and click suppression stay synchronous onpending; the visual treatment (disabled palette, spinner, frozen size) keys off the delayedpendingActive. No public API change toswc-button/swc-action-button. The generatedglobal-*.cssis unchanged by the refactor (pending is@global-exclude); the small diff is generator de-staling.Motivation and context
Pending was inheritance-locked to
ButtonBaseand the spinner render lived inside the button package, so non-button busy controls couldn't reuse either. This lifts both into dedicated core primitives alongside the other reactive controllers, directives, and mixins.Planning doc:
CONTRIBUTOR-DOCS/03_project-planning/03_components/button/pending-reusable-primitives-plan.md.Related issue(s)
Author's checklist
Reviewer's checklist
patch,minor, ormajorfeaturesManual review test cases
Button + action-button pending visual is unchanged
pendingon a labeled instance.Pending is announced and non-activatable while focusable
<button>hasaria-disabled="true", no nativedisabled, andaria-label= the derived"<name>, busy"(or explicitpending-label).Pending controller docs render
Device review
Accessibility testing checklist
Keyboard (required — document steps below)
pendinginstance.pendingand confirm activation works. Confirm adisabledinstance is skipped in the tab order (distinct from pending).Screen reader (required — document steps below)
buttonrole announce.pending; confirm it is announced as unavailable (aria-disabled) and busy by name ("<name>, busy", or the explicitpending-label).aria-hidden). Clearpendingand confirm the name returns to normal.