diff --git a/.changeset/mfa-backup-codes-pending-menu.md b/.changeset/mfa-backup-codes-pending-menu.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mfa-backup-codes-pending-menu.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index ca7d158c2c5..d2aca72f306 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -104,6 +104,15 @@ section. The dialog opens once `visible` is true, so it skips the loading card, dismissal only while the controller has a cancel callback. Return focus with the hook's `lastKey`, which outlives the run, unlike `pendingKey`. +For a direct action in `ActionMenu`, opt in with `status: 'idle'`. Set it to +`'pending'` while its request runs and no dialog is open yet. Update the status +in the click event, since the menu closes on the next render unless an action +is pending. The menu reserves room for a spinner, disables its actions, and +blocks dismissal while pending. It closes when the status returns to `'idle'`, +whether a dialog opened or the request settled. Leave `status` undefined for +ordinary actions that close through the menu's native click behavior. Ordinary +failures use the section's existing error. + A single open boolean earns nothing. A two-state machine with one event is a boolean spelled long. `useUserProfileEditUsernameController` holds `isOpen` in `useState` and hands the async part to `useForm`: diff --git a/packages/mosaic/src/components/action-menu/action-menu.test.tsx b/packages/mosaic/src/components/action-menu/action-menu.test.tsx index 520deb20c17..c6ce65dc78a 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { Pen } from '../../icons/glyphs/pen'; @@ -17,6 +18,27 @@ function renderMenu(actions: Parameters[0]['actions']) { ); } +function AsyncMenu({ onRemove = () => undefined }: { onRemove?: () => void }) { + const [status, setStatus] = useState<'idle' | 'pending'>('idle'); + return ( + + setStatus('pending') }, + { label: 'Remove', onClick: onRemove }, + ]} + /> + + + ); +} + describe('Mosaic ActionMenu', () => { it('renders nothing when there are no actions', () => { renderMenu([]); @@ -37,6 +59,66 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('closes after an action that does not report pending', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + renderMenu([{ label: 'Make default', onClick }]); + + await user.click(screen.getByRole('button', { name: 'Manage passkey' })); + await user.click(await screen.findByRole('menuitem', { name: 'Make default' })); + + expect(onClick).toHaveBeenCalledOnce(); + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + }); + + it('keeps an opted-in action open while pending and closes immediately when it settles', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Manage passkey' })); + await user.click(await screen.findByRole('menuitem', { name: 'Regenerate' })); + const menu = screen.getByRole('menu'); + expect(within(menu).getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true'); + expect(within(menu).getByRole('menuitem', { name: 'Remove' })).toHaveAttribute('aria-disabled', 'true'); + await user.click(within(menu).getByRole('menuitem', { name: 'Remove' })); + expect(onRemove).not.toHaveBeenCalled(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('menu')).toBeInTheDocument(); + await user.click(document.body); + expect(screen.getByRole('menu')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Finish request' })); + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + }); + + it('reserves an indicator immediately and delays its named spinner', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Manage passkey' })); + const item = await screen.findByRole('menuitem', { name: 'Regenerate' }); + expect(item.querySelector('.cl-menu-indicator')).toBeInTheDocument(); + vi.useFakeTimers(); + try { + fireEvent.click(item); + expect(screen.queryByRole('progressbar', { name: 'Regenerate' })).not.toBeInTheDocument(); + await act(() => vi.advanceTimersByTime(149)); + expect(screen.queryByRole('progressbar', { name: 'Regenerate' })).not.toBeInTheDocument(); + await act(() => vi.advanceTimersByTime(1)); + expect(screen.getByRole('progressbar', { name: 'Regenerate' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Finish request' })); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('closes an opted-in action that hands off before becoming pending', async () => { + const user = userEvent.setup(); + renderMenu([{ label: 'Open dialog', status: 'idle', onClick: () => undefined }]); + await user.click(screen.getByRole('button', { name: 'Manage passkey' })); + await user.click(await screen.findByRole('menuitem', { name: 'Open dialog' })); + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()); + }); + it('keeps a disabled trigger focusable without allowing pointer or keyboard activation', async () => { const onClick = vi.fn(); render( diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 73bb62de3f4..76f6d63b139 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -1,14 +1,17 @@ -import type { Ref } from 'react'; +import { type Ref, useState } from 'react'; +import { useSpinDelay } from '../../hooks/use-spin-delay'; import type { IconComponent } from '../../icons/glyph'; import { Icon } from '../icon'; import { Menu } from '../menu'; +import { Spinner } from '../spinner'; export interface ActionMenuAction { label: string; color?: 'neutral' | 'negative'; icon?: IconComponent; onClick: () => void; + status?: 'idle' | 'pending'; } export interface ActionMenuProps { @@ -20,12 +23,30 @@ export interface ActionMenuProps { } export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhenDisabled }: ActionMenuProps) { + const [open, setOpen] = useState(false); + const [activated, setActivated] = useState(false); + const pending = actions.some(action => action.status === 'pending'); + const showSpinner = useSpinDelay(pending ? true : null, { minDuration: 0 }); + + if (activated && !pending) { + setActivated(false); + setOpen(false); + } + if (actions.length === 0) { return null; } return ( - + { + if (!pending) { + setOpen(nextOpen); + } + }} + > { + if (action.status !== undefined) { + setActivated(true); + } + action.onClick(); + }} + closeOnClick={action.status === undefined} + aria-busy={action.status === 'pending' || undefined} + disabled={disabled || pending} > {action.icon ? ( @@ -47,6 +75,16 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen ) : null} {action.label} + {action.status !== undefined ? ( + + {action.status === 'pending' && showSpinner ? ( + + ) : null} + + ) : null} ))} diff --git a/packages/mosaic/src/components/button/submit-button.tsx b/packages/mosaic/src/components/button/submit-button.tsx index ab2271e8bf6..4e99b354241 100644 --- a/packages/mosaic/src/components/button/submit-button.tsx +++ b/packages/mosaic/src/components/button/submit-button.tsx @@ -96,15 +96,7 @@ export const SubmitButton = React.forwardRef {isPending || showPending ? ( diff --git a/packages/mosaic/src/components/menu/menu.styles.ts b/packages/mosaic/src/components/menu/menu.styles.ts index 11ec39b5c9a..5ea1d18f4ee 100644 --- a/packages/mosaic/src/components/menu/menu.styles.ts +++ b/packages/mosaic/src/components/menu/menu.styles.ts @@ -160,6 +160,17 @@ export const label = stylex.create({ }, }); +export const indicator = stylex.create({ + base: { + alignItems: 'center', + aspectRatio: '1/1', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + width: space['4'], + }, +}); + export const separator = stylex.create({ base: { // Full-bleed across the popup: cancel the viewport's inline padding. diff --git a/packages/mosaic/src/components/menu/menu.tsx b/packages/mosaic/src/components/menu/menu.tsx index 851aad729b5..9fa8081b4a0 100644 --- a/packages/mosaic/src/components/menu/menu.tsx +++ b/packages/mosaic/src/components/menu/menu.tsx @@ -167,11 +167,27 @@ export const MenuLabel = React.forwardRef(funct }); }); +export type MenuIndicatorProps = MosaicComponentProps<'span'>; + +export const MenuIndicator = React.forwardRef(function MosaicMenuIndicator( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps(themeProps('menu-indicator'), stylex.props(reset.base, slots.indicator.base, xstyle), rest), + }, + }); +}); + export interface MenuItemProps extends Omit, MosaicStyleProps { /** Semantic color of the action. */ color?: 'neutral' | 'negative'; /** - * The row's content, composed from `Menu.Media` and `Menu.Label`. Required, and text goes in + * The row's content, composed from `Menu.Media`, `Menu.Label` and `Menu.Indicator`. Required, and text goes in * `Menu.Label` rather than straight in here: a bare text node is not a flex item the row can * size, so it neither lines up with the other rows nor truncates. */ @@ -227,5 +243,6 @@ export const Menu = { Item: MenuItem, Media: MenuMedia, Label: MenuLabel, + Indicator: MenuIndicator, Separator: MenuSeparator, }; diff --git a/packages/mosaic/src/components/spinner/spinner.test.tsx b/packages/mosaic/src/components/spinner/spinner.test.tsx index f185ef37ee5..b5151213fd5 100644 --- a/packages/mosaic/src/components/spinner/spinner.test.tsx +++ b/packages/mosaic/src/components/spinner/spinner.test.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { describe, expect, it } from 'vitest'; @@ -20,6 +20,13 @@ describe('Mosaic Spinner', () => { expect(el).toHaveAttribute('aria-hidden', 'true'); }); + it('announces a labeled spinner as an indeterminate progressbar', () => { + render(); + const progress = screen.getByRole('progressbar', { name: 'Regenerating backup codes' }); + expect(progress).not.toHaveAttribute('aria-hidden'); + expect(progress).not.toHaveAttribute('aria-valuenow'); + }); + it('applies the default size when none is passed', () => { const { container } = render(); expect(spinner(container)).toHaveAttribute('data-size', 'md'); diff --git a/packages/mosaic/src/components/spinner/spinner.tsx b/packages/mosaic/src/components/spinner/spinner.tsx index bfb16853c53..0c5b9d05f6b 100644 --- a/packages/mosaic/src/components/spinner/spinner.tsx +++ b/packages/mosaic/src/components/spinner/spinner.tsx @@ -7,11 +7,11 @@ import { sizes, styles } from './spinner.styles'; export type SpinnerProps = MosaicElementProps<'span'> & { size?: 'sm' | 'md'; + label?: string; }; /** - * 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. + * Indeterminate loading spinner. Decorative by default; pass `label` to announce progress. * * @example * // Default (md), sized to match a `md` Icon @@ -22,13 +22,15 @@ export type SpinnerProps = MosaicElementProps<'span'> & { * */ export const Spinner = React.forwardRef(function MosaicSpinner( - { size = 'md', xstyle, ...rest }, + { size = 'md', label, xstyle, ...rest }, ref, ) { return ( ); diff --git a/packages/mosaic/src/features/switcher/switcher.view.tsx b/packages/mosaic/src/features/switcher/switcher.view.tsx index 8d6667357b7..824ee1b78ba 100644 --- a/packages/mosaic/src/features/switcher/switcher.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher.view.tsx @@ -197,9 +197,7 @@ function PendingSpinner() { const m = useSwitcherMessages(); return ( ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index 82087518ee9..920a0a08015 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -268,7 +268,7 @@ describe('UserProfileSecurityPanelView', () => { const backupOnlyView = renderView( , ); @@ -280,7 +280,7 @@ describe('UserProfileSecurityPanelView', () => { renderView( , ); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx index 99640f3e314..f146797e6cf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa-management.feature.test.tsx @@ -406,6 +406,49 @@ describe('User profile MFA management', () => { expect(fapi.mfa.backupCodeCreations).toBe(1); }); + it('holds the menu open with a spinner on Regenerate until the backup codes are ready', async () => { + await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + mfaEnvironment(), + ); + const generate = holdRequests('post', '/v1/me/backup_codes/'); + const user = userEvent.setup(); + + try { + await regenerate(user); + await waitFor(() => expect(generate.requests).toHaveLength(1)); + expect(screen.getByRole('menu')).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true'); + expect(await screen.findByRole('progressbar', { name: 'Regenerate' })).toBeVisible(); + expect(screen.queryByRole('dialog')).toBeNull(); + } finally { + generate.release(); + } + + const list = await screen.findByRole('list', { name: 'Backup codes' }); + expect(within(list).getAllByRole('listitem')).toHaveLength(10); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + }); + + it('holds the Regenerate menu item until session verification opens', async () => { + await renderBackupCodesNeedingReverification(); + const start = holdRequests('post', '/v1/client/sessions/:id/verify'); + const user = userEvent.setup(); + + try { + await regenerate(user); + await waitFor(() => expect(start.requests).toHaveLength(1)); + expect(screen.getByRole('menu')).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Regenerate' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByText('Enter your current password to continue')).toBeNull(); + } finally { + start.release(); + } + + expect(await screen.findByText('Enter your current password to continue')).toBeVisible(); + await waitFor(() => expect(screen.queryByRole('menu')).toBeNull()); + }); + async function renderBackupCodesNeedingReverification() { const fapi = await renderMfa( fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx index 9b031d955ae..a4496159858 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx @@ -14,8 +14,8 @@ export function UserProfileMfaRowView({ triggerRef, onRemove, onSetDefault, - onRegenerateBackupCodes, -}: Pick & { + regenerate, +}: Pick & { method: UserProfileMfaMethod; triggerRef?: Ref; onRemove?: () => void; @@ -36,8 +36,8 @@ export function UserProfileMfaRowView({ } if (method.type === 'backup-codes') { - if (onRegenerateBackupCodes) { - actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes }); + if (regenerate) { + actions.push({ label: m.regenerate, onClick: regenerate.onClick, status: regenerate.status }); } } else if (onRemove && method.canRemove !== false) { actions.push({ label: m.remove, color: 'negative', onClick: onRemove }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts index aaf4638c728..d6311ba4808 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.controller.ts @@ -587,8 +587,13 @@ export function useUserProfileMfaController(model: ReadyModel) { const sectionProps: UserProfileMfaSectionViewProps = { methods: [...model.methods], addableMethods: model.addableMethods, - onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, - regenerateError: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined, + regenerate: model.generateBackupCodes + ? { + onClick: generateBackupCodes, + status: context.pending === 'generate' && !dialogOpen ? 'pending' : 'idle', + error: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined, + } + : undefined, onRemove: model.remove, onSetDefault: model.setDefault, removeReverification: model.removeReverification, diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx index bbd9969879d..a5048a26df8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view.tsx @@ -21,8 +21,7 @@ export interface UserProfileMfaSectionViewProps { addButtonRef?: Ref; addControl?: ReactNode; onAdd?: (type: UserProfileMfaAddableMethod) => void; - onRegenerateBackupCodes?: () => void; - regenerateError?: string; + regenerate?: { onClick: () => void; status: 'idle' | 'pending'; error?: string }; onRemove?: (id: string) => void | Promise; onSetDefault?: (id: string) => void | Promise; removeReverification?: ReverificationController; @@ -35,8 +34,7 @@ export function UserProfileMfaSectionView({ addButtonRef, addControl, onAdd, - onRegenerateBackupCodes, - regenerateError, + regenerate, onRemove, onSetDefault, removeReverification, @@ -82,11 +80,11 @@ export function UserProfileMfaSectionView({ triggerRef={controller.registerTrigger(method.id)} onRemove={controller.remove ? () => controller.openRemoval(method) : undefined} onSetDefault={controller.setDefault} - onRegenerateBackupCodes={onRegenerateBackupCodes} + regenerate={regenerate} /> ))} - {regenerateError ?? controller.defaultError} + {regenerate?.error ?? controller.defaultError} {controller.remove ? ( { expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.pendingAction).toBe('generate'); expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); - expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + expect(result.current.section.regenerate).toBeUndefined(); await act(async () => { request.resolve(regeneratedCodes); @@ -63,7 +63,7 @@ describe('MFA playground', () => { expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); expect(result.current.backupCodes.pendingAction).toBeUndefined(); expect(result.current.section.addableMethods).not.toContain('backup-codes'); - expect(result.current.section.onRegenerateBackupCodes).toBeDefined(); + expect(result.current.section.regenerate).toBeDefined(); expect(onGenerateBackupCodes).toHaveBeenCalledOnce(); }); @@ -101,7 +101,7 @@ describe('MFA playground', () => { expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.section.methods.map(method => method.type)).toEqual(['sms']); expect(result.current.section.addableMethods).toContain('backup-codes'); - expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + expect(result.current.section.regenerate).toBeUndefined(); await complete(() => result.current.backupCodes.onRetry()); expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); @@ -111,7 +111,7 @@ describe('MFA playground', () => { await complete(() => result.current.backupCodes.onCopy()); expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); - await complete(() => result.current.section.onRegenerateBackupCodes?.()); + await complete(() => result.current.section.regenerate?.onClick()); expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.section.methods.filter(method => method.type === 'backup-codes')).toHaveLength(1); expect(onGenerateBackupCodes).toHaveBeenCalledTimes(3); @@ -130,7 +130,7 @@ describe('MFA playground', () => { await complete(() => result.current.section.onAdd?.('backup-codes')); act(() => result.current.setup.onOpenChange(false)); onGenerateBackupCodes.mockRejectedValueOnce(new Error('Regeneration failed')); - await complete(() => result.current.section.onRegenerateBackupCodes?.()); + await complete(() => result.current.section.regenerate?.onClick()); expect(result.current.backupCodes.onBack).toBeUndefined(); act(() => result.current.setup.onOpenChange(false)); expect(result.current.setup.open).toBe(false); @@ -155,7 +155,7 @@ describe('MFA playground', () => { await complete(() => result.current.backupCodes.onCopy()); expect(onCopy).toHaveBeenCalledExactlyOnceWith(codes); expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); - await complete(() => result.current.section.onRegenerateBackupCodes?.()); + await complete(() => result.current.section.regenerate?.onClick()); expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual(regeneratedCodes); expect(onGenerateBackupCodes).toHaveBeenCalledOnce(); @@ -284,7 +284,7 @@ describe('MFA playground', () => { expect(result.current.section.methods).toEqual([]); expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); - expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + expect(result.current.section.regenerate).toBeUndefined(); expect(result.current.section.addableMethods).toEqual(['sms', 'authenticator']); act(() => result.current.section.onAdd?.('authenticator')); @@ -310,7 +310,7 @@ describe('MFA playground', () => { expect(result.current.section.addableMethods).toContain('backup-codes'); expect(result.current.setup).not.toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual([]); - expect(result.current.section.onRegenerateBackupCodes).toBeUndefined(); + expect(result.current.section.regenerate).toBeUndefined(); }, ); @@ -326,7 +326,7 @@ describe('MFA playground', () => { } else { onGenerateBackupCodes.mockResolvedValueOnce([]); } - await complete(() => result.current.section.onRegenerateBackupCodes?.()); + await complete(() => result.current.section.regenerate?.onClick()); expect(result.current.setup).toMatchObject({ open: true, step: 'backup-codes' }); expect(result.current.backupCodes.codes).toEqual([]); expect(result.current.backupCodes.errorMessage).toContain('Unable to generate'); diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts index 38a3ff41d70..af46c88b510 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa.ts @@ -333,8 +333,10 @@ export function useUserProfileMfaFixture({ methods, addableMethods, onAdd: open, - onRegenerateBackupCodes: - account.hasBackupCodes && onGenerateBackupCodes ? () => void generateBackupCodes('regenerate') : undefined, + regenerate: + account.hasBackupCodes && onGenerateBackupCodes + ? { onClick: () => void generateBackupCodes('regenerate'), status: 'idle' as const } + : undefined, onSetDefault: async id => { await pause(); setAccount(current => ({ ...current, defaultPhoneId: id })); diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx index b7e0fdd6f9f..ceb40500deb 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -69,8 +69,8 @@ props (ARIA attributes, click and keyboard handlers) to spread. ### Items -`label` names the item for typeahead and for assistive technology. What the row shows is composed -from `Menu.Media` and `Menu.Label`, and those children are required: text dropped straight into the +`label` names the item for typeahead and for assistive technology. Compose the row with +`Menu.Label` and optional `Menu.Media` and `Menu.Indicator` parts. Text dropped straight into the item is not a flex child the row can size, so it neither lines up with the other rows nor truncates. A row with nothing to lead it is `Menu.Label` alone. Use `color='negative'` for destructive actions; the color is inherited by the children. `disabled` items @@ -121,7 +121,13 @@ line. Items whose text is a name — an account, an organization — need it mos the menu instead of ellipsing. It is also what pushes a trailing mark to the end of the row. Use it for every composed item, so rows built from parts all read the same. -The two together are the whole of a row that leads with an avatar and trails with a check: +### Indicator + +`Menu.Indicator` is a square trailing column for a mark at the end of the row, such as a check or a +spinner. It keeps its width while empty, so render it on any item whose mark can appear later and +the label does not shift when it does. + +Together they make a row that leads with an avatar and trails with a check: ```tsx @@ -134,7 +140,9 @@ The two together are the whole of a row that leads with an avatar and trails wit colin@clerk.dev - + + + ``` diff --git a/packages/swingset/src/stories/menu.component.stories.tsx b/packages/swingset/src/stories/menu.component.stories.tsx index 541813050f6..bc19a5cf3c7 100644 --- a/packages/swingset/src/stories/menu.component.stories.tsx +++ b/packages/swingset/src/stories/menu.component.stories.tsx @@ -78,12 +78,14 @@ export function Accounts() { {account.identifier} - {account.active ? ( - - ) : null} + + {account.active ? ( + + ) : null} + ))}