diff --git a/.changeset/user-button-infinite-scroll.md b/.changeset/user-button-infinite-scroll.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-button-infinite-scroll.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-button-integration.md b/.changeset/user-button-integration.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-button-integration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index 0741ed4a2b1..556a86f050e 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -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'; @@ -29,7 +29,7 @@ const handlers = { onCreateOrganization: () => {}, onAddAccount: () => {}, onUpgrade: () => {}, -} satisfies Partial; +} satisfies Partial; const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' }; @@ -37,7 +37,6 @@ export function Default(_args: Record) { return ( ) { return ( ) { return ( & { 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 ( + ({ + 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} + /> + ); +} 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/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx index 6be4951ee0b..56650f7983f 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -24,10 +24,19 @@ let user: FakeUser | null; let session: { id: string; checkAuthorization: ReturnType } | null; let organization: { id: string } | null; let membershipRequests: { count: number }; -let userMemberships: { data: unknown[]; count: number; revalidate: ReturnType }; -let userInvitations: { data: unknown[]; count: number; revalidate: ReturnType }; -let userSuggestions: { data: unknown[]; count: number; revalidate: ReturnType }; +type FakePaginated = { + data: unknown[]; + count: number; + revalidate: ReturnType; + hasNextPage?: boolean; + isFetching?: boolean; +}; +let userMemberships: FakePaginated; +let userInvitations: FakePaginated; +let userSuggestions: FakePaginated; let signedInSessions: FakeSession[]; +let singleSessionMode: boolean; +let controllerOptions: UserButtonControllerOptions | undefined; let setActive: ReturnType; let signOut: ReturnType; @@ -50,8 +59,11 @@ vi.mock('@clerk/shared/react', async importOriginal => { buildOrganizationProfileUrl: () => '/org-profile', buildCreateOrganizationUrl: () => '/create-org', buildSignInUrl: () => '/sign-in', + buildAfterSignOutUrl: () => '/after-signout', + buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-signout', client: { signedInSessions }, __internal_environment: { + authConfig: { singleSessionMode }, displayConfig: { afterCreateOrganizationUrl: '/after-create', afterSwitchSessionUrl: '/after-switch', @@ -121,6 +133,8 @@ beforeEach(() => { setActive = vi.fn().mockResolvedValue(undefined); signOut = vi.fn().mockResolvedValue(undefined); navigate = vi.fn().mockResolvedValue(undefined); + singleSessionMode = false; + controllerOptions = undefined; }); afterEach(() => { @@ -128,7 +142,7 @@ afterEach(() => { }); function Harness() { - const c = useUserButtonController(); + const c = useUserButtonController(controllerOptions); if (c.status !== 'ready') { return {c.status}; } @@ -142,9 +156,14 @@ function Harness() { {String(c.activeOrganizationId)} {String(c.hasOrganizations)} {c.additionalSessions.map(a => a.userId).join(',')} + {String(Boolean(c.onAddAccount))} + {String(Boolean(c.onSignOutAll))} {JSON.stringify(c.memberships)} {JSON.stringify(c.suggestions)} {JSON.stringify(c.invitations)} + {String(c.hasMoreRows)} + {String(c.isFetchingRows)} + {String(typeof c.loadMoreRef === 'function')} + ); } function activeMembership(data: UserButtonData): UserButtonMembership | undefined { @@ -484,11 +142,14 @@ interface RowProps { badge?: ReactNode; trailing?: ReactNode; hoverAction?: ReactNode; + /** Key for this row's own select action; drives its spinner/disabled state. */ + busyKey?: string; } -function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, trailing, hoverAction }: RowProps) { +function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, trailing, hoverAction, busyKey }: RowProps) { const theme = useMosaicTheme(); const { item, select, name: nameSlot, secondary: secondarySlot } = useRecipe(userButtonRecipe); + const { loading, disabled } = useBusy(busyKey); const inner = ( <> {inner} @@ -524,7 +186,9 @@ function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, traili {inner} )} - {active ? ( + {loading ? ( + + ) : active ? ( Suggested; } -function InlineButton({ label, onClick }: { label: string; onClick: () => void }) { - const { inlineButton } = useRecipe(userButtonRecipe); - return ( - - ); -} - -function HoverAction({ onClick }: { onClick: () => void }) { - const { hoverAction } = useRecipe(userButtonRecipe); - return ( - - ); +function PendingApprovalLabel() { + const { suggestedBadge } = useRecipe(userButtonRecipe); + return Pending approval; } function AddRow({ label, onClick }: { label: string; onClick: () => void }) { const theme = useMosaicTheme(); const { add, addIcon } = useRecipe(userButtonRecipe); + const { disabled } = useBusy(); return ( + ))} ) : null} @@ -683,7 +337,7 @@ function Header() { function WorkspaceList() { const data = useUserButtonContext(); - const { group } = useRecipe(userButtonRecipe); + const { group, scroll, loadMore } = useRecipe(userButtonRecipe); const selectOrg = data.onSelectOrganization; const acceptSuggestion = data.onAcceptSuggestion; const acceptInvitation = data.onAcceptInvitation; @@ -691,60 +345,95 @@ function WorkspaceList() { return (
- signOutSession(data.activeSession.sessionId)} /> : undefined - } - /> - {data.memberships.map(m => ( - selectOrg(m.organizationId) : undefined} - active={m.organizationId === data.activeOrganizationId} - /> - ))} - {data.suggestions.map(s => ( - } - trailing={ - acceptSuggestion ? ( - acceptSuggestion(s.id)} - /> - ) : undefined - } - /> - ))} - {data.invitations.map(i => ( +
acceptInvitation(i.id)} + shape='circle' + name={data.activeSession.name} + secondary={data.activeSession.email} + imageUrl={data.activeSession.imageUrl} + onSelect={data.onSelectPersonal} + active={data.activeOrganizationId === null} + busyKey={userButtonBusyKeys.selectPersonal()} + hoverAction={ + signOutSession ? ( + signOutSession(data.activeSession.sessionId)} + busyKey={userButtonBusyKeys.signOutSession(data.activeSession.sessionId)} /> ) : undefined } /> - ))} + {data.memberships.map(m => ( + selectOrg(m.organizationId) : undefined} + active={m.organizationId === data.activeOrganizationId} + busyKey={userButtonBusyKeys.selectOrganization(m.organizationId)} + /> + ))} + {data.suggestions.map(s => + s.status === 'accepted' ? ( + } + /> + ) : ( + } + trailing={ + acceptSuggestion ? ( + acceptSuggestion(s.id)} + busyKey={userButtonBusyKeys.acceptSuggestion(s.id)} + /> + ) : undefined + } + /> + ), + )} + {data.invitations.map(i => ( + acceptInvitation(i.id)} + busyKey={userButtonBusyKeys.acceptInvitation(i.id)} + /> + ) : undefined + } + /> + ))} +
+ {data.hasMoreRows || data.isFetchingRows ? ( +
+ +
+ ) : null} +
+
{data.onCreateOrganization ? ( switchSession(a.sessionId) : undefined} + busyKey={userButtonBusyKeys.switchSession(a.sessionId)} /> ))} {data.onAddAccount ? ( @@ -790,22 +480,24 @@ function SessionsSection() { function Footer() { const theme = useMosaicTheme(); const data = useUserButtonContext(); - const { footer, signOutAll, branding } = useRecipe(userButtonRecipe); + const { footer, branding } = useRecipe(userButtonRecipe); return (
{data.onSignOutAll ? ( - + ) : null}
Secured by Clerk
@@ -814,7 +506,7 @@ function Footer() { // ─── Public parts ─────────────────────────────────────────────────────────── -export interface UserButtonRootProps extends UserButtonData, UserButtonCallbacks { +export interface UserButtonRootProps extends UserButtonData, UserButtonCallbacks, UserButtonBusyState { children: ReactNode; open?: boolean; defaultOpen?: boolean; @@ -881,6 +573,31 @@ export function UserButtonTrigger() { ); } +/** + * Placeholder shown in the trigger's slot while the controller loads, so the sidebar reserves the + * trigger's space and nothing shifts when the real avatar/name land. Non-interactive. + */ +export function UserButtonTriggerSkeleton() { + const { trigger } = useRecipe(userButtonRecipe); + return ( +
+ ({ borderRadius: t.rounded.full, flexShrink: 0 })} + /> + +
+ ); +} + /** The popover surface: header, workspace list, additional accounts, and footer. */ export function UserButtonPopup() { const data = useUserButtonContext(); @@ -890,7 +607,9 @@ export function UserButtonPopup() {
- {data.hasOrganizations ? : null} + {data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0 ? ( + + ) : null}