Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/button-action-spinner-delay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[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
13 changes: 13 additions & 0 deletions .changeset/togglebutton-onpressedchange-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@astryxdesign/core': patch
---

[fix] ToggleButton onPressedChange receives the click event for preventDefault opt-out
@cixzhang

`onPressedChange` now receives the originating click event as a second
argument. Calling `event.preventDefault()` skips `pressedChangeAction`, so a
consumer can handle the toggle entirely in `onPressedChange` without firing the
action — matching how `Switch`'s `onChange` and `Button`'s `onClick` already
gate their action props. Existing `(isPressed) => void` handlers keep working;
the event is an added trailing argument.
6 changes: 6 additions & 0 deletions packages/core/src/Button/Button.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/Button/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ describe('Button', () => {
expect(button).toBeDisabled();
});

it('sets aria-busy synchronously while clickAction is pending', async () => {
// The spinner reveal is visually delayed (CSS animation-delay), but the
// loading DOM state — aria-busy and disabled — must not be delayed.
const user = userEvent.setup();
let resolveAction: (() => void) | undefined;
const clickAction = vi.fn(
async () =>
new Promise<void>(resolve => {
resolveAction = resolve;
}),
);
render(<Button label="Save" clickAction={clickAction} />);
const button = screen.getByRole('button');

await user.click(button);
expect(button).toHaveAttribute('aria-busy', 'true');
expect(button).toBeDisabled();

await act(async () => {
resolveAction?.();
await Promise.resolve();
});
expect(button).not.toHaveAttribute('aria-busy', 'true');
expect(button).not.toBeDisabled();
});

it('renders the loading spinner with the inherit shade for every variant (#2717)', () => {
// The spinner must follow the button's resolved foreground color rather
// than a hardcoded white, so it keeps contrast on themed variants like the
Expand Down Expand Up @@ -273,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<void>(resolve => {
resolveAction = resolve;
}),
);
render(<Button label="Toggle" isInterruptible clickAction={clickAction} />);
const button = screen.getByRole('button');

await user.click(button);
// Loading is announced via aria-busy, but the button is not disabled so it
// can be re-clicked to interrupt the in-flight action.
expect(button).toHaveAttribute('aria-busy', 'true');
expect(button).not.toBeDisabled();

await act(async () => {
resolveAction?.();
await Promise.resolve();
});
expect(button).not.toHaveAttribute('aria-busy', 'true');
expect(button).not.toBeDisabled();
});

it('re-fires clickAction on re-click while pending when isInterruptible (no dedupe)', async () => {
// Unlike the fire-once default, an interruptible action is not deduped: a
// re-click while pending starts a fresh action that interrupts the prior.
const resolvers: (() => void)[] = [];
const clickAction = vi.fn(
async () =>
new Promise<void>(resolve => {
resolvers.push(resolve);
}),
);
render(<Button label="Toggle" isInterruptible clickAction={clickAction} />);

const button = screen.getByRole('button');
await act(async () => {
fireEvent.click(button);
});
await act(async () => {
fireEvent.click(button);
});
expect(clickAction).toHaveBeenCalledTimes(2);

await act(async () => {
resolvers.forEach(resolve => resolve());
await Promise.resolve();
});
});

// type/name/value/form props
it('defaults type to button', () => {
render(<Button label="Test" />);
Expand Down
80 changes: 73 additions & 7 deletions packages/core/src/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* - /apps/storybook/stories/Button.stories.tsx (storybook stories)
* - /packages/cli/templates/blocks/components/Button/ (showcase blocks)
*
* Last synced props: label, variant, size, isDisabled, isLoading, clickAction, icon, isIconOnly, children, tooltip, endContent, href, as, target, rel
* Last synced props: label, variant, size, isDisabled, isLoading, isInterruptible, clickAction, icon, isIconOnly, children, tooltip, endContent, href, as, target, rel
*/

import {useRef, useTransition, type ReactNode} from 'react';
Expand Down Expand Up @@ -320,6 +320,16 @@ export interface ButtonProps extends BaseProps<HTMLButtonElement> {
* @default false
*/
isLoading?: boolean;
/**
* Keep the button interactive while a `clickAction` is pending. The loading
* state still renders the spinner and `aria-busy`, but the button is not
* disabled and the in-flight action is not deduped — so a re-click lands and
* interrupts the previous action with a fresh one. Use for interruptible
* actions (e.g. a toggle whose action can be re-triggered before the previous
* one settles), not fire-once actions (submit/save/pay).
* @default false
*/
isInterruptible?: boolean;
/**
* Click handler. For async actions that should show a loading state,
* use `clickAction` instead.
Expand Down Expand Up @@ -380,13 +390,40 @@ export interface ButtonProps extends BaseProps<HTMLButtonElement> {
rel?: string;
}

const spinnerReveal = stylex.keyframes({
from: {opacity: 0},
to: {opacity: 1},
});

const contentHide = stylex.keyframes({
from: {color: 'inherit'},
to: {color: 'transparent'},
});

// Hold the loading swap for a short delay so a fast action (e.g. clickAction)
// that settles within the delay never flashes a spinner. The spinner fade-in
// and the content hide share the same delay so the button never shows an empty
// frame in between. Reduced motion is instant.
const SPINNER_DELAY = durationVars['--duration-medium-min'];

const loadingStyles = stylex.create({
// Hide the button's own content while the spinner overlay is shown. Applied
// to the content wrapper (not the button) so the button keeps its variant
// foreground color, which the spinner inherits via shade="inherit" (#2717).
hiddenContent: {
color: 'transparent',
},
// Delayed variant: keep content visible, then hide it in lockstep with the
// spinner reveal once the delay elapses.
hiddenContentDelayed: {
animationName: contentHide,
animationDuration: '1ms',
animationFillMode: 'forwards',
animationDelay: {
default: SPINNER_DELAY,
'@media (prefers-reduced-motion: reduce)': '0s',
},
},
spinnerOverlay: {
position: 'absolute',
top: 0,
Expand All @@ -396,6 +433,15 @@ const loadingStyles = stylex.create({
display: 'grid',
placeItems: 'center',
},
spinnerDelayed: {
animationName: spinnerReveal,
animationDuration: durationVars['--duration-fast'],
animationFillMode: 'backwards',
animationDelay: {
default: SPINNER_DELAY,
'@media (prefers-reduced-motion: reduce)': '0s',
},
},
});

const groupStyles = stylex.create({
Expand Down Expand Up @@ -493,6 +539,7 @@ export function Button({
type = 'button',
isDisabled = false,
isLoading = false,
isInterruptible = false,
clickAction,
icon,
isIconOnly = false,
Expand All @@ -513,12 +560,23 @@ export function Button({
const buttonGroup = useButtonGroup();

const [isPending, startTransition] = useTransition();
// clickAction is fire-once (submit/save/pay), so a same-tick double-click must
// dedupe — which neither isPending nor useOptimistic do. Hence the ref guard.
// clickAction is normally fire-once (submit/save/pay), so a same-tick
// double-click must dedupe — which neither isPending nor useOptimistic do.
// Hence the ref guard. Interruptible callers (e.g. ToggleButton) opt out so a
// re-click can land and interrupt the in-flight action with a fresh one.
const actionInFlightRef = useRef(false);
const isLoadingState = isLoading || isPending;
// Delay the spinner reveal for action-driven loading (clickAction's own
// transition) so a fast action that settles within the delay does not flash
// a spinner. Interruptible loading is delayed too, so rapid re-clicks settle
// before any spinner shows. Explicit isLoading-only stays immediate, since
// the consumer is deliberately showing it.
const delaySpinner = isPending || isInterruptible;
const groupDisabled = buttonGroup?.isDisabled ?? false;
const buttonDisabled = isDisabled || groupDisabled || isLoadingState;
// When interruptible, the loading state drives the spinner and aria-busy but
// not disabled, so clicks keep landing and can interrupt the in-flight action.
const buttonDisabled =
isDisabled || groupDisabled || (isLoadingState && !isInterruptible);
// isIconOnly prop is the source of truth for icon-only rendering.
// When false (default), label is always rendered as visible text.

Expand All @@ -533,7 +591,9 @@ export function Button({
const useAriaDisabled = tooltip != null && buttonDisabled;

const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (buttonDisabled || actionInFlightRef.current) {
// The ref guard dedupes fire-once actions. Interruptible callers skip it so
// a re-click while pending starts a fresh action that interrupts the prior.
if (buttonDisabled || (actionInFlightRef.current && !isInterruptible)) {
e.preventDefault();
return;
}
Expand Down Expand Up @@ -600,15 +660,21 @@ export function Button({
<>
{isLoadingState && (
<span
{...stylex.props(loadingStyles.spinnerOverlay)}
{...stylex.props(
loadingStyles.spinnerOverlay,
delaySpinner && loadingStyles.spinnerDelayed,
)}
aria-hidden="true">
<Spinner size="sm" shade="inherit" />
</span>
)}
<span
{...stylex.props(
styles.contentWrapper,
isLoadingState && loadingStyles.hiddenContent,
isLoadingState &&
(delaySpinner
? loadingStyles.hiddenContentDelayed
: loadingStyles.hiddenContent),
)}
aria-hidden={isLoadingState || undefined}>
{icon && (
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/ToggleButton/ToggleButton.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ export const docs = {
},
{
name: 'onPressedChange',
type: '(isPressed: boolean) => void',
description: 'Called when pressed state should change. Ignored when inside a group.',
type: '(isPressed: boolean, event: MouseEvent) => void',
description: 'Called when pressed state should change. Receives the next state and the click event; call event.preventDefault() to skip pressedChangeAction. Ignored when inside a group.',
},
{
name: 'pressedChangeAction',
type: '(isPressed: boolean) => void | Promise<void>',
description: 'Action handler for API- or navigation-backed toggles, run in a transition. Shows an optimistic pressed state immediately and a (debounced) spinner while pending; interruptible by re-clicks.',
description: 'Action handler for API- or navigation-backed toggles, run in a transition. Shows an optimistic pressed state immediately and a spinner while pending; the button stays interruptible by re-clicks.',
},
{
name: 'size',
Expand Down
Loading
Loading