Skip to content

Commit 2f24826

Browse files
feat(ui): add Mosaic SubmitButton (#9342)
1 parent 63d25ba commit 2f24826

16 files changed

Lines changed: 1029 additions & 13 deletions

File tree

.changeset/tender-months-attack.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/stories/button.mdx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,100 @@ by the styles, not by `pointer-events`: the button stays hit-testable, which is
123123
`cursor: not-allowed` render at all and what lets a wrapping tooltip explain _why_ it's disabled.
124124
That tooltip is worth adding — a disabled control with no explanation is a dead end.
125125

126+
### Submitting
127+
128+
`SubmitButton` is a `Button` that defaults `type` to `submit` and adds `isPending`. Use it for
129+
the button that commits a form; reach for plain `Button` everywhere else.
130+
131+
<Story
132+
name='Submit'
133+
storyModule={ButtonStories}
134+
/>
135+
136+
Press the button above to run a stand-in action: it goes pending for two seconds, then comes back.
137+
138+
While `isPending`, the label fades to zero opacity and a spinner centers over it. The label stays
139+
mounted rather than being swapped out, so the button holds the width its content gives it — watch
140+
that it doesn't resize across the flip — and nothing around it reflows. Every child sits in one
141+
box, so an icon fades with its label instead of hanging on beside the spinner. That box is the slot
142+
`cl-button-content`, so it can be targeted directly — `.cl-button-content` for the content row of
143+
any submit button, `.cl-button[data-pending] .cl-button-content` for it mid-action.
144+
145+
A pending button is inert but not `disabled`: it carries `aria-disabled`, drops its pointer events
146+
so hover and press stop firing, and cancels the press so the form can't be submitted twice. The
147+
native `disabled` attribute would do all of that too, but it takes the button out of the tab order
148+
mid-action — pulling focus away at the exact moment the spinner is announced. The state is also
149+
reflected as `data-pending` for styling.
150+
151+
#### The spinner is delayed
152+
153+
The button becomes pending the instant `isPending` flips, but the spinner waits 300ms before it's
154+
drawn, then stays up at least 200ms once it is. Plenty of actions resolve faster than a spinner
155+
takes to read, and one that appears and vanishes inside a few frames registers as a glitch rather
156+
than as progress.
157+
158+
<Story
159+
name='SubmitDelay'
160+
storyModule={ButtonStories}
161+
/>
162+
163+
Nothing about the pending _state_ is delayed — only the pixels. Both buttons above go inert and
164+
announce themselves the moment they're pressed, which is what stops a double submit; the fast one
165+
simply finishes before its spinner is due.
166+
167+
Both numbers move with `spinDelay`. An action already known to be slow has nothing to gain by
168+
waiting, so it can skip straight to the spinner:
169+
170+
```tsx
171+
<SubmitButton
172+
isPending={isSubmitting}
173+
spinDelay={{ delay: 0 }}
174+
>
175+
Save changes
176+
</SubmitButton>
177+
```
178+
179+
#### What assistive tech gets
180+
181+
The spinner is decorative everywhere else in Mosaic, but here it is the only signal the action is
182+
running, so it enters the accessibility tree as an indeterminate `progressbar` the moment
183+
`isPending` flips — including during the delay above, when it's mounted but not yet drawn. That's
184+
why the delay is `opacity` and not conditional rendering: `visibility: hidden` or `display: none`
185+
would take it back out of the tree, and so would not rendering it. Fading the label with `opacity`
186+
is the same call — it keeps the button named "Save changes" for the whole action instead of going
187+
briefly nameless.
188+
189+
The indicator is named in its own right, via `pendingLabel` (default `pending`). It is not folded
190+
into the button's name: `progressbar` is a range role, so name computation reads its _value_
191+
absent, since it's indeterminate — rather than its label, and a descendant one contributes nothing
192+
to the button above it. `pendingLabel` is untranslated, so pass a localized string wherever the
193+
surrounding copy is localized.
194+
195+
```tsx
196+
<SubmitButton
197+
isPending={isSubmitting}
198+
pendingLabel='Saving'
199+
>
200+
Save changes
201+
</SubmitButton>
202+
```
203+
204+
<Story
205+
name='SubmitSizes'
206+
storyModule={ButtonStories}
207+
/>
208+
209+
The spinner is sized off the `Icon` scale, since it stands in for one. That scale stops at `md`,
210+
so `md` and `lg` buttons share the larger ring.
211+
212+
<Story
213+
name='SubmitVariants'
214+
storyModule={ButtonStories}
215+
/>
216+
217+
Both the ring and its arc are mixed from `currentColor`, so the spinner reads on a `filled`
218+
button's fill and on a bare surface alike without a color prop to keep in step with the button's.
219+
126220
### Touch target
127221

128222
Every size is shorter than the 44px a fingertip needs, so under `pointer: coarse` the button grows

packages/swingset/src/stories/button.stories.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** @jsxImportSource @emotion/react */
22
import type { ButtonProps } from '@clerk/ui/mosaic/components/button';
3-
import { Button } from '@clerk/ui/mosaic/components/button';
3+
import { Button, SubmitButton } from '@clerk/ui/mosaic/components/button';
44
import { Icon } from '@clerk/ui/mosaic/components/icon';
55
import React from 'react';
66

@@ -301,3 +301,94 @@ export function Disabled(props: Record<string, unknown>) {
301301
</Button>
302302
);
303303
}
304+
305+
// Stands in for an async submit, so the example can be pressed and the flip between the two
306+
// states watched — including that the button doesn't resize under the spinner.
307+
function usePendingOnPress(duration = 2000) {
308+
const [isPending, setIsPending] = React.useState(false);
309+
const timeout = React.useRef<ReturnType<typeof setTimeout>>(undefined);
310+
311+
React.useEffect(() => () => clearTimeout(timeout.current), []);
312+
313+
return {
314+
isPending,
315+
onClick: () => {
316+
setIsPending(true);
317+
timeout.current = setTimeout(() => setIsPending(false), duration);
318+
},
319+
};
320+
}
321+
322+
export function Submit(props: Record<string, unknown>) {
323+
const { isPending, onClick } = usePendingOnPress();
324+
return (
325+
<SubmitButton
326+
{...knobsAsProps(props)}
327+
isPending={isPending}
328+
onClick={onClick}
329+
>
330+
Save changes
331+
</SubmitButton>
332+
);
333+
}
334+
335+
// Press both: only the slow one ever draws a spinner. The fast one is pending the whole time it
336+
// says it is — it just finishes before the spinner is due, so nothing flashes.
337+
export function SubmitDelay(props: Record<string, unknown>) {
338+
const slow = usePendingOnPress(2000);
339+
const fast = usePendingOnPress(150);
340+
return (
341+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
342+
<SubmitButton
343+
{...knobsAsProps(props)}
344+
isPending={slow.isPending}
345+
onClick={slow.onClick}
346+
>
347+
Slow action
348+
</SubmitButton>
349+
<SubmitButton
350+
{...knobsAsProps(props)}
351+
isPending={fast.isPending}
352+
onClick={fast.onClick}
353+
>
354+
Fast action
355+
</SubmitButton>
356+
</div>
357+
);
358+
}
359+
360+
export function SubmitSizes(props: Record<string, unknown>) {
361+
return (
362+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
363+
{(['sm', 'md', 'lg'] as const).map(size => (
364+
<SubmitButton
365+
key={size}
366+
{...knobsAsProps(props)}
367+
size={size}
368+
isPending
369+
>
370+
Save changes
371+
</SubmitButton>
372+
))}
373+
</div>
374+
);
375+
}
376+
377+
// The spinner takes its arc from `currentColor`, so it reads on a fill and on a bare surface
378+
// alike — no color prop to keep in step with the button's.
379+
export function SubmitVariants(props: Record<string, unknown>) {
380+
return (
381+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
382+
{(['filled', 'outline', 'ghost'] as const).map(variant => (
383+
<SubmitButton
384+
key={variant}
385+
{...knobsAsProps(props)}
386+
variant={variant}
387+
isPending
388+
>
389+
Save changes
390+
</SubmitButton>
391+
))}
392+
</div>
393+
);
394+
}

packages/ui/src/mosaic/components/button/button.styles.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ const iconFadedOnNegative = `color-mix(in oklab, ${colorVars['--cl-color-negativ
5858
// Both selectors are written out per cell rather than hoisted to a const: `@stylexjs/sort-keys`
5959
// reads a computed key as its identifier name and fails the ordering.
6060
//
61+
// `:active` also excludes `[data-pending]`, which `SubmitButton` sets while its action runs. That
62+
// button drops its pointer events, which is enough for the pointer, but a focused button still
63+
// takes `:active` from the keyboard — space and enter — and a pending button shouldn't flash a
64+
// pressed fill for a press it ignores.
65+
//
6166
// `[data-open]` takes the pressed fill too, so a button acting as a disclosure trigger stays
6267
// visibly engaged for as long as its surface is open. Disclosure primitives set it on the
6368
// trigger (`popover-trigger.tsx` and friends); a plain button never carries it. It is excluded
@@ -188,7 +193,7 @@ export const variants = stylex.create({
188193
},
189194
backgroundColor: {
190195
default: colorVars['--cl-color-primary'],
191-
':enabled:active': primaryActive,
196+
':enabled:not([data-pending]):active': primaryActive,
192197
':enabled[data-open]': primaryActive,
193198
'@media (hover: hover)': {
194199
default: null,
@@ -208,7 +213,7 @@ export const variants = stylex.create({
208213
},
209214
backgroundColor: {
210215
default: neutralStep0,
211-
':enabled:active': neutralStep2,
216+
':enabled:not([data-pending]):active': neutralStep2,
212217
':enabled[data-open]': neutralStep2,
213218
'@media (hover: hover)': {
214219
default: null,
@@ -228,7 +233,7 @@ export const variants = stylex.create({
228233
},
229234
backgroundColor: {
230235
default: colorVars['--cl-color-negative'],
231-
':enabled:active': negativeActive,
236+
':enabled:not([data-pending]):active': negativeActive,
232237
':enabled[data-open]': negativeActive,
233238
'@media (hover: hover)': {
234239
default: null,
@@ -253,7 +258,7 @@ export const variants = stylex.create({
253258
borderColor: colorVars['--cl-color-border'],
254259
backgroundColor: {
255260
default: 'transparent',
256-
':enabled:active': neutralStep1,
261+
':enabled:not([data-pending]):active': neutralStep1,
257262
':enabled[data-open]': neutralStep1,
258263
'@media (hover: hover)': {
259264
default: null,
@@ -274,7 +279,7 @@ export const variants = stylex.create({
274279
borderColor: colorVars['--cl-color-border'],
275280
backgroundColor: {
276281
default: 'transparent',
277-
':enabled:active': neutralStep1,
282+
':enabled:not([data-pending]):active': neutralStep1,
278283
':enabled[data-open]': neutralStep1,
279284
'@media (hover: hover)': {
280285
default: null,
@@ -295,7 +300,7 @@ export const variants = stylex.create({
295300
borderColor: colorVars['--cl-color-border'],
296301
backgroundColor: {
297302
default: 'transparent',
298-
':enabled:active': neutralStep1,
303+
':enabled:not([data-pending]):active': neutralStep1,
299304
':enabled[data-open]': neutralStep1,
300305
'@media (hover: hover)': {
301306
default: null,
@@ -316,7 +321,7 @@ export const variants = stylex.create({
316321
},
317322
backgroundColor: {
318323
default: 'transparent',
319-
':enabled:active': neutralStep1,
324+
':enabled:not([data-pending]):active': neutralStep1,
320325
':enabled[data-open]': neutralStep1,
321326
'@media (hover: hover)': {
322327
default: null,
@@ -336,7 +341,7 @@ export const variants = stylex.create({
336341
},
337342
backgroundColor: {
338343
default: 'transparent',
339-
':enabled:active': neutralStep1,
344+
':enabled:not([data-pending]):active': neutralStep1,
340345
':enabled[data-open]': neutralStep1,
341346
'@media (hover: hover)': {
342347
default: null,
@@ -358,7 +363,7 @@ export const variants = stylex.create({
358363
},
359364
backgroundColor: {
360365
default: 'transparent',
361-
':enabled:active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
366+
':enabled:not([data-pending]):active': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
362367
':enabled[data-open]': `color-mix(in oklab, ${colorVars['--cl-color-negative-faded']}, ${colorVars['--cl-color-negative']} 8%)`,
363368
'@media (hover: hover)': {
364369
default: null,

packages/ui/src/mosaic/components/button/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export interface ButtonProps extends MosaicElementProps<'button'> {
4949
// adjacent text shares one box, or `Delete {name}` would split into two flex items with the
5050
// button's `gap` opening up mid-sentence. Element children (icons) pass through untouched,
5151
// so they stay direct flex items and `gap` still applies.
52-
function withTruncatableLabel(children: React.ReactNode): React.ReactNode {
52+
export function withTruncatableLabel(children: React.ReactNode): React.ReactNode {
5353
const result: React.ReactNode[] = [];
5454
let run: React.ReactNode[] = [];
5555

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
export { Button } from './button';
22
export type { ButtonProps } from './button';
3+
export { SubmitButton } from './submit-button';
4+
export type { SubmitButtonProps } from './submit-button';
5+
// Named here rather than only inside `SubmitButtonProps`, so a consumer can type the object they
6+
// pass to `spinDelay`.
7+
export type { SpinDelayOptions } from '../../hooks/useSpinDelay';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
export const styles = stylex.create({
4+
// The containing block the spinner centers against. Unconditional, so the button's stacking
5+
// and its coarse-pointer `::after` overlay behave the same in both states.
6+
root: {
7+
position: 'relative',
8+
},
9+
// Button gates its hover and pressed fills on `:enabled`, which a pending button still is —
10+
// `aria-disabled` keeps it focusable, so the native attribute is out. Dropping pointer events
11+
// stops `:hover` and `:active` matching for the pointer in one line, across every variant cell.
12+
// Unlike `disabled` there's nothing lost by it: pending is self-explanatory and brief, so the
13+
// button isn't carrying a tooltip that has to stay hoverable to explain itself.
14+
//
15+
// It doesn't cover the keyboard, though — a focused button still takes `:active` from space and
16+
// enter with no pointer involved. That half is handled where the fills are declared, by the
17+
// `:not([data-pending])` on each cell's active selector in `button.styles.ts`.
18+
rootPending: {
19+
pointerEvents: 'none',
20+
},
21+
22+
// One box around every child, so the whole content fades as a unit rather than per-run. It
23+
// stands in for the button's own content row — `gap` picks up whatever the size axis set —
24+
// so an icon and its label keep their spacing across the extra nesting level.
25+
content: {
26+
gap: 'inherit',
27+
alignItems: 'center',
28+
display: 'inline-flex',
29+
// Releases the flex-item min-width floor so the label boxes inside can still clip.
30+
minWidth: 0,
31+
},
32+
// Opacity rather than unmounting the label or swapping in the spinner: the content keeps its
33+
// box, so the button holds its width and nothing around it reflows when the state flips.
34+
contentPending: {
35+
opacity: 0,
36+
},
37+
38+
// Out of flow and centered by `inset: 0` + `margin: auto`, which resolves against the padding
39+
// box on both axes without a transform and stays correct under any writing mode.
40+
spinner: {
41+
margin: 'auto',
42+
insetBlock: 0,
43+
insetInline: 0,
44+
position: 'absolute',
45+
},
46+
// The spinner mounts the instant the action starts but waits out `useSpinDelay` before it is
47+
// drawn, so a fast action never flashes one. Hiding it with `opacity` rather than by not
48+
// rendering it is what keeps the progressbar in the accessibility tree for the whole action —
49+
// `visibility: hidden` or `display: none` would take it back out.
50+
spinnerHidden: {
51+
opacity: 0,
52+
},
53+
});

0 commit comments

Comments
 (0)