Skip to content

fix(ToggleButton): pass click event to onPressedChange for preventDefault opt-out#3160

Merged
cixzhang merged 6 commits into
mainfrom
navi/fix/togglebutton-onpressedchange-event
Jun 27, 2026
Merged

fix(ToggleButton): pass click event to onPressedChange for preventDefault opt-out#3160
cixzhang merged 6 commits into
mainfrom
navi/fix/togglebutton-onpressedchange-event

Conversation

@cixzhang

@cixzhang cixzhang commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Based on main. Follow-up to #3056. The merged #3056 shipped the first-pass useTransition + optimistic version of ToggleButton, but the interruptible rework landed on the branch after the merge, so it did not reach main. This PR brings that rework forward, adds the preventDefault opt-out, and hooks up the CSS spinner delay on Button.

What's in here

1. Button.isInterruptible (new prop)

isInterruptible?: boolean // default false

When set, the loading state still renders the spinner and aria-busy, but the button is not disabled — so clicks keep landing. Defaults to false, so existing isLoading behavior (spinner + disabled) is unchanged.

2. Button: CSS spinner delay for action-driven loading

A fast clickAction (or an interruptible caller like ToggleButton) that settles quickly no longer flashes a spinner. The spinner reveal and content hide share a CSS animation-delay, so the loading swap only becomes visible if the action stays pending past the delay. This replaces the earlier JS debounce (useDelayed + a 150ms timer) with pure CSS at the Button layer, so it covers both clickAction and the toggle case.

  • The delay is purely visualaria-busy and the disabled state are set synchronously, so accessibility/DOM state is never delayed.
  • Removed under prefers-reduced-motion.
  • Explicit isLoading (controlled, neither pending nor interruptible) stays immediate — the consumer is deliberately showing it.

3. ToggleButton: interruptible while pending

Drops the JS spinner-debounce hack and relies on Button.isInterruptible + the CSS delay. The pending spinner no longer disables the button, so re-clicks always land and interrupt the in-flight action (e.g. true → false → true). The spinner is also suppressed for purely-local toggles (no async action), so a plain toggle never flashes one.

4. ToggleButton: preventDefault opt-out on onPressedChange

onPressedChange now receives the originating click event as a second argument:

onPressedChange?: (isPressed: boolean, event: React.MouseEvent<HTMLButtonElement>) => void

Calling event.preventDefault() skips pressedChangeAction, so a consumer can handle the toggle entirely in the callback. Mirrors the Switch.onChange / Button.onClick convention. The event is an added trailing argument, so existing (isPressed) => void handlers — including all current stories — are unaffected.

Test plan

  • Button: isInterruptible shows aria-busy but keeps the button clickable; clickAction sets aria-busy/disabled synchronously while pending and clears on settle (locking the "delay is visual-only" contract); default isLoading still disables.
  • ToggleButton: optimistic state shows + button stays interruptible while pending; loading clears on settle; rapid re-clicks interrupt (true → false → true); sync action supported; preventDefault skips the action.
  • Full Button + ToggleButton suites pass; core typecheck, doc typecheck, SYNC check, and changeset checks clean.
  • The spinner-delay timing itself is a visual behavior — best confirmed in Storybook / preview rather than jsdom.

Review feedback (from #3056)

  • Pass the event so consumers can preventDefault() — done.
  • Don't disable while pending; the toggle should stay interruptible — done via Button.isInterruptible.
  • Delay the spinner with CSS animation-delay instead of a JS timer — done at the Button layer (covers clickAction too).
  • Use isomorphic startTransition + derive isPending from optimistic state — kept useTransition deliberately: it re-throws action errors (isomorphic doesn't), and optimistic-derived isPending would read "not pending" on a false → true → false mid-flight toggle.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jun 26, 2026
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview, Comment Jun 27, 2026 11:05pm

Request Review

@cixzhang cixzhang force-pushed the navi/fix/togglebutton-onpressedchange-event branch 2 times, most recently from 75a64bf to d559ef6 Compare June 26, 2026 13:20
@cixzhang cixzhang changed the base branch from navi/fix/togglebutton-pressedchangeaction-transition to main June 26, 2026 13:20
@cixzhang cixzhang marked this pull request as ready for review June 26, 2026 19:27
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Vercel Preview Deployment

Status ✅ Deployed
Preview Open Preview
Commit 4f88be8
Inspect Vercel Dashboard
Workflow View Logs

No authentication required — anyone with the link can view the preview.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.6KB 0B

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Jun 26, 2026
Comment on lines +284 to +290
startTransition(async () => {
setOptimisticPressed(newState);
onPressedChangeProp?.(newState, event);
if (!event.defaultPrevented) {
await pressedChangeAction?.(newState);
}
});

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.

Avoid extra startTransition out in ToggleButton. Instead make use of Button's clickAction

cixzhang added 6 commits June 27, 2026 22:48
Replace the spinner debounce (useDelayed) with Button's new isInterruptible
prop. The pending spinner now shows immediately but no longer disables the
button, so re-clicks always land and interrupt the in-flight action instead
of racing a 150ms debounce window.

Adds Button.isInterruptible: loading still renders the spinner and aria-busy
but does not disable when set. ToggleButton only treats a transition as
pending when an async pressedChangeAction is present, so a purely local
toggle does not flash a spinner.
…ault opt-out

onPressedChange now receives the originating click event as a second
argument. Calling event.preventDefault() skips pressedChangeAction, so a
consumer can fully handle the toggle in onPressedChange without firing the
action — matching how Switch's onChange and Button's onClick gate their
action props. The event is an added trailing argument, so existing
(isPressed) => void handlers are unaffected.
A fast clickAction (or an interruptible caller like ToggleButton) that
settles quickly no longer flashes a spinner: the spinner reveal and content
hide share a CSS animation-delay, so the swap only happens if the action
stays pending past the delay. The delay is purely visual — aria-busy and the
disabled state are set synchronously — and is removed under
prefers-reduced-motion. Explicit isLoading stays immediate.
…; drop Button isInterruptible

ToggleButton no longer runs its own useTransition. The synchronous part
(onPressedChange + preventDefault opt-out, group mode, disabled) runs in
Button's onClick, and pressedChangeAction runs through Button's
clickAction, so Button owns the pending state and spinner. Removes the
now-unused Button isInterruptible prop. Drops the interrupt-on-re-click
behavior, which Button's fire-once clickAction guard does not provide.
…ruptible

Reintroduce Button's isInterruptible prop and wire it into the clickAction
path: when set, the loading state drives the spinner and aria-busy but does
not disable the button, and the fire-once ref guard is bypassed so a re-click
lands and interrupts the in-flight action with a fresh one.

ToggleButton renders Button interruptible and derives the next pressed state
from the optimistic value (not the committed one), so rapid re-clicks toggle
true -> false -> true while an action is pending instead of stalling.
@cixzhang cixzhang force-pushed the navi/fix/togglebutton-onpressedchange-event branch from a2bf69e to ef266b6 Compare June 27, 2026 23:02
github-actions Bot added a commit that referenced this pull request Jun 27, 2026
@cixzhang cixzhang merged commit 080d887 into main Jun 27, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants