Skip to content
Open
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/user-button-infinite-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/user-button-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
7 changes: 2 additions & 5 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @jsxImportSource @emotion/react */
import { type UserButtonProps, UserButtonView } from '@clerk/ui/mosaic/user-button/user-button.view';
import { UserButtonView, type UserButtonViewProps } from '@clerk/ui/mosaic/user-button/user-button.view';

import type { StoryMeta } from '@/lib/types';

Expand Down Expand Up @@ -29,15 +29,14 @@ const handlers = {
onCreateOrganization: () => {},
onAddAccount: () => {},
onUpgrade: () => {},
} satisfies Partial<UserButtonProps>;
} satisfies Partial<UserButtonViewProps>;

const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' };

export function Default(_args: Record<string, unknown>) {
return (
<UserButtonView
{...handlers}
status='ready'
activeSession={preston}
activeOrganizationId='org_clerk_app'
hasOrganizations
Expand Down Expand Up @@ -65,7 +64,6 @@ export function Personal(_args: Record<string, unknown>) {
return (
<UserButtonView
{...handlers}
status='ready'
activeSession={{
sessionId: 'sess_cam',
userId: 'user_cam',
Expand All @@ -88,7 +86,6 @@ export function MultipleSessions(_args: Record<string, unknown>) {
return (
<UserButtonView
{...handlers}
status='ready'
activeSession={preston}
activeOrganizationId='org_clerk_app'
hasOrganizations
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/src/mosaic/components/spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// eslint-disable-next-line no-restricted-imports -- Mosaic has no keyframes helper; emotion is the only source, as in components/skeleton.tsx.
import { keyframes } from '@emotion/react';

import type { SxProp } from '../slot-recipe';
import { Box, type BoxProps } from './box';

const spin = keyframes({ to: { transform: 'rotate(360deg)' } });

export type SpinnerProps = Omit<BoxProps, 'sx' | 'children'> & { sx?: SxProp };

/**
* 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. Marked with
* `data-cl-spinner` for querying.
*/
export function Spinner({ sx, ...rest }: SpinnerProps) {
return (
<Box
aria-hidden
data-cl-spinner=''
sx={t => ({
display: 'inline-block',
flexShrink: 0,
width: t.spacing(4),
height: t.spacing(4),
borderRadius: t.rounded.full,
borderStyle: 'solid',
borderWidth: '2px',
borderColor: t.color.border,
borderBlockStartColor: t.color.cardForeground,
animation: `${spin} 600ms linear infinite`,
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
...(typeof sx === 'function' ? sx(t) : sx),
})}
{...rest}
/>
);
}
88 changes: 88 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
58 changes: 58 additions & 0 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T | null, options: SpinDelayOptions = {}): T | null {
const delay = options.delay ?? DEFAULT_DELAY;
const minDuration = options.minDuration ?? DEFAULT_MIN_DURATION;

const [shown, setShown] = useState<T | null>(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;
}
Loading
Loading