From b36c9033d9d4c7755b39617d6c56c8945d6cb42f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 15:27:03 -0400 Subject: [PATCH 1/2] feat(ui): add Mosaic SubmitButton A `Button` that defaults `type` to `submit` and takes `isPending`. While pending it fades its label to zero opacity and centers a spinner over it, so the button keeps its width and nothing around it reflows. The pending state applies immediately; only the spinner is delayed, so a fast action never flashes one. `spinDelay` tunes both ends of that window. The button goes inert via `aria-disabled` rather than the `disabled` attribute, so it stays focusable and its progressbar stays announced. Brings `Spinner` and `useSpinDelay` over from carp/account-button-switcher. --- .changeset/tender-months-attack.md | 2 + packages/swingset/src/stories/button.mdx | 92 +++++ .../swingset/src/stories/button.stories.tsx | 93 ++++- .../mosaic/components/button/button.styles.ts | 23 +- .../src/mosaic/components/button/button.tsx | 2 +- .../ui/src/mosaic/components/button/index.ts | 5 + .../components/button/submit-button.styles.ts | 53 +++ .../components/button/submit-button.test.tsx | 359 ++++++++++++++++++ .../components/button/submit-button.tsx | 118 ++++++ .../ui/src/mosaic/components/spinner/index.ts | 2 + .../components/spinner/spinner.styles.ts | 39 ++ .../components/spinner/spinner.test.tsx | 52 +++ .../src/mosaic/components/spinner/spinner.tsx | 36 ++ .../hooks/__tests__/useSpinDelay.test.ts | 88 +++++ packages/ui/src/mosaic/hooks/useSpinDelay.ts | 58 +++ packages/ui/src/mosaic/styles/index.ts | 6 +- 16 files changed, 1015 insertions(+), 13 deletions(-) create mode 100644 .changeset/tender-months-attack.md create mode 100644 packages/ui/src/mosaic/components/button/submit-button.styles.ts create mode 100644 packages/ui/src/mosaic/components/button/submit-button.test.tsx create mode 100644 packages/ui/src/mosaic/components/button/submit-button.tsx create mode 100644 packages/ui/src/mosaic/components/spinner/index.ts create mode 100644 packages/ui/src/mosaic/components/spinner/spinner.styles.ts create mode 100644 packages/ui/src/mosaic/components/spinner/spinner.test.tsx create mode 100644 packages/ui/src/mosaic/components/spinner/spinner.tsx create mode 100644 packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts create mode 100644 packages/ui/src/mosaic/hooks/useSpinDelay.ts diff --git a/.changeset/tender-months-attack.md b/.changeset/tender-months-attack.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tender-months-attack.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx index e0ce70678e9..7b2599f83a9 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -123,6 +123,98 @@ by the styles, not by `pointer-events`: the button stays hit-testable, which is `cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled. That tooltip is worth adding — a disabled control with no explanation is a dead end. +### Submitting + +`SubmitButton` is a `Button` that defaults `type` to `submit` and adds `isPending`. Use it for +the button that commits a form; reach for plain `Button` everywhere else. + + + +Press the button above to run a stand-in action: it goes pending for two seconds, then comes back. + +While `isPending`, the label fades to zero opacity and a spinner centers over it. The label stays +mounted rather than being swapped out, so the button holds the width its content gives it — watch +that it doesn't resize across the flip — and nothing around it reflows. Every child sits in one +box, so an icon fades with its label instead of hanging on beside the spinner. + +A pending button is inert but not `disabled`: it carries `aria-disabled`, drops its pointer events +so hover and press stop firing, and cancels the press so the form can't be submitted twice. The +native `disabled` attribute would do all of that too, but it takes the button out of the tab order +mid-action — pulling focus away at the exact moment the spinner is announced. The state is also +reflected as `data-pending` for styling. + +#### The spinner is delayed + +The button becomes pending the instant `isPending` flips, but the spinner waits 300ms before it's +drawn, then stays up at least 200ms once it is. Plenty of actions resolve faster than a spinner +takes to read, and one that appears and vanishes inside a few frames registers as a glitch rather +than as progress. + + + +Nothing about the pending _state_ is delayed — only the pixels. Both buttons above go inert and +announce themselves the moment they're pressed, which is what stops a double submit; the fast one +simply finishes before its spinner is due. + +Both numbers move with `spinDelay`. An action already known to be slow has nothing to gain by +waiting, so it can skip straight to the spinner: + +```tsx + + Save changes + +``` + +#### What assistive tech gets + +The spinner is decorative everywhere else in Mosaic, but here it is the only signal the action is +running, so it enters the accessibility tree as an indeterminate `progressbar` the moment +`isPending` flips — including during the delay above, when it's mounted but not yet drawn. That's +why the delay is `opacity` and not conditional rendering: `visibility: hidden` or `display: none` +would take it back out of the tree, and so would not rendering it. Fading the label with `opacity` +is the same call — it keeps the button named "Save changes" for the whole action instead of going +briefly nameless. + +The indicator is named in its own right, via `pendingLabel` (default `pending`). It is not folded +into the button's name: `progressbar` is a range role, so name computation reads its _value_ — +absent, since it's indeterminate — rather than its label, and a descendant one contributes nothing +to the button above it. `pendingLabel` is untranslated, so pass a localized string wherever the +surrounding copy is localized. + +```tsx + + Save changes + +``` + + + +The spinner is sized off the `Icon` scale, since it stands in for one. That scale stops at `md`, +so `md` and `lg` buttons share the larger ring. + + + +Both the ring and its arc are mixed from `currentColor`, so the spinner reads on a `filled` +button's fill and on a bare surface alike without a color prop to keep in step with the button's. + ### Touch target Every size is shorter than the 44px a fingertip needs, so under `pointer: coarse` the button grows diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index e2e6fefc3a2..e74c512b977 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import type { ButtonProps } from '@clerk/ui/mosaic/components/button'; -import { Button } from '@clerk/ui/mosaic/components/button'; +import { Button, SubmitButton } from '@clerk/ui/mosaic/components/button'; import { Icon } from '@clerk/ui/mosaic/components/icon'; import React from 'react'; @@ -301,3 +301,94 @@ export function Disabled(props: Record) { ); } + +// Stands in for an async submit, so the example can be pressed and the flip between the two +// states watched — including that the button doesn't resize under the spinner. +function usePendingOnPress(duration = 2000) { + const [isPending, setIsPending] = React.useState(false); + const timeout = React.useRef>(undefined); + + React.useEffect(() => () => clearTimeout(timeout.current), []); + + return { + isPending, + onClick: () => { + setIsPending(true); + timeout.current = setTimeout(() => setIsPending(false), duration); + }, + }; +} + +export function Submit(props: Record) { + const { isPending, onClick } = usePendingOnPress(); + return ( + + Save changes + + ); +} + +// Press both: only the slow one ever draws a spinner. The fast one is pending the whole time it +// says it is — it just finishes before the spinner is due, so nothing flashes. +export function SubmitDelay(props: Record) { + const slow = usePendingOnPress(2000); + const fast = usePendingOnPress(150); + return ( +
+ + Slow action + + + Fast action + +
+ ); +} + +export function SubmitSizes(props: Record) { + return ( +
+ {(['sm', 'md', 'lg'] as const).map(size => ( + + Save changes + + ))} +
+ ); +} + +// The spinner takes its arc from `currentColor`, so it reads on a fill and on a bare surface +// alike — no color prop to keep in step with the button's. +export function SubmitVariants(props: Record) { + return ( +
+ {(['filled', 'outline', 'ghost'] as const).map(variant => ( + + Save changes + + ))} +
+ ); +} diff --git a/packages/ui/src/mosaic/components/button/button.styles.ts b/packages/ui/src/mosaic/components/button/button.styles.ts index 536fa32db13..6b6b51428e8 100644 --- a/packages/ui/src/mosaic/components/button/button.styles.ts +++ b/packages/ui/src/mosaic/components/button/button.styles.ts @@ -57,6 +57,11 @@ const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negativ // Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys` // reads a computed key as its identifier name and fails the ordering. // +// `:active` also excludes `[data-pending]`, which `SubmitButton` sets while its action runs. That +// button drops its pointer events, which is enough for the pointer, but a focused button still +// takes `:active` from the keyboard — space and enter — and a pending button shouldn't flash a +// pressed fill for a press it ignores. +// // `[data-open]` takes the pressed fill too, so a button acting as a disclosure trigger stays // visibly engaged for as long as its surface is open. Disclosure primitives set it on the // trigger (`popover-trigger.tsx` and friends); a plain button never carries it. It is excluded @@ -186,7 +191,7 @@ export const variants = stylex.create({ }, backgroundColor: { default: colorVars['--cl-color-primary'], - ':enabled:active': primaryActive, + ':enabled:not([data-pending]):active': primaryActive, ':enabled[data-open]': primaryActive, '@media (hover: hover)': { default: null, @@ -206,7 +211,7 @@ export const variants = stylex.create({ }, backgroundColor: { default: neutralStep0, - ':enabled:active': neutralStep2, + ':enabled:not([data-pending]):active': neutralStep2, ':enabled[data-open]': neutralStep2, '@media (hover: hover)': { default: null, @@ -226,7 +231,7 @@ export const variants = stylex.create({ }, backgroundColor: { default: colorVars['--cl-color-negative'], - ':enabled:active': negativeActive, + ':enabled:not([data-pending]):active': negativeActive, ':enabled[data-open]': negativeActive, '@media (hover: hover)': { default: null, @@ -251,7 +256,7 @@ export const variants = stylex.create({ borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', - ':enabled:active': neutralStep1, + ':enabled:not([data-pending]):active': neutralStep1, ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, @@ -272,7 +277,7 @@ export const variants = stylex.create({ borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', - ':enabled:active': neutralStep1, + ':enabled:not([data-pending]):active': neutralStep1, ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, @@ -293,7 +298,7 @@ export const variants = stylex.create({ borderColor: colorVars['--cl-color-border'], backgroundColor: { default: 'transparent', - ':enabled:active': neutralStep1, + ':enabled:not([data-pending]):active': neutralStep1, ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, @@ -314,7 +319,7 @@ export const variants = stylex.create({ }, backgroundColor: { default: 'transparent', - ':enabled:active': neutralStep1, + ':enabled:not([data-pending]):active': neutralStep1, ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, @@ -334,7 +339,7 @@ export const variants = stylex.create({ }, backgroundColor: { default: 'transparent', - ':enabled:active': neutralStep1, + ':enabled:not([data-pending]):active': neutralStep1, ':enabled[data-open]': neutralStep1, '@media (hover: hover)': { default: null, @@ -356,7 +361,7 @@ export const variants = stylex.create({ }, backgroundColor: { default: 'transparent', - ':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, + ':enabled:not([data-pending]):active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, ':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`, '@media (hover: hover)': { default: null, diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 83b4bd8b4bb..201ee833754 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -49,7 +49,7 @@ export interface ButtonProps extends MosaicElementProps<'button'> { // adjacent text shares one box, or `Delete {name}` would split into two flex items with the // button's `gap` opening up mid-sentence. Element children (icons) pass through untouched, // so they stay direct flex items and `gap` still applies. -function withTruncatableLabel(children: React.ReactNode): React.ReactNode { +export function withTruncatableLabel(children: React.ReactNode): React.ReactNode { const result: React.ReactNode[] = []; let run: React.ReactNode[] = []; diff --git a/packages/ui/src/mosaic/components/button/index.ts b/packages/ui/src/mosaic/components/button/index.ts index 588b977f11c..7279cfde856 100644 --- a/packages/ui/src/mosaic/components/button/index.ts +++ b/packages/ui/src/mosaic/components/button/index.ts @@ -1,2 +1,7 @@ export { Button } from './button'; export type { ButtonProps } from './button'; +export { SubmitButton } from './submit-button'; +export type { SubmitButtonProps } from './submit-button'; +// Named here rather than only inside `SubmitButtonProps`, so a consumer can type the object they +// pass to `spinDelay`. +export type { SpinDelayOptions } from '../../hooks/useSpinDelay'; diff --git a/packages/ui/src/mosaic/components/button/submit-button.styles.ts b/packages/ui/src/mosaic/components/button/submit-button.styles.ts new file mode 100644 index 00000000000..39d84077083 --- /dev/null +++ b/packages/ui/src/mosaic/components/button/submit-button.styles.ts @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + // The containing block the spinner centers against. Unconditional, so the button's stacking + // and its coarse-pointer `::after` overlay behave the same in both states. + root: { + position: 'relative', + }, + // Button gates its hover and pressed fills on `:enabled`, which a pending button still is — + // `aria-disabled` keeps it focusable, so the native attribute is out. Dropping pointer events + // stops `:hover` and `:active` matching for the pointer in one line, across every variant cell. + // Unlike `disabled` there's nothing lost by it: pending is self-explanatory and brief, so the + // button isn't carrying a tooltip that has to stay hoverable to explain itself. + // + // It doesn't cover the keyboard, though — a focused button still takes `:active` from space and + // enter with no pointer involved. That half is handled where the fills are declared, by the + // `:not([data-pending])` on each cell's active selector in `button.styles.ts`. + rootPending: { + pointerEvents: 'none', + }, + + // One box around every child, so the whole content fades as a unit rather than per-run. It + // stands in for the button's own content row — `gap` picks up whatever the size axis set — + // so an icon and its label keep their spacing across the extra nesting level. + content: { + gap: 'inherit', + alignItems: 'center', + display: 'inline-flex', + // Releases the flex-item min-width floor so the label boxes inside can still clip. + minWidth: 0, + }, + // Opacity rather than unmounting the label or swapping in the spinner: the content keeps its + // box, so the button holds its width and nothing around it reflows when the state flips. + contentPending: { + opacity: 0, + }, + + // Out of flow and centered by `inset: 0` + `margin: auto`, which resolves against the padding + // box on both axes without a transform and stays correct under any writing mode. + spinner: { + margin: 'auto', + insetBlock: 0, + insetInline: 0, + position: 'absolute', + }, + // The spinner mounts the instant the action starts but waits out `useSpinDelay` before it is + // drawn, so a fast action never flashes one. Hiding it with `opacity` rather than by not + // rendering it is what keeps the progressbar in the accessibility tree for the whole action — + // `visibility: hidden` or `display: none` would take it back out. + spinnerHidden: { + opacity: 0, + }, +}); diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx new file mode 100644 index 00000000000..82bce873100 --- /dev/null +++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx @@ -0,0 +1,359 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SubmitButton } from './submit-button'; + +/** The spinner is decorative, so it has no role or name to query — only its slot class. */ +const spinner = () => document.querySelector('.cl-spinner'); +const content = () => screen.getByRole('button').firstElementChild; + +/** + * StyleX classes, as a set. Which atoms carry which declaration is an implementation detail, so + * the visual assertions below compare one state's atoms against another's rather than naming any. + */ +const atoms = (el: Element | null | undefined) => el?.className.split(' ').filter(Boolean) ?? []; +const isSupersetOf = (all: string[], some: string[]) => some.every(atom => all.includes(atom)); + +describe('Mosaic SubmitButton', () => { + it('renders a submit button with its children', () => { + render(Save); + const button = screen.getByRole('button', { name: 'Save' }); + expect(button).toHaveClass('cl-button'); + expect(button).toHaveAttribute('type', 'submit'); + }); + + it('lets the consumer override the type', () => { + render(Save); + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); + }); + + it('boxes every child in one content span', () => { + render( + + + Save + , + ); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(1); + expect(content()?.tagName).toBe('SPAN'); + expect(content()).toContainElement(screen.getByTestId('icon')); + expect(button).toHaveAccessibleName('Save'); + }); + + it('still gives a text run its own box to truncate against', () => { + render(Save); + const label = content()?.firstElementChild; + expect(label?.tagName).toBe('SPAN'); + expect(label).toHaveTextContent('Save'); + }); + + it('renders no spinner and announces nothing while idle', () => { + render(Save); + const button = screen.getByRole('button'); + expect(spinner()).not.toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect(button).not.toHaveAttribute('aria-busy'); + expect(button).not.toHaveAttribute('aria-disabled'); + expect(button).not.toHaveAttribute('data-pending'); + }); + + it('renders the spinner and reflects the pending state', () => { + render(Save); + const button = screen.getByRole('button'); + expect(spinner()).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toHaveAttribute('data-pending', ''); + }); + + // The indicator is only decorative when nothing depends on it being announced; here it is the + // sole signal that the action is running, so it has to reach the accessibility tree. + it('puts the indicator in the accessibility tree as an indeterminate progressbar', () => { + render(Save); + const bar = screen.getByRole('progressbar', { name: 'pending' }); + expect(bar).toBe(spinner()); + expect(bar).not.toHaveAttribute('aria-hidden'); + expect(bar).not.toHaveAttribute('aria-valuenow'); + }); + + it('lets the consumer name the indicator', () => { + render( + + Save + , + ); + expect(screen.getByRole('progressbar', { name: 'Saving' })).toBeInTheDocument(); + }); + + // Fading the label rather than unmounting it keeps the button named for the whole action. The + // indicator is named separately: a `progressbar` descendant is a range role, so the name + // computation reads its value rather than its label and it contributes nothing here. + it('keeps the button named by its own label while pending', () => { + render(Save); + expect(screen.getByRole('button')).toHaveAccessibleName('Save'); + }); + + // The whole point of fading the label rather than swapping it out: the button keeps the + // width its content gives it, so nothing around it reflows when the state flips. + it('keeps the label mounted while pending so the button holds its width', () => { + render(Save); + expect(content()).toHaveTextContent('Save'); + }); + + // `aria-disabled` rather than the `disabled` attribute: the button stays focusable, so focus + // isn't dropped mid-action just as the progressbar is announced. + it('marks itself disabled to assistive tech while pending but stays focusable', async () => { + render(Save); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toBeEnabled(); + + await userEvent.tab(); + expect(button).toHaveFocus(); + }); + + it('does not submit its form while pending', async () => { + const onSubmit = vi.fn(event => event.preventDefault()); + const onClick = vi.fn(); + render( +
+ + Save + +
, + ); + + await userEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('submits its form once it is no longer pending', async () => { + const onSubmit = vi.fn(event => event.preventDefault()); + render( +
+ Save +
, + ); + + await userEvent.click(screen.getByRole('button')); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('renders the spinner after the content so it can center over the whole button', () => { + render(Save); + const button = screen.getByRole('button'); + expect(button.children).toHaveLength(2); + expect(button.lastElementChild).toBe(spinner()); + }); + + it.each([ + ['sm', 'sm'], + ['md', 'md'], + ['lg', 'md'], + ] as const)('sizes the spinner to %s with the %s step', (size, expected) => { + render( + + Save + , + ); + expect(spinner()).toHaveAttribute('data-size', expected); + }); + + it('wires the button variant props and consumer className/style through', () => { + render( + + Save + , + ); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('data-color', 'negative'); + expect(button).toHaveAttribute('data-variant', 'outline'); + expect(button).toHaveAttribute('data-size', 'sm'); + expect(button).toHaveAttribute('data-full-width', ''); + expect(button).toHaveClass('cl-button', 'my-button'); + expect(button).toHaveStyle({ marginTop: '8px' }); + }); + + it('keeps the isPending prop off the element', () => { + render(Save); + expect(screen.getByRole('button')).not.toHaveAttribute('ispending'); + }); + + it('calls onClick when pressed', async () => { + const onClick = vi.fn(); + render(Save); + await userEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('forwards arbitrary button props and the ref', () => { + const ref = React.createRef(); + render( + + Save + , + ); + const button = screen.getByRole('button'); + expect(ref.current).toBe(button); + expect(button).toHaveAttribute('form', 'profile'); + }); +}); + +// The pending *state* is immediate — it has to be, or a fast action could be submitted twice and +// assistive tech would miss it. Only the pixels are delayed, so an action that resolves in a +// couple of frames never flashes a spinner at all. +describe('Mosaic SubmitButton spin delay', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const advance = (ms: number) => act(() => void vi.advanceTimersByTime(ms)); + + it('marks itself pending immediately, before the spinner is due to show', () => { + render(Save); + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveAttribute('data-pending', ''); + }); + + // `opacity`, not conditional rendering: the indicator has to be in the accessibility tree the + // moment the action starts, whether or not it is on screen yet. + it('mounts the indicator immediately and reveals it once the delay elapses', () => { + render(Save); + const hidden = atoms(spinner()); + expect(screen.getByRole('progressbar', { name: 'pending' })).toBeInTheDocument(); + + advance(300); + const shown = atoms(spinner()); + expect(hidden.length).toBeGreaterThan(shown.length); + expect(isSupersetOf(hidden, shown)).toBe(true); + }); + + it('keeps the label at full opacity until the spinner shows', () => { + const { rerender } = render(Save); + const idle = atoms(content()); + + rerender(Save); + expect(atoms(content())).toEqual(idle); + + advance(300); + const faded = atoms(content()); + expect(faded.length).toBeGreaterThan(idle.length); + expect(isSupersetOf(faded, idle)).toBe(true); + }); + + it('never shows a spinner for an action that finishes inside the delay window', () => { + const { rerender } = render(Save); + const idle = atoms(content()); + + rerender(Save); + advance(200); + rerender(Save); + advance(1000); + + expect(spinner()).not.toBeInTheDocument(); + expect(atoms(content())).toEqual(idle); + }); + + it('lets the consumer lengthen the delay', () => { + render( + + Save + , + ); + const hidden = atoms(spinner()); + + advance(300); + expect(atoms(spinner())).toEqual(hidden); + + advance(700); + expect(atoms(spinner()).length).toBeLessThan(hidden.length); + }); + + // A consumer who already knows the action is slow has nothing to gain by waiting. + it('lets the consumer opt out of the delay', () => { + render( + + Save + , + ); + const hidden = atoms(spinner()); + + advance(0); + expect(atoms(spinner()).length).toBeLessThan(hidden.length); + }); + + it('keeps the default minimum duration when only the delay is overridden', () => { + const { rerender } = render( + + Save + , + ); + advance(0); + + rerender(Save); + expect(spinner()).toBeInTheDocument(); + + advance(200); + expect(spinner()).not.toBeInTheDocument(); + }); + + it('lets the consumer drop the minimum duration', () => { + const { rerender } = render( + + Save + , + ); + advance(0); + + rerender(Save); + expect(spinner()).not.toBeInTheDocument(); + }); + + // Otherwise an action that resolves just after the spinner appears would flash it off again. + it('holds the spinner on screen briefly after the action finishes', () => { + const { rerender } = render(Save); + advance(300); + const shown = atoms(spinner()); + + rerender(Save); + expect(atoms(spinner())).toEqual(shown); + + advance(200); + expect(spinner()).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/components/button/submit-button.tsx b/packages/ui/src/mosaic/components/button/submit-button.tsx new file mode 100644 index 00000000000..669c1dbe3d0 --- /dev/null +++ b/packages/ui/src/mosaic/components/button/submit-button.tsx @@ -0,0 +1,118 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { SpinDelayOptions } from '../../hooks/useSpinDelay'; +import { useSpinDelay } from '../../hooks/useSpinDelay'; +import { mergeStyleProps } from '../../props'; +import { reset } from '../reset.styles'; +import { Spinner } from '../spinner'; +import type { ButtonProps } from './button'; +import { Button, withTruncatableLabel } from './button'; +import { styles } from './submit-button.styles'; + +export interface SubmitButtonProps extends ButtonProps { + /** + * Marks the action as running: the button goes inert, announces itself busy, and — once the + * action outlasts a short delay — fades its label and centers a spinner over it. The label + * stays mounted at zero opacity so the button keeps its width and the form around it does not + * reflow while the action runs. + */ + isPending?: boolean; + /** + * Accessible name for the pending indicator, announced alongside the button's own label. The + * default is untranslated, so pass a localized string wherever the surrounding copy is. + */ + pendingLabel?: string; + /** + * Tunes when the spinner appears and how long it stays. `delay` (default `300`) is how long the + * action has to run before the spinner is drawn at all; `minDuration` (default `200`) is how + * long it stays once drawn. Neither affects the pending state itself, which always applies + * immediately. Set `delay: 0` for an action already known to be slow. + */ + spinDelay?: SpinDelayOptions; +} + +// The spinner scale stops at `md`, and a `lg` button's label is only one step up, so both take +// the larger ring rather than `lg` asking for one the spinner cannot render. +const spinnerSizes = { sm: 'sm', md: 'md', lg: 'md' } as const; + +// Long enough that a request served from cache or a local mutation never draws a spinner, short +// enough that a press which is going to take a while doesn't sit there looking ignored. Set here +// rather than on `useSpinDelay` itself: a button is pressed and watched, so it wants a tighter +// window than a hook shared with background loads. `minDuration` has no such tension, so it takes +// the hook's default. +const DEFAULT_SPIN_DELAY = 300; + +/** + * A `Button` that submits its form, with a pending affordance. Takes every `Button` prop; + * `type` defaults to `submit` and can still be overridden. + * + * While `isPending`, the button is inert but stays focusable and announced — see the + * `isPending` prop for what that means for assistive tech. + * + * @example + * Save + * + * @example + * // Pending state on a destructive action + * Delete + */ +export const SubmitButton = React.forwardRef(function MosaicSubmitButton( + { isPending = false, pendingLabel = 'pending', size = 'md', spinDelay, className, children, onClick, ...rest }, + ref, +) { + const { delay = DEFAULT_SPIN_DELAY, minDuration } = spinDelay ?? {}; + + // `isPending` drives the semantics, this drives the pixels. The split is deliberate: the button + // has to go inert and start announcing the moment the action does, or a fast action gets + // submitted twice and assistive tech misses it — but drawing a spinner that fast only produces + // a flash, so the visual waits out the delay and then sticks around long enough to be read. + const showPending = useSpinDelay(isPending || null, { delay, minDuration }) !== null; + + const handleClick = (event: React.MouseEvent) => { + if (isPending) { + // `aria-disabled` is advisory — it doesn't stop the native submit — so the press is + // cancelled here instead. The `disabled` attribute would do both, but it drops the button + // out of the tab order mid-action, taking focus with it just as the spinner is announced. + // + // TODO: fold this into the headless button's `focusableWhenDisabled` once it lands + // (clerk/javascript#9319, #9320), which owns the same behavior one layer down. + event.preventDefault(); + return; + } + onClick?.(event); + }; + + return ( + + ); +}); diff --git a/packages/ui/src/mosaic/components/spinner/index.ts b/packages/ui/src/mosaic/components/spinner/index.ts new file mode 100644 index 00000000000..602e3746b37 --- /dev/null +++ b/packages/ui/src/mosaic/components/spinner/index.ts @@ -0,0 +1,2 @@ +export { Spinner } from './spinner'; +export type { SpinnerProps } from './spinner'; diff --git a/packages/ui/src/mosaic/components/spinner/spinner.styles.ts b/packages/ui/src/mosaic/components/spinner/spinner.styles.ts new file mode 100644 index 00000000000..c6c82ab9bb1 --- /dev/null +++ b/packages/ui/src/mosaic/components/spinner/spinner.styles.ts @@ -0,0 +1,39 @@ +import * as stylex from '@stylexjs/stylex'; + +import { radiusVars, space } from '../../tokens.stylex'; + +const spin = stylex.keyframes({ + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, +}); + +export const styles = stylex.create({ + base: { + // Both colors come off `currentColor` rather than a fixed pair, so the spinner reads on + // whatever it sits on — a card, or the fill of a `filled` button, where a card-foreground + // arc would disappear. + borderColor: `color-mix(in oklab, currentColor 20%, transparent)`, + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + // The ring is one uniform border with a single arc picked out in the foreground color; + // rotating the whole element is what animates the arc around it. + animationDuration: '600ms', + animationIterationCount: 'infinite', + animationName: { + default: spin, + '@media (prefers-reduced-motion: reduce)': 'none', + }, + animationTimingFunction: 'linear', + borderBlockStartColor: 'currentColor', + boxSizing: 'border-box', + display: 'inline-block', + flexShrink: 0, + }, +}); + +// Sized off the `Icon` scale, since a spinner stands in for an icon wherever it appears. The +// border thins with it so the ring keeps its proportion rather than swallowing the small one. +export const sizes = stylex.create({ + sm: { borderWidth: '1.5px', height: space['3.5'], width: space['3.5'] }, + md: { borderWidth: '2px', height: space['4'], width: space['4'] }, +}); diff --git a/packages/ui/src/mosaic/components/spinner/spinner.test.tsx b/packages/ui/src/mosaic/components/spinner/spinner.test.tsx new file mode 100644 index 00000000000..24127752350 --- /dev/null +++ b/packages/ui/src/mosaic/components/spinner/spinner.test.tsx @@ -0,0 +1,52 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Spinner } from './spinner'; + +/** The spinner is decorative, so it has no role or name to query — only its slot class. */ +const spinner = (container: HTMLElement) => container.querySelector('.cl-spinner'); + +describe('Mosaic Spinner', () => { + it('renders a decorative element carrying the slot class', () => { + const { container } = render(); + const el = spinner(container); + expect(el).toBeInTheDocument(); + expect(el).toHaveAttribute('aria-hidden', 'true'); + }); + + it('applies the default size when none is passed', () => { + const { container } = render(); + expect(spinner(container)).toHaveAttribute('data-size', 'md'); + }); + + it.each(['sm', 'md'] as const)('reflects the %s size', size => { + const { container } = render(); + expect(spinner(container)).toHaveAttribute('data-size', size); + }); + + it('lets the consumer className and style win', () => { + const { container } = render( + , + ); + const el = spinner(container); + expect(el).toHaveClass('cl-spinner', 'my-spinner'); + expect(el).toHaveStyle({ marginTop: '8px' }); + }); + + it('forwards arbitrary span props and the ref', () => { + const ref = React.createRef(); + const { container } = render( + , + ); + const el = spinner(container); + expect(ref.current).toBe(el); + expect(el).toHaveAttribute('id', 'pending'); + }); +}); diff --git a/packages/ui/src/mosaic/components/spinner/spinner.tsx b/packages/ui/src/mosaic/components/spinner/spinner.tsx new file mode 100644 index 00000000000..ce034983030 --- /dev/null +++ b/packages/ui/src/mosaic/components/spinner/spinner.tsx @@ -0,0 +1,36 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { sizes, styles } from './spinner.styles'; + +export type SpinnerProps = MosaicElementProps<'span'> & { + size?: 'sm' | 'md'; +}; + +/** + * Indeterminate loading spinner. Decorative (`aria-hidden`) — pair it with a disabled control or an + * `aria-busy` container so assistive tech is informed of the pending state. + * + * @example + * // Default (md), sized to match a `md` Icon + * + * + * @example + * // Standing in for a `sm` Icon + * + */ +export const Spinner = React.forwardRef(function MosaicSpinner( + { size = 'md', className, style, ...rest }, + ref, +) { + return ( + + ); +}); diff --git a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts new file mode 100644 index 00000000000..66052c26835 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts @@ -0,0 +1,88 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSpinDelay } from '../useSpinDelay'; + +describe('useSpinDelay', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const render = (value: string | null, options?: { delay?: number; minDuration?: number }) => + renderHook(({ value }) => useSpinDelay(value, options), { initialProps: { value } }); + + const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms)); + + it('returns null while idle', () => { + const { result } = render(null); + expect(result.current).toBeNull(); + }); + + it('stays null during the delay window', async () => { + const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + expect(result.current).toBeNull(); + + await advance(499); + expect(result.current).toBeNull(); + }); + + it('surfaces the value once it outlasts the delay', async () => { + const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + + await advance(500); + expect(result.current).toBe('a'); + }); + + it('never surfaces a value that clears faster than the delay', async () => { + const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + + await advance(300); + await act(() => rerender({ value: null })); + + await advance(1000); + expect(result.current).toBeNull(); + }); + + it('holds the value for at least minDuration once shown', async () => { + const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + await advance(500); + expect(result.current).toBe('a'); + + // value clears almost immediately after it appeared + await act(() => rerender({ value: null })); + await advance(199); + expect(result.current).toBe('a'); + + await advance(1); + expect(result.current).toBeNull(); + }); + + it('keeps showing while the value persists beyond minDuration, then clears', async () => { + const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + await advance(500); + await advance(5000); + expect(result.current).toBe('a'); + + await act(() => rerender({ value: null })); + expect(result.current).toBeNull(); + }); + + it('swaps to a new value immediately when one replaces another mid-show', async () => { + const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + await advance(500); + expect(result.current).toBe('a'); + + await act(() => rerender({ value: 'b' })); + expect(result.current).toBe('b'); + }); +}); diff --git a/packages/ui/src/mosaic/hooks/useSpinDelay.ts b/packages/ui/src/mosaic/hooks/useSpinDelay.ts new file mode 100644 index 00000000000..b847c0bc517 --- /dev/null +++ b/packages/ui/src/mosaic/hooks/useSpinDelay.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface SpinDelayOptions { + /** Wait this long before showing the value, so quick actions never flash a spinner. */ + delay?: number; + /** Once shown, keep the value up at least this long, so the spinner never flickers off. */ + minDuration?: number; +} + +const DEFAULT_DELAY = 500; +const DEFAULT_MIN_DURATION = 200; + +/** + * Spin-delays a nullable value: returns `null` until `value` has stayed non-null longer than `delay` + * (so quick actions never flash a spinner), then holds the last non-null value for at least + * `minDuration` after it clears (so the spinner never flickers off). A value-carrying rework of + * https://github.com/smeijer/spin-delay: each timer lives in a `const` and is cancelled by effect + * cleanup when `value` flips before it fires. + */ +export function useSpinDelay(value: T | null, options: SpinDelayOptions = {}): T | null { + const delay = options.delay ?? DEFAULT_DELAY; + const minDuration = options.minDuration ?? DEFAULT_MIN_DURATION; + + const [shown, setShown] = useState(null); + const shownAt = useRef(0); + + useEffect(() => { + // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. + if (shown === null) { + if (value === null) { + return; + } + const timer = setTimeout(() => { + shownAt.current = Date.now(); + setShown(value); + }, delay); + return () => clearTimeout(timer); + } + + // Showing, and the value cleared: hold it for the rest of `minDuration`. + if (value === null) { + const remaining = minDuration - (Date.now() - shownAt.current); + if (remaining <= 0) { + setShown(null); + return; + } + const timer = setTimeout(() => setShown(null), remaining); + return () => clearTimeout(timer); + } + + // Showing, and the value swapped to another non-null: surface it right away. + if (value !== shown) { + setShown(value); + } + }, [value, shown, delay, minDuration]); + + return shown; +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 826944639b8..3f65e3c03cb 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -10,8 +10,8 @@ export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar'; export { Badge } from '../components/badge'; export type { BadgeProps } from '../components/badge'; -export { Button } from '../components/button'; -export type { ButtonProps } from '../components/button'; +export { Button, SubmitButton } from '../components/button'; +export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button'; export { Card } from '../components/card'; export type { CardProps } from '../components/card'; export { Heading, HeadingContext } from '../components/heading'; @@ -30,6 +30,8 @@ export type { } from '../components/menu'; export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area'; export type { ScrollAreaGutter } from '../components/scroll-area'; +export { Spinner } from '../components/spinner'; +export type { SpinnerProps } from '../components/spinner'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; From 86554aac7babe2eef65c531d7dbee19a6a939f91 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 5 Aug 2026 15:33:53 -0400 Subject: [PATCH 2/2] feat(ui): give SubmitButton's content box a slot class The wrapper is where a consumer's children actually land, so it carries `cl-button-content` rather than being reachable only through StyleX atoms. --- packages/swingset/src/stories/button.mdx | 4 +++- .../src/mosaic/components/button/submit-button.test.tsx | 7 +++++++ .../ui/src/mosaic/components/button/submit-button.tsx | 9 +++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/button.mdx b/packages/swingset/src/stories/button.mdx index 7b2599f83a9..ff8db065e8e 100644 --- a/packages/swingset/src/stories/button.mdx +++ b/packages/swingset/src/stories/button.mdx @@ -138,7 +138,9 @@ Press the button above to run a stand-in action: it goes pending for two seconds While `isPending`, the label fades to zero opacity and a spinner centers over it. The label stays mounted rather than being swapped out, so the button holds the width its content gives it — watch that it doesn't resize across the flip — and nothing around it reflows. Every child sits in one -box, so an icon fades with its label instead of hanging on beside the spinner. +box, so an icon fades with its label instead of hanging on beside the spinner. That box is the slot +`cl-button-content`, so it can be targeted directly — `.cl-button-content` for the content row of +any submit button, `.cl-button[data-pending] .cl-button-content` for it mid-action. A pending button is inert but not `disabled`: it carries `aria-disabled`, drops its pointer events so hover and press stop firing, and cancels the press so the form can't be submitted twice. The diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx index 82bce873100..b8398f07523 100644 --- a/packages/ui/src/mosaic/components/button/submit-button.test.tsx +++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx @@ -29,6 +29,13 @@ describe('Mosaic SubmitButton', () => { expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); }); + // The wrapper is not an implementation detail a consumer can ignore — it is the box their + // children actually land in — so it carries a slot class they can target. + it('names the content box with a slot class', () => { + render(Save); + expect(content()).toHaveClass('cl-button-content'); + }); + it('boxes every child in one content span', () => { render( diff --git a/packages/ui/src/mosaic/components/button/submit-button.tsx b/packages/ui/src/mosaic/components/button/submit-button.tsx index 669c1dbe3d0..711af575e38 100644 --- a/packages/ui/src/mosaic/components/button/submit-button.tsx +++ b/packages/ui/src/mosaic/components/button/submit-button.tsx @@ -3,7 +3,7 @@ import React from 'react'; import type { SpinDelayOptions } from '../../hooks/useSpinDelay'; import { useSpinDelay } from '../../hooks/useSpinDelay'; -import { mergeStyleProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../reset.styles'; import { Spinner } from '../spinner'; import type { ButtonProps } from './button'; @@ -95,7 +95,12 @@ export const SubmitButton = React.forwardRef - + {withTruncatableLabel(children)} {isPending || showPending ? (