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
2 changes: 2 additions & 0 deletions .changeset/tender-months-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a @clerk/ui release entry.

This PR adds public SubmitButton and Spinner exports. The empty Changeset creates no package version or changelog entry. Add a minor @clerk/ui Changeset.

As per coding guidelines, “Use Changesets for version management and changelogs.” Based on learnings, an empty Changeset is acceptable only when no published package release is involved.

Proposed Changeset
 ---
+'`@clerk/ui`': minor
 ---
+
+Add Mosaic SubmitButton and Spinner components.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add Mosaic SubmitButton and Spinner components.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/tender-months-attack.md around lines 1 - 2, Replace the empty
Changeset front matter with a minor release entry for the `@clerk/ui` package,
preserving the standard Changesets format so the new public SubmitButton and
Spinner exports produce a package version and changelog entry.

Sources: Coding guidelines, Learnings

94 changes: 94 additions & 0 deletions packages/swingset/src/stories/button.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,100 @@ 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.

<Story
name='Submit'
storyModule={ButtonStories}
/>

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. 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
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.

<Story
name='SubmitDelay'
storyModule={ButtonStories}
/>

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
<SubmitButton
isPending={isSubmitting}
spinDelay={{ delay: 0 }}
>
Save changes
</SubmitButton>
```

#### 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
<SubmitButton
isPending={isSubmitting}
pendingLabel='Saving'
>
Save changes
</SubmitButton>
```

<Story
name='SubmitSizes'
storyModule={ButtonStories}
/>

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.

<Story
name='SubmitVariants'
storyModule={ButtonStories}
/>

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
Expand Down
93 changes: 92 additions & 1 deletion packages/swingset/src/stories/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -301,3 +301,94 @@ export function Disabled(props: Record<string, unknown>) {
</Button>
);
}

// 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<ReturnType<typeof setTimeout>>(undefined);

React.useEffect(() => () => clearTimeout(timeout.current), []);

return {
isPending,
onClick: () => {
setIsPending(true);
timeout.current = setTimeout(() => setIsPending(false), duration);
},
};
}

export function Submit(props: Record<string, unknown>) {
const { isPending, onClick } = usePendingOnPress();
return (
<SubmitButton
{...knobsAsProps(props)}
isPending={isPending}
onClick={onClick}
>
Save changes
</SubmitButton>
);
}

// 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<string, unknown>) {
const slow = usePendingOnPress(2000);
const fast = usePendingOnPress(150);
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<SubmitButton
{...knobsAsProps(props)}
isPending={slow.isPending}
onClick={slow.onClick}
>
Slow action
</SubmitButton>
<SubmitButton
{...knobsAsProps(props)}
isPending={fast.isPending}
onClick={fast.onClick}
>
Fast action
</SubmitButton>
</div>
);
}

export function SubmitSizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{(['sm', 'md', 'lg'] as const).map(size => (
<SubmitButton
key={size}
{...knobsAsProps(props)}
size={size}
isPending
>
Save changes
</SubmitButton>
))}
</div>
);
}

// 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<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{(['filled', 'outline', 'ghost'] as const).map(variant => (
<SubmitButton
key={variant}
{...knobsAsProps(props)}
variant={variant}
isPending
>
Save changes
</SubmitButton>
))}
</div>
);
}
23 changes: 14 additions & 9 deletions packages/ui/src/mosaic/components/button/button.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/mosaic/components/button/index.ts
Original file line number Diff line number Diff line change
@@ -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';
53 changes: 53 additions & 0 deletions packages/ui/src/mosaic/components/button/submit-button.styles.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
Loading
Loading