feat(button): extract pending state into reusable core primitives#6439
feat(button): extract pending state into reusable core primitives#6439caseyisonit wants to merge 5 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: 83dacab 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🔍 First Generation 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 28112418066Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Warning No base build found for commit Coverage: 96.246%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → 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! ✨
| // ───────────────────────── | ||
| // LIFECYCLE | ||
| // ───────────────────────── | ||
|
|
There was a problem hiding this comment.
hostDisconnected resets _wasPending = false. On reconnect, hostUpdate is supposed to see _wasPending = false vs host.pending = true and restart the timer — but in Lit 3, reconnect doesn't schedule an update, so hostUpdate never runs.
| public hostConnected(): void { | |
| if (this._host.pending) { | |
| this._wasPending = true; | |
| this._activateAfterDelay(); | |
| } | |
| } | |
When the element reconnects while still pending, this restarts the timer immediately. The _wasPending = true prevents hostUpdate from starting a second timer if an update does happen to fire afterward.
| import { classMap } from 'lit/directives/class-map.js'; | ||
| import { ifDefined } from 'lit/directives/if-defined.js'; | ||
|
|
||
| import { renderPendingSpinner } from '../../../directives/pending-spinner/index.js'; |
There was a problem hiding this comment.
Should this be moved out of directives if it's a plain render function?
There was a problem hiding this comment.
Could you say more about this question?
There was a problem hiding this comment.
Ah, yeah! It's a plain function that returns HTML and doesn't use the Lit directive func or Directive class. I think it can stay here but the semantics don't necessarily line up with Lit directives.
| @property({ type: String, attribute: 'pending-label' }) | ||
| public pendingLabel?: string; | ||
|
|
||
| private _pendingController = new PendingController(this, { |
|
@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 |
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.