From c566a90f46a395c39f259899b5058102872ce62c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 26 Oct 2026 14:26:14 -0600 Subject: [PATCH 1/5] feat(mosaic): hold the backup codes menu open while regeneration starts --- .changeset/mfa-backup-codes-pending-menu.md | 2 + packages/mosaic/docs/controllers.md | 8 ++++ .../action-menu/action-menu.test.tsx | 14 +++++- .../components/action-menu/action-menu.tsx | 45 +++++++++++++++++-- ...er-profile-mfa-management.feature.test.tsx | 43 ++++++++++++++++++ .../user-profile-mfa-row.view.tsx | 5 ++- .../user-profile-mfa-section.controller.ts | 1 + .../user-profile-mfa-section.view.tsx | 4 ++ 8 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 .changeset/mfa-backup-codes-pending-menu.md 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..7d66b939b38 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -104,6 +104,14 @@ 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`, derive its `progress` from the pending +action and whether its dialog is open. Pass `idle` before activation, `pending` +while the request or verification preparation is running, and `handoff` when the +dialog becomes visible. The menu keeps its selected item and trailing spinner +visible while pending, disables its actions, and blocks dismissal until handoff +or settlement. Ordinary failures close the menu and use the section's existing +error. Actions without `progress` still close immediately. + 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..f4fddd9e49f 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -37,6 +37,18 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('closes a progress action that settles before pending renders', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + renderMenu([{ label: 'Make default', progress: 'idle', 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 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..5acb6d860e8 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; + progress?: 'idle' | 'pending' | 'handoff'; } export interface ActionMenuProps { @@ -20,12 +23,31 @@ 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.progress === 'pending'); + const handoff = actions.some(action => action.progress === 'handoff'); + const showSpinner = useSpinDelay(pending ? true : null); + + if (activated && !pending) { + setActivated(false); + setOpen(false); + } + if (actions.length === 0) { return null; } return ( - + { + if (!pending && !handoff) { + setOpen(nextOpen); + } + }} + > { + if (action.progress !== undefined) { + setActivated(true); + } + action.onClick(); + }} + closeOnClick={action.progress === undefined} + aria-busy={action.progress === 'pending' || undefined} + disabled={disabled || pending || handoff} > {action.icon ? ( @@ -47,6 +76,14 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen ) : null} {action.label} + {action.progress === 'pending' && showSpinner ? ( + + ) : null} ))} 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..7cc2ac406a0 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 @@ -15,7 +15,8 @@ export function UserProfileMfaRowView({ onRemove, onSetDefault, onRegenerateBackupCodes, -}: Pick & { + regenerateProgress, +}: Pick & { method: UserProfileMfaMethod; triggerRef?: Ref; onRemove?: () => void; @@ -37,7 +38,7 @@ export function UserProfileMfaRowView({ if (method.type === 'backup-codes') { if (onRegenerateBackupCodes) { - actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes }); + actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes, progress: regenerateProgress }); } } 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..a05a86b25ba 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 @@ -588,6 +588,7 @@ export function useUserProfileMfaController(model: ReadyModel) { methods: [...model.methods], addableMethods: model.addableMethods, onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, + regenerateProgress: context.pending === 'generate' ? (dialogOpen ? 'handoff' : 'pending') : 'idle', regenerateError: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined, onRemove: model.remove, onSetDefault: model.setDefault, 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..dfeab9bc911 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 @@ -3,6 +3,7 @@ import type { ReactNode, Ref } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { ReverificationDialog } from '../../../blocks/reverification'; +import type { ActionMenuAction } from '../../../components/action-menu'; import { Section } from '../../../components/section'; import { fill, type MosaicMessages, useMessages } from '../../../localization'; import type { ReverificationController } from '../../reverification'; @@ -22,6 +23,7 @@ export interface UserProfileMfaSectionViewProps { addControl?: ReactNode; onAdd?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; + regenerateProgress?: ActionMenuAction['progress']; regenerateError?: string; onRemove?: (id: string) => void | Promise; onSetDefault?: (id: string) => void | Promise; @@ -36,6 +38,7 @@ export function UserProfileMfaSectionView({ addControl, onAdd, onRegenerateBackupCodes, + regenerateProgress, regenerateError, onRemove, onSetDefault, @@ -83,6 +86,7 @@ export function UserProfileMfaSectionView({ onRemove={controller.remove ? () => controller.openRemoval(method) : undefined} onSetDefault={controller.setDefault} onRegenerateBackupCodes={onRegenerateBackupCodes} + regenerateProgress={regenerateProgress} /> ))} From f1726fba201c54390627823546390b2dd4233235 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 26 Oct 2026 17:19:03 -0600 Subject: [PATCH 2/5] feat(mosaic): add Menu.Indicator trailing slot for the action menu spinner --- .../components/action-menu/action-menu.tsx | 18 +++++++++++------- .../mosaic/src/components/menu/menu.styles.ts | 11 +++++++++++ packages/mosaic/src/components/menu/menu.tsx | 19 ++++++++++++++++++- .../swingset/src/stories/menu.component.mdx | 14 +++++++++++--- .../src/stories/menu.component.stories.tsx | 14 ++++++++------ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 5acb6d860e8..22c507dbda9 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -76,13 +76,17 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen ) : null} {action.label} - {action.progress === 'pending' && showSpinner ? ( - + {action.progress !== undefined ? ( + + {action.progress === 'pending' && showSpinner ? ( + + ) : null} + ) : null} ))} 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/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx index b7e0fdd6f9f..ad7793be06a 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -70,7 +70,7 @@ 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 +from `Menu.Media`, `Menu.Label` and `Menu.Indicator`, and those children are required: 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} + ))} From 31c7b55fe08c7133ac03bcb5a389c5dd9f7ac547 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 26 Oct 2026 20:10:27 -0600 Subject: [PATCH 3/5] refactor(mosaic): replace ActionMenu progress states with a pending flag --- packages/mosaic/docs/controllers.md | 13 +++--- .../action-menu/action-menu.test.tsx | 4 +- .../components/action-menu/action-menu.tsx | 41 ++++++++----------- .../user-profile-mfa-row.view.tsx | 6 +-- .../user-profile-mfa-section.controller.ts | 2 +- .../user-profile-mfa-section.view.tsx | 7 ++-- 6 files changed, 33 insertions(+), 40 deletions(-) diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index 7d66b939b38..d3429f7462c 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -104,13 +104,12 @@ 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`, derive its `progress` from the pending -action and whether its dialog is open. Pass `idle` before activation, `pending` -while the request or verification preparation is running, and `handoff` when the -dialog becomes visible. The menu keeps its selected item and trailing spinner -visible while pending, disables its actions, and blocks dismissal until handoff -or settlement. Ordinary failures close the menu and use the section's existing -error. Actions without `progress` still close immediately. +For a direct action in `ActionMenu`, set `pending` while its request runs and +no dialog is open yet. Set it in the same event as the click, since the menu +closes on the next render unless an action is pending. The menu keeps its +trailing spinner visible while pending, disables its actions, and blocks +dismissal. It closes once `pending` turns false, whether a dialog opened or the +request settled. 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 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 f4fddd9e49f..1d0881a35c9 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -37,10 +37,10 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); - it('closes a progress action that settles before pending renders', async () => { + it('closes after an action that does not report pending', async () => { const onClick = vi.fn(); const user = userEvent.setup(); - renderMenu([{ label: 'Make default', progress: 'idle', onClick }]); + renderMenu([{ label: 'Make default', onClick }]); await user.click(screen.getByRole('button', { name: 'Manage passkey' })); await user.click(await screen.findByRole('menuitem', { name: 'Make default' })); diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 22c507dbda9..6f99b84ad75 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -11,7 +11,7 @@ export interface ActionMenuAction { color?: 'neutral' | 'negative'; icon?: IconComponent; onClick: () => void; - progress?: 'idle' | 'pending' | 'handoff'; + pending?: boolean; } export interface ActionMenuProps { @@ -25,8 +25,7 @@ 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.progress === 'pending'); - const handoff = actions.some(action => action.progress === 'handoff'); + const pending = actions.some(action => action.pending); const showSpinner = useSpinDelay(pending ? true : null); if (activated && !pending) { @@ -41,9 +40,9 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen return ( { - if (!pending && !handoff) { + if (!pending) { setOpen(nextOpen); } }} @@ -61,14 +60,12 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen color={action.color} label={action.label} onClick={() => { - if (action.progress !== undefined) { - setActivated(true); - } + setActivated(true); action.onClick(); }} - closeOnClick={action.progress === undefined} - aria-busy={action.progress === 'pending' || undefined} - disabled={disabled || pending || handoff} + closeOnClick={false} + aria-busy={action.pending || undefined} + disabled={disabled || pending} > {action.icon ? ( @@ -76,18 +73,16 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen ) : null} {action.label} - {action.progress !== undefined ? ( - - {action.progress === 'pending' && showSpinner ? ( - - ) : null} - - ) : null} + + {action.pending && showSpinner ? ( + + ) : null} + ))} 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 7cc2ac406a0..e86e4c6615d 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 @@ -15,8 +15,8 @@ export function UserProfileMfaRowView({ onRemove, onSetDefault, onRegenerateBackupCodes, - regenerateProgress, -}: Pick & { + regeneratePending, +}: Pick & { method: UserProfileMfaMethod; triggerRef?: Ref; onRemove?: () => void; @@ -38,7 +38,7 @@ export function UserProfileMfaRowView({ if (method.type === 'backup-codes') { if (onRegenerateBackupCodes) { - actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes, progress: regenerateProgress }); + actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes, pending: regeneratePending }); } } 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 a05a86b25ba..77b76137dae 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 @@ -588,7 +588,7 @@ export function useUserProfileMfaController(model: ReadyModel) { methods: [...model.methods], addableMethods: model.addableMethods, onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, - regenerateProgress: context.pending === 'generate' ? (dialogOpen ? 'handoff' : 'pending') : 'idle', + regeneratePending: context.pending === 'generate' && !dialogOpen, regenerateError: flow.kind === 'closed' && flow.error ? errorText(flow.error) : undefined, onRemove: model.remove, onSetDefault: model.setDefault, 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 dfeab9bc911..aeef717df25 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 @@ -3,7 +3,6 @@ import type { ReactNode, Ref } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { ReverificationDialog } from '../../../blocks/reverification'; -import type { ActionMenuAction } from '../../../components/action-menu'; import { Section } from '../../../components/section'; import { fill, type MosaicMessages, useMessages } from '../../../localization'; import type { ReverificationController } from '../../reverification'; @@ -23,7 +22,7 @@ export interface UserProfileMfaSectionViewProps { addControl?: ReactNode; onAdd?: (type: UserProfileMfaAddableMethod) => void; onRegenerateBackupCodes?: () => void; - regenerateProgress?: ActionMenuAction['progress']; + regeneratePending?: boolean; regenerateError?: string; onRemove?: (id: string) => void | Promise; onSetDefault?: (id: string) => void | Promise; @@ -38,7 +37,7 @@ export function UserProfileMfaSectionView({ addControl, onAdd, onRegenerateBackupCodes, - regenerateProgress, + regeneratePending, regenerateError, onRemove, onSetDefault, @@ -86,7 +85,7 @@ export function UserProfileMfaSectionView({ onRemove={controller.remove ? () => controller.openRemoval(method) : undefined} onSetDefault={controller.setDefault} onRegenerateBackupCodes={onRegenerateBackupCodes} - regenerateProgress={regenerateProgress} + regeneratePending={regeneratePending} /> ))} From 29961d8a2629b1771bbfd827360c54044f0170bf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 26 Oct 2026 20:24:39 -0600 Subject: [PATCH 4/5] fix(mosaic): reserve the action menu spinner column only on actions that report pending --- packages/mosaic/docs/controllers.md | 3 ++- .../components/action-menu/action-menu.tsx | 22 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index d3429f7462c..e1b16e471d0 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -105,7 +105,8 @@ dismissal only while the controller has a cancel callback. Return focus with the `lastKey`, which outlives the run, unlike `pendingKey`. For a direct action in `ActionMenu`, set `pending` while its request runs and -no dialog is open yet. Set it in the same event as the click, since the menu +no dialog is open yet, and `false` otherwise so its row keeps room for the +spinner. Set it in the same event as the click, since the menu closes on the next render unless an action is pending. The menu keeps its trailing spinner visible while pending, disables its actions, and blocks dismissal. It closes once `pending` turns false, whether a dialog opened or the diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 6f99b84ad75..c2476d85cbf 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -73,16 +73,18 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen ) : null} {action.label} - - {action.pending && showSpinner ? ( - - ) : null} - + {action.pending !== undefined ? ( + + {action.pending && showSpinner ? ( + + ) : null} + + ) : null} ))} From 347eec9422a37898a60e9faa0b7fa53d87349f54 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 27 Oct 2026 14:58:55 -0600 Subject: [PATCH 5/5] fix(mosaic): address pending action menu review feedback --- packages/mosaic/docs/controllers.md | 15 ++-- .../action-menu/action-menu.test.tsx | 72 ++++++++++++++++++- .../components/action-menu/action-menu.tsx | 22 +++--- .../src/components/button/submit-button.tsx | 10 +-- .../src/components/spinner/spinner.test.tsx | 9 ++- .../mosaic/src/components/spinner/spinner.tsx | 10 +-- .../src/features/switcher/switcher.view.tsx | 4 +- .../user-profile-security-panel.view.test.tsx | 4 +- .../user-profile-mfa-row.view.tsx | 9 ++- .../user-profile-mfa-section.controller.ts | 10 ++- .../user-profile-mfa-section.view.tsx | 13 ++-- .../stories/fixtures/user-profile-mfa.test.ts | 18 ++--- .../src/stories/fixtures/user-profile-mfa.ts | 6 +- .../swingset/src/stories/menu.component.mdx | 4 +- 14 files changed, 138 insertions(+), 68 deletions(-) diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index e1b16e471d0..d2aca72f306 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -104,13 +104,14 @@ 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`, set `pending` while its request runs and -no dialog is open yet, and `false` otherwise so its row keeps room for the -spinner. Set it in the same event as the click, since the menu -closes on the next render unless an action is pending. The menu keeps its -trailing spinner visible while pending, disables its actions, and blocks -dismissal. It closes once `pending` turns false, whether a dialog opened or the -request settled. Ordinary failures use the section's existing error. +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 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 1d0881a35c9..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, waitFor } 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([]); @@ -49,6 +71,54 @@ describe('Mosaic ActionMenu', () => { 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 c2476d85cbf..76f6d63b139 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -11,7 +11,7 @@ export interface ActionMenuAction { color?: 'neutral' | 'negative'; icon?: IconComponent; onClick: () => void; - pending?: boolean; + status?: 'idle' | 'pending'; } export interface ActionMenuProps { @@ -25,8 +25,8 @@ 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.pending); - const showSpinner = useSpinDelay(pending ? true : null); + const pending = actions.some(action => action.status === 'pending'); + const showSpinner = useSpinDelay(pending ? true : null, { minDuration: 0 }); if (activated && !pending) { setActivated(false); @@ -60,11 +60,13 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen color={action.color} label={action.label} onClick={() => { - setActivated(true); + if (action.status !== undefined) { + setActivated(true); + } action.onClick(); }} - closeOnClick={false} - aria-busy={action.pending || undefined} + closeOnClick={action.status === undefined} + aria-busy={action.status === 'pending' || undefined} disabled={disabled || pending} > {action.icon ? ( @@ -73,14 +75,12 @@ export function ActionMenu({ label, actions, triggerRef, disabled, focusableWhen ) : null} {action.label} - {action.pending !== undefined ? ( + {action.status !== undefined ? ( - {action.pending && showSpinner ? ( + {action.status === 'pending' && showSpinner ? ( ) : 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/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/user-profile-mfa-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/user-profile-mfa-row.view.tsx index e86e4c6615d..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,9 +14,8 @@ export function UserProfileMfaRowView({ triggerRef, onRemove, onSetDefault, - onRegenerateBackupCodes, - regeneratePending, -}: Pick & { + regenerate, +}: Pick & { method: UserProfileMfaMethod; triggerRef?: Ref; onRemove?: () => void; @@ -37,8 +36,8 @@ export function UserProfileMfaRowView({ } if (method.type === 'backup-codes') { - if (onRegenerateBackupCodes) { - actions.push({ label: m.regenerate, onClick: onRegenerateBackupCodes, pending: regeneratePending }); + 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 77b76137dae..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,9 +587,13 @@ export function useUserProfileMfaController(model: ReadyModel) { const sectionProps: UserProfileMfaSectionViewProps = { methods: [...model.methods], addableMethods: model.addableMethods, - onRegenerateBackupCodes: model.generateBackupCodes ? generateBackupCodes : undefined, - regeneratePending: context.pending === 'generate' && !dialogOpen, - 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 aeef717df25..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,9 +21,7 @@ export interface UserProfileMfaSectionViewProps { addButtonRef?: Ref; addControl?: ReactNode; onAdd?: (type: UserProfileMfaAddableMethod) => void; - onRegenerateBackupCodes?: () => void; - regeneratePending?: boolean; - regenerateError?: string; + regenerate?: { onClick: () => void; status: 'idle' | 'pending'; error?: string }; onRemove?: (id: string) => void | Promise; onSetDefault?: (id: string) => void | Promise; removeReverification?: ReverificationController; @@ -36,9 +34,7 @@ export function UserProfileMfaSectionView({ addButtonRef, addControl, onAdd, - onRegenerateBackupCodes, - regeneratePending, - regenerateError, + regenerate, onRemove, onSetDefault, removeReverification, @@ -84,12 +80,11 @@ export function UserProfileMfaSectionView({ triggerRef={controller.registerTrigger(method.id)} onRemove={controller.remove ? () => controller.openRemoval(method) : undefined} onSetDefault={controller.setDefault} - onRegenerateBackupCodes={onRegenerateBackupCodes} - regeneratePending={regeneratePending} + 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 ad7793be06a..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`, `Menu.Label` and `Menu.Indicator`, 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