From 11d7c2fd4d1f4dfe83440f7ae9353a247e44e52a Mon Sep 17 00:00:00 2001 From: cixzhang Date: Fri, 26 Jun 2026 12:41:10 +0000 Subject: [PATCH 1/6] fix(ToggleButton): keep the button interruptible while pending 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. --- .changeset/button-is-interruptible.md | 13 ++++++ packages/core/src/Button/Button.doc.mjs | 6 +++ packages/core/src/Button/Button.test.tsx | 14 ++++++ packages/core/src/Button/Button.tsx | 16 ++++++- .../src/ToggleButton/ToggleButton.doc.mjs | 2 +- .../src/ToggleButton/ToggleButton.test.tsx | 25 +++++------ .../core/src/ToggleButton/ToggleButton.tsx | 43 +++---------------- 7 files changed, 65 insertions(+), 54 deletions(-) create mode 100644 .changeset/button-is-interruptible.md diff --git a/.changeset/button-is-interruptible.md b/.changeset/button-is-interruptible.md new file mode 100644 index 0000000000000..03d32d63dfbe9 --- /dev/null +++ b/.changeset/button-is-interruptible.md @@ -0,0 +1,13 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Button: add isInterruptible to keep the button clickable while loading +@cixzhang + +New `isInterruptible` prop on `Button`. When set, the loading state still +renders the spinner and `aria-busy`, but the button is not disabled — so +clicks keep landing and can interrupt an in-flight action. Defaults to +`false`, so existing `isLoading` behavior (spinner + disabled) is unchanged. +This is what lets `ToggleButton` show a pending spinner while remaining +interruptible. diff --git a/packages/core/src/Button/Button.doc.mjs b/packages/core/src/Button/Button.doc.mjs index ceb030e51c236..3e8ef88dc7cf5 100644 --- a/packages/core/src/Button/Button.doc.mjs +++ b/packages/core/src/Button/Button.doc.mjs @@ -78,6 +78,12 @@ export const docs = { description: 'Shows a loading spinner and disables interaction. Announces "Loading" via a live region.', default: 'false', }, + { + name: 'isInterruptible', + type: 'boolean', + description: 'Keep the button clickable while loading: the spinner and aria-busy still show, but the button is not disabled, so clicks keep landing and can interrupt the in-flight action.', + default: 'false', + }, { name: 'isDisabled', type: 'boolean', diff --git a/packages/core/src/Button/Button.test.tsx b/packages/core/src/Button/Button.test.tsx index 6b70636c5b8d7..24013edd76ec2 100644 --- a/packages/core/src/Button/Button.test.tsx +++ b/packages/core/src/Button/Button.test.tsx @@ -71,6 +71,20 @@ describe('Button', () => { expect(button).toBeDisabled(); }); + it('stays clickable while loading when isInterruptible is set', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render( + From ef266b630e1a0ebefa4f221133c193ea48cd08b1 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sat, 27 Jun 2026 23:01:28 +0000 Subject: [PATCH 6/6] fix(ToggleButton): keep async toggle interruptible via Button isInterruptible 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. --- .changeset/button-action-spinner-delay.md | 2 +- packages/core/src/Button/Button.doc.mjs | 6 +++ packages/core/src/Button/Button.test.tsx | 53 +++++++++++++++++++ packages/core/src/Button/Button.tsx | 35 +++++++++--- .../core/src/ToggleButton/ToggleButton.tsx | 19 +++++-- 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/.changeset/button-action-spinner-delay.md b/.changeset/button-action-spinner-delay.md index 66ecb9835f58d..b094ec3d37ef6 100644 --- a/.changeset/button-action-spinner-delay.md +++ b/.changeset/button-action-spinner-delay.md @@ -2,5 +2,5 @@ '@astryxdesign/core': patch --- -[fix] ToggleButton: pass the click event to `onPressedChange` so `event.preventDefault()` opts out of `pressedChangeAction`. The async toggle now runs through Button's `clickAction`. +[feat] Button: add `isInterruptible` to keep the button clickable while a `clickAction` is pending — the spinner and `aria-busy` still show, but the button is not disabled or deduped, so a re-click interrupts the in-flight action. ToggleButton's async toggle now runs through this path, staying interruptible. @cixzhang diff --git a/packages/core/src/Button/Button.doc.mjs b/packages/core/src/Button/Button.doc.mjs index ceb030e51c236..e73382749ca38 100644 --- a/packages/core/src/Button/Button.doc.mjs +++ b/packages/core/src/Button/Button.doc.mjs @@ -78,6 +78,12 @@ export const docs = { description: 'Shows a loading spinner and disables interaction. Announces "Loading" via a live region.', default: 'false', }, + { + name: 'isInterruptible', + type: 'boolean', + description: 'Keep the button clickable while a clickAction is pending: the spinner and aria-busy still show, but the button is not disabled and the action is not deduped, so a re-click lands and interrupts the in-flight action with a fresh one.', + default: 'false', + }, { name: 'isDisabled', type: 'boolean', diff --git a/packages/core/src/Button/Button.test.tsx b/packages/core/src/Button/Button.test.tsx index 6fb174798118b..52829ca8ba56e 100644 --- a/packages/core/src/Button/Button.test.tsx +++ b/packages/core/src/Button/Button.test.tsx @@ -299,6 +299,59 @@ describe('Button', () => { }); }); + it('stays clickable (not disabled) while a clickAction is pending when isInterruptible', async () => { + const user = userEvent.setup(); + let resolveAction: (() => void) | undefined; + const clickAction = vi.fn( + async () => + new Promise(resolve => { + resolveAction = resolve; + }), + ); + render(