diff --git a/.changeset/mosaic-active-device-dialogs.md b/.changeset/mosaic-active-device-dialogs.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-active-device-dialogs.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.claude/skills/mosaic/references/views.md b/.claude/skills/mosaic/references/views.md
index 1bc0f536243..3a6a9cca927 100644
--- a/.claude/skills/mosaic/references/views.md
+++ b/.claude/skills/mosaic/references/views.md
@@ -80,6 +80,48 @@ Which affordance lands in which slot, and how a consumer's `order` array
rearranges a list, are decisions with no React in them. They live in
`*.layout.ts` / `*.utils.ts` and get their own tests — the view calls the result.
+## Removing a row takes focus with it
+
+A dialog returns focus to its trigger on close. When the action removes the row
+that trigger sits on — signing a device out, removing an email — the trigger
+unmounts and focus falls to `
`. Nothing catches it: the menu item that
+opened the dialog unmounted with the menu, and a dialog mounted at the section
+rather than inside the menu has no floating-tree ancestor to walk back to. A
+keyboard user loses their place mid-list and a screen reader announces nothing.
+
+Hand focus to a surviving element. `finalFocus` on `Dialog.Popup` and on the
+`Confirmation` block takes a function, resolved when the dialog closes — which is
+after the row has gone, so it can pick from what is left:
+
+```tsx
+const triggers = useRef(new Map());
+const removed = useRef(undefined);
+
+const removeRow = async (row: Row) => {
+ const index = rows.findIndex(candidate => candidate.id === row.id);
+ await onRemove(row.id);
+ // Only once it is really gone: a cancelled or failed attempt keeps its own trigger.
+ removed.current = index;
+};
+
+const focusAfterRemove = () => {
+ const index = removed.current;
+ removed.current = undefined;
+ if (index === undefined) {
+ return null; // null keeps the default — the trigger, which is still there
+ }
+ const next = rows[Math.min(index, rows.length - 1)] ?? anchorRow;
+ return (next && triggers.current.get(next.id)) ?? null;
+};
+```
+
+Prefer the row that took the removed one's place, the last row when it was the
+last, and a control that outlives the list once it is empty.
+
+Test the removal, not just the cancel: `toHaveFocus()` on the row that should
+have caught it. A suite that only asserts focus after cancelling passes while
+every successful removal drops focus on the floor.
+
## Testing
Render the view directly with **plain props and `vi.fn()` callbacks**. No Clerk
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
index 43ff3ce7cbc..2b38b622b71 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
@@ -3,11 +3,16 @@ import type { ReactNode } from 'react';
import { Banner } from '../../components/banner';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
-import type { DialogHandle, DialogTriggerProps } from '../../components/dialog';
+import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
import { useConfirmationController } from './confirmation.controller';
+/** The weight the confirming button carries: an undoable action takes `primary`. */
+export type ConfirmationColor = 'negative' | 'primary';
+
interface ConfirmationCardProps {
+ color: ConfirmationColor;
+ finalFocus: DialogFocusTarget | undefined;
title: string;
description: ReactNode;
actionLabel: string;
@@ -18,6 +23,8 @@ interface ConfirmationCardProps {
}
function ConfirmationCard({
+ color,
+ finalFocus,
title,
description,
actionLabel,
@@ -27,7 +34,10 @@ function ConfirmationCard({
errorMessage,
}: ConfirmationCardProps) {
return (
-
+
@@ -75,6 +85,13 @@ function ConfirmationCard({
export interface ConfirmationControlledProps {
/** Whether the dialog is open */
open: boolean;
+ /** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
+ color?: ConfirmationColor;
+ /**
+ * Where focus returns when the dialog closes. Default: the trigger — which a confirmed removal
+ * may have taken off the page, so a list hands back the row that replaced it instead.
+ */
+ finalFocus?: DialogFocusTarget;
/** Callback when open state changes */
onOpenChange: (open: boolean) => void;
/** Element that opens the dialog */
@@ -97,6 +114,8 @@ export interface ConfirmationControlledProps {
function ControlledConfirmation({
open,
+ color = 'negative',
+ finalFocus,
onOpenChange,
trigger,
title,
@@ -115,6 +134,8 @@ function ControlledConfirmation({
>
{trigger ? : null}
(value: FromPayload, payload: Pa
export interface ConfirmationHandleProps {
/** Opens the dialog with a payload from anywhere: `handle.open(payload)` */
handle: ConfirmationHandle;
+ /** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
+ color?: ConfirmationColor;
+ /**
+ * Where focus returns when the dialog closes. Default: the trigger — which a confirmed removal
+ * may have taken off the page, so a list hands back the row that replaced it instead.
+ */
+ finalFocus?: DialogFocusTarget;
/** Dialog heading, or a function of the payload */
title: FromPayload;
/** What the action does and why it warrants a second look, or a function of the payload. Takes markup, for a name to emphasise */
@@ -166,6 +194,8 @@ export interface ConfirmationHandleProps {
function HandleConfirmation({
handle,
+ color = 'negative',
+ finalFocus,
title,
description,
actionLabel,
@@ -184,6 +214,8 @@ function HandleConfirmation({
{({ payload }) =>
payload === undefined ? null : (
({
export type ConfirmationProps = ConfirmationControlledProps | ConfirmationHandleProps;
/**
- * Confirmation dialog for a destructive action that is worth a second look but not worth
- * making the user type for. Use `Destructive` for the actions that are.
+ * Confirmation dialog for an action worth a second look but not worth making the user type for.
+ * Use `Destructive` for the destructive actions that are. `color` sets the weight the confirming
+ * button carries: `negative` for what cannot be undone, `primary` for what can.
*
* An `alertdialog`: it announces as an interruption, an outside press cannot answer it, and the
* card withholds its corner dismiss. Escape still closes it, the way the cancel action does. Under
diff --git a/packages/mosaic/src/blocks/confirmation/index.ts b/packages/mosaic/src/blocks/confirmation/index.ts
index 75e24b29ed1..b161658f063 100644
--- a/packages/mosaic/src/blocks/confirmation/index.ts
+++ b/packages/mosaic/src/blocks/confirmation/index.ts
@@ -1,5 +1,6 @@
export { Confirmation } from './confirmation';
export type {
+ ConfirmationColor,
ConfirmationControlledProps,
ConfirmationHandle,
ConfirmationHandleProps,
diff --git a/packages/mosaic/src/components/card/card.styles.ts b/packages/mosaic/src/components/card/card.styles.ts
index 11a696bd8c3..fadcdc64cb5 100644
--- a/packages/mosaic/src/components/card/card.styles.ts
+++ b/packages/mosaic/src/components/card/card.styles.ts
@@ -49,7 +49,7 @@ export const header = stylex.create({
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
- rowGap: space['1'],
+ rowGap: space['0.5'],
},
title: {
color: colorVars['--cl-color-foreground'],
diff --git a/packages/mosaic/src/components/data-list/data-list.styles.ts b/packages/mosaic/src/components/data-list/data-list.styles.ts
new file mode 100644
index 00000000000..87cb102bcf5
--- /dev/null
+++ b/packages/mosaic/src/components/data-list/data-list.styles.ts
@@ -0,0 +1,56 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
+
+export const list = stylex.create({
+ base: {
+ borderColor: colorVars['--cl-color-border'],
+ borderRadius: radiusVars['--cl-radius-lg'],
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ paddingInline: space['3'],
+ backgroundColor: colorVars['--cl-color-background-subtle'],
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ },
+});
+
+export const item = stylex.create({
+ base: {
+ paddingBlock: space['3'],
+ alignItems: 'baseline',
+ columnGap: space['6'],
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ divided: {
+ borderBlockEndColor: colorVars['--cl-color-border'],
+ borderBlockEndStyle: 'solid',
+ borderBlockEndWidth: {
+ default: '1px',
+ ':last-child': 0,
+ },
+ },
+});
+
+export const label = stylex.create({
+ base: {
+ color: colorVars['--cl-color-foreground'],
+ flexShrink: 0,
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ },
+});
+
+export const value = stylex.create({
+ base: {
+ color: colorVars['--cl-color-foreground-secondary'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-normal'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ textAlign: 'end',
+ minWidth: 0,
+ },
+});
diff --git a/packages/mosaic/src/components/data-list/data-list.test.tsx b/packages/mosaic/src/components/data-list/data-list.test.tsx
new file mode 100644
index 00000000000..39d51ba980c
--- /dev/null
+++ b/packages/mosaic/src/components/data-list/data-list.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { MosaicProvider } from '../../MosaicProvider';
+import { DataList } from './data-list';
+
+function renderList(props?: { divided?: boolean }) {
+ return render(
+
+
+
+ IP address
+ 2600:100e:b10b:787b
+
+
+ ,
+ );
+}
+
+describe('DataList', () => {
+ it('pairs each value with its label', () => {
+ const { container } = renderList();
+
+ const label = screen.getByText('IP address');
+ const value = screen.getByText('2600:100e:b10b:787b');
+ expect(label.tagName).toBe('DT');
+ expect(value.tagName).toBe('DD');
+ expect(container.querySelector('dl')).toContainElement(label);
+ });
+
+ it('carries the slot classes a theme targets', () => {
+ const { container } = renderList();
+
+ expect(container.querySelector('.cl-data-list')).toBeInTheDocument();
+ expect(container.querySelector('.cl-data-list-item')).toBeInTheDocument();
+ expect(screen.getByText('IP address')).toHaveClass('cl-data-list-label');
+ expect(screen.getByText('2600:100e:b10b:787b')).toHaveClass('cl-data-list-value');
+ });
+
+ it('reflects whether the items are ruled', () => {
+ const { container, unmount } = renderList();
+ expect(container.querySelector('.cl-data-list')).toHaveAttribute('data-divided');
+ unmount();
+
+ const plain = renderList({ divided: false });
+ expect(plain.container.querySelector('.cl-data-list')).not.toHaveAttribute('data-divided');
+ });
+});
diff --git a/packages/mosaic/src/components/data-list/data-list.tsx b/packages/mosaic/src/components/data-list/data-list.tsx
new file mode 100644
index 00000000000..5763a07326f
--- /dev/null
+++ b/packages/mosaic/src/components/data-list/data-list.tsx
@@ -0,0 +1,109 @@
+import { useRender } from '@clerk/headless/utils';
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import type { MosaicComponentProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { reset } from '../../utils/reset.styles';
+import { truncationStyles } from '../../utils/typography.styles';
+import * as slots from './data-list.styles';
+
+export type DataListProps = MosaicComponentProps<'dl'> & {
+ /**
+ * Rules between the items, for a list read row by row. Turn it off where the pairs are few
+ * enough to scan as a block.
+ *
+ * @default true
+ */
+ divided?: boolean;
+};
+
+const DataListContext = React.createContext(true);
+
+/**
+ * Root list. Renders a `
` and provides its `divided` to the items within it.
+ *
+ * @example
+ *
+ *
+ * IP address
+ * 2600:100e:b10b:787b
+ *
+ *
+ */
+const Root = React.forwardRef(function MosaicDataList(
+ { divided = true, render, xstyle, ...rest },
+ ref,
+) {
+ const element = useRender({
+ defaultTagName: 'dl',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('data-list', { divided }), stylex.props(reset.base, slots.list.base, xstyle), rest),
+ },
+ });
+
+ return {element};
+});
+
+const Item = React.forwardRef>(function MosaicDataListItem(
+ { render, xstyle, ...rest },
+ ref,
+) {
+ const divided = React.useContext(DataListContext);
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(
+ themeProps('data-list-item'),
+ stylex.props(reset.base, slots.item.base, divided && slots.item.divided, xstyle),
+ rest,
+ ),
+ },
+ });
+});
+
+const Label = React.forwardRef>(function MosaicDataListLabel(
+ { render, xstyle, ...rest },
+ ref,
+) {
+ return useRender({
+ defaultTagName: 'dt',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('data-list-label'), stylex.props(reset.base, slots.label.base, xstyle), rest),
+ },
+ });
+});
+
+const Value = React.forwardRef>(function MosaicDataListValue(
+ { render, xstyle, ...rest },
+ ref,
+) {
+ return useRender({
+ defaultTagName: 'dd',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(
+ themeProps('data-list-value'),
+ stylex.props(reset.base, slots.value.base, truncationStyles.singleLine, xstyle),
+ rest,
+ ),
+ },
+ });
+});
+
+/**
+ * Mosaic `DataList` — label/value pairs for read-only detail, such as the fields describing a
+ * device or a payment method. Composed via dot syntax: `DataList.Root`, `DataList.Item`,
+ * `DataList.Label`, `DataList.Value`. Every part takes a `render` prop and forwards a ref.
+ *
+ * Semantics come free: the parts render `dl`/`dt`/`dd`, so assistive technology reads each value
+ * with the label it belongs to.
+ */
+export const DataList = { Root, Item, Label, Value };
diff --git a/packages/mosaic/src/components/data-list/index.ts b/packages/mosaic/src/components/data-list/index.ts
new file mode 100644
index 00000000000..5ba0c71d82c
--- /dev/null
+++ b/packages/mosaic/src/components/data-list/index.ts
@@ -0,0 +1,2 @@
+export { DataList } from './data-list';
+export type { DataListProps } from './data-list';
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
new file mode 100644
index 00000000000..2bd61025e42
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-device-actions.test.tsx
@@ -0,0 +1,315 @@
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { act, 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 { MosaicProvider } from '../../../MosaicProvider';
+import type { UserProfileDevice } from '../user-profile-active-devices.types';
+import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section.view';
+
+const current: UserProfileDevice = {
+ id: 'current',
+ name: 'Safari on macOS',
+ description: 'Salt Lake City, UT, United States',
+ type: 'desktop',
+ isCurrent: true,
+};
+
+const mobile: UserProfileDevice = {
+ id: 'mobile',
+ name: 'Safari on iOS',
+ description: 'Last seen 2 weeks ago · Orem, UT, United States',
+ type: 'mobile',
+ lastActive: '4 days ago',
+ model: 'iPhone 16 Pro',
+ browser: 'Safari 18.4',
+ ipAddress: '2600:100e:b10b:787b:e8ae:6e75',
+ location: 'Orem, UT, United States',
+ signedInAt: 'July 5th, 2026',
+};
+
+function renderDevices(onSignOutDevice?: (id: string) => void | Promise) {
+ return render(
+
+
+ ,
+ );
+}
+
+async function openMenu(user: ReturnType, device: UserProfileDevice) {
+ await user.click(screen.getByRole('button', { name: `Manage ${device.name}` }));
+}
+
+describe('active device details', () => {
+ it('opens the details of the device the menu belongs to', async () => {
+ const user = userEvent.setup();
+ renderDevices();
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+
+ const dialog = screen.getByRole('dialog');
+ expect(within(dialog).getByRole('heading', { name: 'Safari on iOS' })).toBeInTheDocument();
+ expect(within(dialog).getByText('Last active 4 days ago')).toBeInTheDocument();
+ expect(within(dialog).getByText('iPhone 16 Pro')).toBeInTheDocument();
+ expect(within(dialog).getByText('2600:100e:b10b:787b:e8ae:6e75')).toBeInTheDocument();
+ expect(within(dialog).getByText('July 5th, 2026')).toBeInTheDocument();
+ });
+
+ it('omits the rows a device has no detail for', async () => {
+ const user = userEvent.setup();
+ renderDevices();
+ await openMenu(user, current);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+
+ expect(within(screen.getByRole('dialog')).queryByText('Browser')).not.toBeInTheDocument();
+ });
+
+ it('closes the current device from the footer, with nothing to sign out', async () => {
+ const user = userEvent.setup();
+ renderDevices(vi.fn());
+ await openMenu(user, current);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+
+ const dialog = screen.getByRole('dialog');
+ expect(within(dialog).queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument();
+ // The header's corner dismiss is also named Close, so this picks the footer's by its text.
+ await user.click(within(dialog).getByText('Close'));
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ });
+});
+
+describe('active device sign out', () => {
+ it('confirms before signing a device out', async () => {
+ const user = userEvent.setup();
+ const onSignOutDevice = vi.fn();
+ renderDevices(onSignOutDevice);
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+
+ const confirmation = screen.getByRole('alertdialog');
+ expect(within(confirmation).getByText(/Safari on iOS will be signed out/)).toBeInTheDocument();
+ expect(onSignOutDevice).not.toHaveBeenCalled();
+
+ await user.click(within(confirmation).getByRole('button', { name: 'Sign out' }));
+ expect(onSignOutDevice).toHaveBeenCalledWith('mobile');
+ });
+
+ it('shows a failure in the confirmation and allows retrying', async () => {
+ const user = userEvent.setup();
+ const onSignOutDevice = vi.fn().mockRejectedValueOnce(new Error('Unable to sign out')).mockResolvedValue(undefined);
+ renderDevices(onSignOutDevice);
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unable to sign out');
+
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ expect(onSignOutDevice).toHaveBeenCalledTimes(2);
+ });
+
+ it('signs out from the details dialog without a second confirmation', async () => {
+ const user = userEvent.setup();
+ function Example() {
+ const [devices, setDevices] = useState([current, mobile]);
+ return (
+
+ setDevices(list => list.filter(device => device.id !== id))}
+ />
+
+ );
+ }
+ render();
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument();
+ });
+
+ it('holds the details dialog open while the sign out is in flight', async () => {
+ const user = userEvent.setup();
+ const signOut = createDeferredPromise();
+ renderDevices(() => signOut.promise);
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ const dialog = screen.getByRole('dialog');
+ await user.click(within(dialog).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Sign out' })).toHaveAttribute('aria-busy'));
+ expect(dialog).toBeInTheDocument();
+
+ await act(async () => {
+ signOut.resolve();
+ await signOut.promise;
+ });
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ });
+
+ it('keeps the details dialog open and explains a failed sign out', async () => {
+ const user = userEvent.setup();
+ const onSignOutDevice = vi.fn().mockRejectedValueOnce(new Error('Unable to sign out')).mockResolvedValue(undefined);
+ renderDevices(onSignOutDevice);
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unable to sign out');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ expect(onSignOutDevice).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('signing out of all other devices', () => {
+ it('holds the button pending, then explains a failure under the row', async () => {
+ const user = userEvent.setup();
+ const onSignOutAllOtherDevices = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('Unable to sign out of all devices'))
+ .mockResolvedValue(undefined);
+ render(
+
+
+ ,
+ );
+
+ const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' });
+ await user.click(signOutAll);
+
+ expect(await screen.findByText('Unable to sign out of all devices')).toBeInTheDocument();
+ expect(signOutAll).not.toHaveAttribute('aria-busy');
+
+ await user.click(signOutAll);
+ await waitFor(() => expect(screen.queryByText('Unable to sign out of all devices')).not.toBeInTheDocument());
+ expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(2);
+ });
+
+ it('ignores a second press while one is in flight', async () => {
+ const user = userEvent.setup();
+ const signOutAll = createDeferredPromise();
+ const onSignOutAllOtherDevices = vi.fn(() => signOutAll.promise);
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Sign out of all devices' });
+ await user.click(button);
+ await waitFor(() => expect(button).toHaveAttribute('aria-busy'));
+ await user.click(button);
+
+ expect(onSignOutAllOtherDevices).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ signOutAll.resolve();
+ await signOutAll.promise;
+ });
+ await waitFor(() => expect(button).not.toHaveAttribute('aria-busy'));
+ });
+});
+
+describe('focus after signing a device out', () => {
+ const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' };
+
+ function Example({ devices: initial }: { devices: UserProfileDevice[] }) {
+ const [devices, setDevices] = useState(initial);
+ return (
+
+ setDevices(list => list.filter(device => device.id !== id))}
+ />
+
+ );
+ }
+
+ it('hands focus to the row that took its place, from the confirmation', async () => {
+ const user = userEvent.setup();
+ render();
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus());
+ });
+
+ it('hands focus to the row that took its place, from the details dialog', async () => {
+ const user = userEvent.setup();
+ render();
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'View details' }));
+ await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus());
+ });
+
+ it('falls back to the last row, then to the current device', async () => {
+ const user = userEvent.setup();
+ render();
+ await openMenu(user, desktop);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus());
+
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus());
+ });
+
+ it('skips the signed-out row when the list only catches up later', async () => {
+ const user = userEvent.setup();
+ function LateExample() {
+ const [devices, setDevices] = useState([current, mobile, desktop]);
+ return (
+
+ {
+ setTimeout(() => setDevices(list => list.filter(device => device.id !== id)), 10);
+ return Promise.resolve();
+ }}
+ />
+
+ );
+ }
+ render();
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus());
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument());
+ expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus();
+ });
+
+ it('still returns focus to the row itself when the sign out is cancelled', async () => {
+ const user = userEvent.setup();
+ render();
+ await openMenu(user, mobile);
+ await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }));
+
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toHaveFocus());
+ });
+});
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 5889a203db0..1d3d5109cdd 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
@@ -1,4 +1,4 @@
-import { render, screen, within } from '@testing-library/react';
+import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -113,6 +113,8 @@ describe('UserProfileSecurityPanelView', () => {
const otherDevices = screen.getByRole('region', { name: 'Other devices' });
await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' }));
await user.click(screen.getByRole('menuitem', { name: 'Sign out' }));
+ await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Sign out' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
// The danger zone confirms in a modal, so it goes last: nothing else is clickable while it is open.
await user.click(screen.getByRole('button', { name: 'Delete account' }));
@@ -147,14 +149,13 @@ describe('UserProfileSecurityPanelView', () => {
expect(screen.queryByText('Password')).not.toBeInTheDocument();
});
- it('does not render actions for the current device', () => {
- renderView({
- onManageDevice: vi.fn(),
- onSignOutDevice: vi.fn(),
- });
+ it('withholds sign out from the current device', async () => {
+ const user = userEvent.setup();
+ renderView({ onSignOutDevice: vi.fn() });
- expect(screen.queryByRole('button', { name: 'Manage Safari on macOS' })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Manage Safari on macOS' }));
+ expect(screen.getByRole('menuitem', { name: 'View details' })).toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument();
});
it('only shows backup codes with another verification method and only allows regeneration', async () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx b/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx
index 968831f482b..19750de3c17 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-action-menu.tsx
@@ -1,3 +1,5 @@
+import type { Ref } from 'react';
+
import { Icon } from '../../components/icon';
import { Menu } from '../../components/menu';
import type { IconName } from '../../icons/registry';
@@ -9,14 +11,26 @@ export interface UserProfileMenuAction {
onClick: () => void;
}
-export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) {
+export function UserProfileActionMenu({
+ label,
+ actions,
+ triggerRef,
+}: {
+ label: string;
+ actions: UserProfileMenuAction[];
+ /** The trigger element, for a caller that has to hand focus back to this row. */
+ triggerRef?: Ref;
+}) {
if (actions.length === 0) {
return null;
}
return (
-
+
{actions.map(action => (
void;
- onSignOutDevice?: (id: string) => void;
- onSignOutAllOtherDevices?: () => void;
+ onSignOutDevice?: (id: string) => void | Promise;
+ onSignOutAllOtherDevices?: () => void | Promise;
}
export function UserProfileActiveDevicesSectionView({
devices,
- onManageDevice,
onSignOutDevice,
onSignOutAllOtherDevices,
}: UserProfileActiveDevicesSectionViewProps) {
+ const m = useMessages('userProfileActiveDevices');
+ const deviceDetails = useMemo(() => Dialog.createHandle(), []);
+ const signOutDevice = useMemo(() => Confirmation.createHandle(), []);
const currentDevices = devices.filter(device => device.isCurrent);
const otherDevices = devices.filter(device => !device.isCurrent);
+ const openSignOut = onSignOutDevice ? (device: UserProfileDevice) => signOutDevice.open(device) : undefined;
+
+ const triggers = useRef(new Map());
+ const signedOut = useRef<{ id: string; index: number } | undefined>(undefined);
+
+ const registerTrigger = (id: string) => (element: HTMLButtonElement | null) => {
+ if (element) {
+ triggers.current.set(id, element);
+ } else {
+ triggers.current.delete(id);
+ }
+ };
+
+ const signOutDeviceAt = onSignOutDevice
+ ? async (device: UserProfileDevice) => {
+ const index = otherDevices.findIndex(other => other.id === device.id);
+ await onSignOutDevice(device.id);
+ // Recorded only once the device is really gone, so a cancelled or failed attempt still
+ // returns focus to the row's own menu.
+ signedOut.current = { id: device.id, index };
+ }
+ : undefined;
+
+ // The row the dialog was opened from has just unmounted, so focus goes to whichever row took
+ // its place — the last one if it was the last, the current device once none are left.
+ const focusAfterSignOut = () => {
+ const removed = signedOut.current;
+ signedOut.current = undefined;
+ if (!removed) {
+ return null;
+ }
+ // Filtered by id as well as index: a caller whose list only catches up on a later refetch
+ // still has the signed-out row here, and handing focus back to it loses focus all over again
+ // when it goes.
+ const remaining = devices.filter(device => !device.isCurrent && device.id !== removed.id);
+ const next = remaining[Math.min(removed.index, remaining.length - 1)] ?? currentDevices[0];
+ return (next && triggers.current.get(next.id)) ?? null;
+ };
+
+ const [isSigningOutAll, setIsSigningOutAll] = useState(false);
+ const [signOutAllError, setSignOutAllError] = useState();
+ const signingOutAll = useRef(false);
+
+ const signOutAllOtherDevices = async () => {
+ if (!onSignOutAllOtherDevices || signingOutAll.current) {
+ return;
+ }
+ signingOutAll.current = true;
+ setIsSigningOutAll(true);
+ setSignOutAllError(undefined);
+ try {
+ await onSignOutAllOtherDevices();
+ } catch (error) {
+ setSignOutAllError(error instanceof Error ? error.message : m.signOutAllError);
+ } finally {
+ signingOutAll.current = false;
+ setIsSigningOutAll(false);
+ }
+ };
+
return (