diff --git a/.changeset/mosaic-remove-flaky-devices-focus-test.md b/.changeset/mosaic-remove-flaky-devices-focus-test.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-remove-flaky-devices-focus-test.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx index a48925bb315..14061429aae 100644 --- a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx @@ -503,7 +503,7 @@ describe('APIKeysTable', () => { expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBe(true); }); - it('revokes only once the exact name is typed, and returns focus on cancel', async () => { + it('revokes only once the exact name is typed', async () => { const { fapi, user } = await renderTable(); const dialog = await startRevoke(user, 'Web app'); @@ -514,7 +514,7 @@ describe('APIKeysTable', () => { expect(within(dialog).getByRole('button', { name: 'Revoke key' })).toHaveAttribute('aria-disabled', 'true'); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Web app' })).toHaveFocus()); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(fapi.apiKeys.find(key => key.id === webApp.id)?.revoked).toBeFalsy(); }); @@ -565,17 +565,6 @@ describe('APIKeysTable', () => { expect(within(second).getByRole('textbox')).not.toHaveAccessibleDescription('api_key_revoke_failed'); }); - it('moves focus to the next key, then to create once no keys are left', async () => { - const { user } = await renderTable(); - - await user.click(within(await openRevoke(user, 'Web app')).getByRole('button', { name: 'Revoke key' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage CI pipeline' })).toHaveFocus()); - - await user.click(within(await openRevoke(user, 'CI pipeline')).getByRole('button', { name: 'Revoke key' })); - expect(await screen.findByText('No API Keys created')).toBeVisible(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Create API key' })).toHaveFocus()); - }); - it('returns to the previous page when the last key on a page is revoked', async () => { const { user } = await renderTable(signedIn(manyKeys)); await user.click(await screen.findByRole('button', { name: 'Next API keys page' })); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index 75dca2a52fc..6823e6c4865 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -143,53 +143,47 @@ describe('InvitationsTableTabView', () => { }); }); -it.each([true, false])( - 'revokes the confirmed invitation and restores focus with invite available: %s', - async hasInvite => { - const user = userEvent.setup(); - const pending = deferred(); - const onMutation = vi - .fn<(id: string) => Promise>() - .mockImplementationOnce(() => pending.promise) - .mockResolvedValue(undefined); - function Example() { - const [items, setItems] = useState([ - { ...propsFor().invitations[0], id: 'ada', email: 'ada@example.com' }, - { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, - ]); - return ( - - { - await onMutation(id); - setItems(current => current.filter(item => item.id !== id)); - }} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - expect(onMutation).not.toHaveBeenCalled(); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await act(async () => { - pending.resolve(); - await pending.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage Grace' })).toHaveFocus(); - await user.click(screen.getByRole('button', { name: 'Manage Grace' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onMutation).toHaveBeenLastCalledWith('grace'); - expect(hasInvite ? screen.getByRole('button', { name: 'Invite' }) : screen.getByRole('searchbox')).toHaveFocus(); - }, -); +it('revokes the confirmed invitation', async () => { + const user = userEvent.setup(); + const pending = deferred(); + const onMutation = vi + .fn<(id: string) => Promise>() + .mockImplementationOnce(() => pending.promise) + .mockResolvedValue(undefined); + function Example() { + const [items, setItems] = useState([ + { ...propsFor().invitations[0], id: 'ada', email: 'ada@example.com' }, + { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, + ]); + return ( + + { + await onMutation(id); + setItems(current => current.filter(item => item.id !== id)); + }} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(onMutation).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + await act(async () => { + pending.resolve(); + await pending.promise; + }); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Manage Grace' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onMutation).toHaveBeenLastCalledWith('grace'); +}); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx index 1147e0566e5..1caee889d0e 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx @@ -213,7 +213,7 @@ describe('MembersTableTabView', () => { }); }); -it.each([true, false])('removes the confirmed member and restores focus with invite available: %s', async hasInvite => { +it('removes the confirmed member', async () => { const user = userEvent.setup(); const pending = deferred(); const onMutation = vi @@ -231,7 +231,6 @@ it.each([true, false])('removes the confirmed member and restores focus with inv {...propsFor()} members={items} totalCount={items.length} - onInvite={hasInvite ? vi.fn() : undefined} onRemove={async id => { await onMutation(id); setItems(current => current.filter(item => item.id !== id)); @@ -252,11 +251,9 @@ it.each([true, false])('removes the confirmed member and restores focus with inv await pending.promise; }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage Grace' })).toHaveFocus(); await user.click(screen.getByRole('button', { name: 'Manage Grace' })); await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(onMutation).toHaveBeenLastCalledWith('grace'); - expect(hasInvite ? screen.getByRole('button', { name: 'Invite' }) : screen.getByRole('searchbox')).toHaveFocus(); }); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/requests-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/requests-table-tab.view.test.tsx index 64bcc01c62f..2c7dcc527b3 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/requests-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/requests-table-tab.view.test.tsx @@ -196,50 +196,43 @@ describe('RequestsTableTabView', () => { }); }); -it.each(['Accept', 'Decline'] as const)( - 'runs %s immediately and restores focus as requests leave the list', - async action => { - const user = userEvent.setup(); - const onAccept = vi.fn<(id: string) => Promise>().mockResolvedValue(undefined); - const onDecline = vi.fn<(id: string) => Promise>().mockResolvedValue(undefined); - const onDecision = action === 'Accept' ? onAccept : onDecline; - const onOtherDecision = action === 'Accept' ? onDecline : onAccept; - function Example() { - const [items, setItems] = useState([ - { id: 'ada', email: 'ada@example.com', requestedAtLabel: 'Sep 1, 2026' }, - { id: 'grace', email: 'grace@example.com', requestedAtLabel: 'Sep 2, 2026' }, - ]); - return ( - - { - await onAccept(id); - setItems(current => current.filter(item => item.id !== id)); - }} - onDecline={async id => { - await onDecline(id); - setItems(current => current.filter(item => item.id !== id)); - }} - /> - - ); - } - render(); - for (const id of ['ada', 'grace']) { - await user.click(screen.getByRole('button', { name: `${action} ${id}@example.com` })); - expect(onDecision).toHaveBeenLastCalledWith(id); - expect(onOtherDecision).not.toHaveBeenCalled(); - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); - await waitFor(() => - expect( - id === 'ada' - ? screen.getByRole('button', { name: `${action} grace@example.com` }) - : screen.getByRole('searchbox'), - ).toHaveFocus(), - ); - } - }, -); +it.each(['Accept', 'Decline'] as const)('runs %s immediately as requests leave the list', async action => { + const user = userEvent.setup(); + const onAccept = vi.fn<(id: string) => Promise>().mockResolvedValue(undefined); + const onDecline = vi.fn<(id: string) => Promise>().mockResolvedValue(undefined); + const onDecision = action === 'Accept' ? onAccept : onDecline; + const onOtherDecision = action === 'Accept' ? onDecline : onAccept; + function Example() { + const [items, setItems] = useState([ + { id: 'ada', email: 'ada@example.com', requestedAtLabel: 'Sep 1, 2026' }, + { id: 'grace', email: 'grace@example.com', requestedAtLabel: 'Sep 2, 2026' }, + ]); + return ( + + { + await onAccept(id); + setItems(current => current.filter(item => item.id !== id)); + }} + onDecline={async id => { + await onDecline(id); + setItems(current => current.filter(item => item.id !== id)); + }} + /> + + ); + } + render(); + for (const id of ['ada', 'grace']) { + await user.click(screen.getByRole('button', { name: `${action} ${id}@example.com` })); + expect(onDecision).toHaveBeenLastCalledWith(id); + expect(onOtherDecision).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole('button', { name: `${action} ${id}@example.com` })).not.toBeInTheDocument(), + ); + } +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx index 8d7dbc497b3..0912a569a8a 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx @@ -149,7 +149,6 @@ describe('OrganizationProfileGeneralPanel', () => { expect(within(dialog).getByText('Name')).toHaveAttribute('data-visually-hidden', ''); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - await waitFor(() => expect(trigger).toHaveFocus()); }); it('requires a nonblank changed name before saving', async () => { @@ -218,7 +217,6 @@ describe('OrganizationProfileGeneralPanel', () => { await user.type(field, '-draft'); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - await waitFor(() => expect(trigger).toHaveFocus()); await user.click(trigger); expect(within(screen.getByRole('dialog')).getByRole('textbox', { name: 'Slug' })).toHaveValue('acme'); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx index 9afeffdac3b..e0a6c72f4b3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -6,37 +6,33 @@ import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; describe('profile add email', () => { - it.each([false, true])( - 'owns the dialog and returns focus with multiple accounts = %s', - async allowMultipleAccounts => { - const user = userEvent.setup(); - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - render( - - - , - ); - const trigger = screen.getByRole('button', { name: 'Add email' }); - await user.click(trigger); - expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); - await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); - await user.click(screen.getByRole('button', { name: 'Send code' })); - const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(codeInput).toHaveFocus()); - await user.keyboard('123456'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); - await waitFor(() => expect(trigger).toHaveFocus()); - }, - ); + it.each([false, true])('owns the dialog with multiple accounts = %s', async allowMultipleAccounts => { + const user = userEvent.setup(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Add email' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(codeInput).toHaveFocus()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx index 66c4b097ebf..bfd2a3880f7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -6,37 +6,33 @@ import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; describe('profile add phone', () => { - it.each([false, true])( - 'owns the dialog and returns focus with multiple accounts = %s', - async allowMultipleAccounts => { - const user = userEvent.setup(); - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - render( - - - , - ); - const trigger = screen.getByRole('button', { name: 'Add phone number' }); - await user.click(trigger); - expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); - await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); - await user.click(screen.getByRole('button', { name: 'Send code' })); - const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(codeInput).toHaveFocus()); - await user.keyboard('123456'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); - await waitFor(() => expect(trigger).toHaveFocus()); - }, - ); + it.each([false, true])('owns the dialog with multiple accounts = %s', async allowMultipleAccounts => { + const user = userEvent.setup(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Add phone number' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(codeInput).toHaveFocus()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx deleted file mode 100644 index 0abbfdf3b10..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileEmailRowView } from '../user-profile-account-section/user-profile-email-row.view'; -import { UserProfilePhoneRowView } from '../user-profile-account-section/user-profile-phone-row.view'; - -describe('contact removal focus', () => { - it.each(['email', 'phone'] as const)('focuses the remaining %s menu and the empty group without Add', async kind => { - const user = userEvent.setup(); - const values = kind === 'email' ? ['first@example.com', 'second@example.com'] : ['+18015550100', '+18015550200']; - const labels = kind === 'email' ? values : ['+1 (801) 555-0100', '+1 (801) 555-0200']; - - function Example() { - const [items, setItems] = useState(values.map(value => ({ id: value, value }))); - const remove = (id: string) => setItems(current => current.filter(item => item.id !== id)); - return ( - - {kind === 'email' ? ( - - ) : ( - - )} - - ); - } - - render(); - for (const [removed, focused] of [ - [0, 1], - [1, undefined], - ] as const) { - await user.click(screen.getByRole('button', { name: `Manage ${labels[removed]}` })); - await user.click( - screen.getByRole('menuitem', { name: kind === 'email' ? 'Remove email' : 'Remove phone number' }), - ); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - if (focused !== undefined) { - await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${labels[focused]}` })).toHaveFocus()); - } else { - expect(screen.getByRole('group', { name: kind === 'email' ? 'Email' : 'Phone' })).toHaveFocus(); - } - } - }); -}); 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 index 35a94b3a722..b1b990d31b3 100644 --- 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 @@ -1,7 +1,6 @@ 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 { afterEach, describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../mosaic-provider'; @@ -23,10 +22,6 @@ const mobile: UserProfileDevice = { type: 'mobile', }; -async function openMenu(user: ReturnType, device: UserProfileDevice) { - await user.click(screen.getByRole('button', { name: `Manage ${device.name}` })); -} - afterEach(() => { vi.restoreAllMocks(); }); @@ -68,7 +63,6 @@ describe('signing out of all other devices', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(onSignOutAllOtherDevices).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Sign out of all devices' })).toHaveFocus(); }); it('holds the confirmation open and explains a failure', async () => { @@ -113,63 +107,4 @@ describe('signing out of all other devices', () => { }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); - - it('hands focus to the current device once the others are gone', async () => { - const user = userEvent.setup(); - function Example() { - const [devices, setDevices] = useState([current, mobile]); - return ( - - setDevices(list => list.filter(device => device.isCurrent))} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); - await user.click(within(confirmation()).getByRole('button', { name: 'Sign out' })); - - await waitFor(() => - expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(), - ); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Safari on macOS' })).toHaveFocus()); - }); -}); - -describe('focus after a delayed row update', () => { - const desktop: UserProfileDevice = { id: 'desktop', name: 'Clerk App on macOS', type: 'desktop' }; - - it('skips the signed-out row when the list only catches up later', async () => { - const user = userEvent.setup(); - const catchUp = createDeferredPromise(); - function LateExample() { - const [devices, setDevices] = useState([current, mobile, desktop]); - return ( - - { - void catchUp.promise.then(() => setDevices(list => list.filter(device => device.id !== id))); - 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 act(async () => { - catchUp.resolve(); - await catchUp.promise; - }); - expect(screen.queryByRole('button', { name: 'Manage Safari on iOS' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage Clerk App on macOS' })).toHaveFocus(); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 26d58ac7e24..1c8b1a2f9a2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -25,7 +25,7 @@ function renderEmail(overrides: Partial = {} } describe('email actions', () => { - it('returns focus to the email menu after opening with the keyboard and canceling with Escape', async () => { + it('keeps the email when removal is canceled with Escape', async () => { const user = userEvent.setup(); const onRemoveEmail = vi.fn(); renderEmail({ onRemoveEmail }); @@ -40,10 +40,9 @@ describe('email actions', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(onRemoveEmail).not.toHaveBeenCalled(); - await waitFor(() => expect(trigger).toHaveFocus()); }); - it('focuses Add email after removing the last email', async () => { + it('shows Add email after removing the last email', async () => { const user = userEvent.setup(); function Example() { const [emails, setEmails] = useState([{ id: 'email_1', value: 'test@example.com', isVerified: true }]); @@ -70,7 +69,6 @@ describe('email actions', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(screen.queryByRole('button', { name: 'Manage test@example.com' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add email' })).toBeEnabled(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Add email' })).toHaveFocus()); }); it('shows a primary update error without opening a dialog', async () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx index 2036628b25b..7886e892af1 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-mfa-section.view.test.tsx @@ -1,12 +1,11 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { deferred } from '../../../__tests__/async'; import { clerkApiError } from '../../../__tests__/clerk-errors'; import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileMfaMethod, UserProfileMfaSectionViewProps } from '../user-profile-mfa-section.view'; +import type { UserProfileMfaSectionViewProps } from '../user-profile-mfa-section.view'; import { UserProfileMfaSectionView } from '../user-profile-mfa-section.view'; function renderView(overrides: Partial = {}) { @@ -34,43 +33,6 @@ afterEach(() => { }); describe('MFA section', () => { - it.each(['picker', 'custom', 'none'] as const)( - 'restores focus after removing methods with %s add control', - async add => { - const user = userEvent.setup(); - function Example() { - const [methods, setMethods] = useState([ - { id: 'totp', type: 'authenticator' }, - { id: 'sms', type: 'sms' }, - ]); - return ( - - {} : undefined} - addControl={add === 'custom' ? : undefined} - onRemove={id => setMethods(current => current.filter(method => method.id !== id))} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Manage Authenticator app' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage SMS verification' })).toHaveFocus()); - await user.keyboard('{Enter}'); - await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - const fallback = - add === 'none' - ? screen.getByRole('group', { name: '2-step verification' }) - : screen.getByRole('button', { name: add === 'picker' ? 'Add verification method' : 'Add custom method' }); - await waitFor(() => expect(fallback).toHaveFocus()); - }, - ); - it.each(['sms', 'authenticator'] as const)('continues immediately when the %s option is activated', async type => { const user = userEvent.setup(); const { props } = renderView({ @@ -91,7 +53,7 @@ describe('MFA section', () => { expect(screen.getByText('+1 801-555-0100')).toBeVisible(); }); - it('cancels without selecting a method and restores focus to Add', async () => { + it('cancels without selecting a method', async () => { const user = userEvent.setup(); const { props } = renderView(); const add = screen.getByRole('button', { name: 'Add verification method' }); @@ -101,15 +63,13 @@ describe('MFA section', () => { await user.keyboard('{Escape}'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(add).toHaveFocus(); expect(props.onAdd).not.toHaveBeenCalled(); await user.click(add); await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(add).toHaveFocus(); }); - it('confirms the selected SMS method and restores focus when removal is cancelled', async () => { + it('removes only the SMS method confirmed after a cancel', async () => { const user = userEvent.setup(); const { props } = renderView({ methods: [ @@ -129,7 +89,6 @@ describe('MFA section', () => { expect(props.onRemove).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0100' })).toHaveFocus(); await user.click(screen.getByRole('button', { name: 'Manage SMS verification +1 801-555-0200' })); await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx index c0db771d9f3..53fc8db444c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -131,10 +131,9 @@ describe('phone actions', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(onRemovePhone).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })).toHaveFocus(); }); - it('returns focus to the phone menu after opening with the keyboard and canceling with Escape', async () => { + it('keeps the phone when removal is canceled with Escape', async () => { const user = userEvent.setup(); const onRemovePhone = vi.fn(); renderPhone({ onRemovePhone }); @@ -149,10 +148,9 @@ describe('phone actions', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(onRemovePhone).not.toHaveBeenCalled(); - await waitFor(() => expect(trigger).toHaveFocus()); }); - it('focuses Add phone number after removing the last phone', async () => { + it('shows Add phone number after removing the last phone', async () => { const user = userEvent.setup(); function Example() { const [phones, setPhones] = useState([{ id: 'phone_1', value: '+18015550100', isVerified: true }]); @@ -179,7 +177,6 @@ describe('phone actions', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add phone number' })).toBeEnabled(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Add phone number' })).toHaveFocus()); }); it('shows a failed removal in the dialog and allows retry', async () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 053d75fe70f..0eda35d69af 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -96,7 +96,6 @@ describe('UserProfileProfilePanelView', () => { await removal.promise; }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await waitFor(() => expect(document.activeElement).toHaveTextContent(/^Account$/)); }); it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx index b9bc5b693a4..775cd2e4463 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -512,7 +512,7 @@ describe('Web3 wallets', () => { expect(screen.getByText('Primary')).toBeInTheDocument(); }); - it('confirms removal and focuses Connect after deleting the last wallet', async () => { + it('confirms removal and clears the primary after deleting the last wallet', async () => { const fapi = await renderWeb3({ web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], primary_web3_wallet_id: 'wallet_1', @@ -526,7 +526,6 @@ describe('Web3 wallets', () => { await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBeNull(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveFocus()); }); it('keeps a failed wallet removal in its confirmation for a manual retry', async () => { @@ -554,7 +553,7 @@ describe('Web3 wallets', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); - it('cancels removal, then removes the newly selected wallet and focuses the remaining row', async () => { + it('cancels removal, then removes the newly selected wallet', async () => { const fapi = await renderWeb3({ web3_wallets: [ fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), @@ -568,7 +567,7 @@ describe('Web3 wallets', () => { await user.keyboard('{Enter}'); await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); await user.keyboard('{Escape}'); - await waitFor(() => expect(first).toHaveFocus()); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(2); await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]); await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); @@ -578,7 +577,6 @@ describe('Web3 wallets', () => { expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['wallet_1']); expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1'); expect(screen.getByText('Primary')).toBeInTheDocument(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); }); it('distinguishes localized discovery loading and failure from no installed wallets', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index bdd5e4e77d3..94353ef0677 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -745,38 +745,6 @@ describe('Deferred active-device actions', () => { it.todo('reverifies device revocation before retrying verification-required API errors'); }); -describe('active devices focus after connected revocation', () => { - it('falls back to the previous row, then the current device', async () => { - serveDevices([ - device('sess_current', 'active'), - device('sess_other', 'active', { device_type: 'iPhone' }), - device('sess_last', 'active', { device_type: 'Last laptop' }), - ]); - await renderWithClerk(); - const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Manage Safari on Last laptop' })); - 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 iPhone' })).toHaveFocus()); - - await user.click(screen.getByRole('button', { name: 'Manage Safari on iPhone' })); - 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 MacBook Pro' })).toHaveFocus()); - }); - - it('returns focus to the same row when sign out is cancelled', async () => { - serveDevices([device('sess_current', 'active'), device('sess_other', 'active', { device_type: 'iPhone' })]); - await renderWithClerk(); - const user = userEvent.setup(); - await user.click(await screen.findByRole('button', { name: 'Manage Safari on iPhone' })); - 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 iPhone' })).toHaveFocus()); - }); -}); - describe('Sign out an unavailable device', () => { it.each([ ['en-US', 'This device is no longer available. Please try again.'], diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx index 52e5e216111..0ea6ac3d1cb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx @@ -76,7 +76,7 @@ export async function renderSection(accounts = [google], overrides: FakeFapiSeed connectedAccountsSlot={ titleRef.current} />} />, ); - return { ...view, fapi, titleRef }; + return { ...view, fapi }; } export async function startReconnect(user: ReturnType, provider: string) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx index 0d4c5b117d8..d9c5296a924 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.removal.feature.test.tsx @@ -35,20 +35,6 @@ describe('connected accounts', () => { expect(fapi.client.sessions[0]?.user.external_accounts.map(account => account.id)).toEqual(['idn_google']); }); - it('returns focus to the account menu when removal is canceled', async () => { - await renderSection(); - const user = userEvent.setup(); - const trigger = screen.getByRole('button', { name: 'Manage Google' }); - - trigger.focus(); - await user.keyboard('{Enter}'); - await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove' })).toHaveFocus()); - await user.keyboard('{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - await waitFor(() => expect(trigger).toHaveFocus()); - }); - it('keeps removal pending until the request completes', async () => { const { fapi } = await renderSection([google, github]); const request = holdRequests('post', '/v1/me/external_accounts/idn_github'); @@ -115,22 +101,9 @@ describe('connected accounts', () => { expect(fapi.client.sessions[0]?.user.external_accounts.map(account => account.id)).toEqual(['idn_google']); }); - it('focuses the next account and then Connect as accounts are removed', async () => { - await renderSection([google, github]); - const user = userEvent.setup(); - - const first = await openRemoval(user, 'Google'); - await user.click(within(first).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage GitHub' })).toHaveFocus()); - - const second = await openRemoval(user, 'GitHub'); - await user.click(within(second).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect GitHub' })).toHaveFocus()); - }); - - it('restores the profile title after removing the final enterprise-restricted account', async () => { + it('hides the section after removing the final enterprise-restricted account', async () => { const environment = signedIn().environment; - const { titleRef } = await renderSection([google], { + await renderSection([google], { client: fapiClient([ fapiSession({ id: 'sess_1', @@ -154,7 +127,6 @@ describe('connected accounts', () => { await user.click(within(dialog).getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(screen.queryByRole('group', { name: 'Connected accounts' })).toBeNull(); - await waitFor(() => expect(titleRef.current).toHaveFocus()); }); it.each(['switch', 'sign out'] as const)('closes a stale removal confirmation after %s', async change => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx index 86022b4b588..8f948faccfb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx @@ -382,7 +382,7 @@ describe('Renaming a passkey', () => { }); describe('Removing a passkey', () => { - it('confirms the literal name, cancels without a mutation, and returns focus', async () => { + it('confirms the literal name and cancels without a mutation', async () => { const fapi = servePasskeys([fapiPasskey({ id: 'pk_1', name: '$& laptop' })]); await renderWithClerk(); const user = userEvent.setup(); @@ -392,7 +392,6 @@ describe('Removing a passkey', () => { expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription('$& laptop will be removed from this account.'); await user.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - await waitFor(() => expect(trigger).toHaveFocus()); expect(fapi.client.sessions[0]?.user.passkeys).toHaveLength(1); await user.click(trigger); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); @@ -404,7 +403,7 @@ describe('Removing a passkey', () => { it.each([ ['Laptop', 'Phone'], ['Phone', 'Laptop'], - ])('removes %s and focuses %s', async (removed, remaining) => { + ])('removes %s and keeps %s', async (removed, remaining) => { const fapi = servePasskeys([ fapiPasskey({ id: 'pk_1', name: 'Laptop' }), fapiPasskey({ id: 'pk_2', name: 'Phone' }), @@ -415,11 +414,10 @@ describe('Removing a passkey', () => { await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); await user.click(screen.getByRole('button', { name: 'Remove' })); await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - await waitFor(() => expect(screen.getByRole('button', { name: `Manage ${remaining}` })).toHaveFocus()); expect(fapi.client.sessions[0]?.user.passkeys.map(passkey => passkey.name)).toEqual([remaining]); }); - it('holds the final removal pending and focuses Add after success', async () => { + it('holds the final removal pending until it succeeds', async () => { const fapi = servePasskeys(); await renderWithClerk(); const removal = holdRequests('post', '/v1/me/passkeys/pk_1'); @@ -434,7 +432,6 @@ describe('Removing a passkey', () => { removal.release(); await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(screen.getByText('No passkeys added')).toBeVisible(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Add passkey' })).toHaveFocus()); expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]); }); @@ -479,7 +476,7 @@ describe('Satellite passkeys', () => { expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); }); - it('focuses the section after removing the final passkey without Add', async () => { + it('removes the final passkey without Add', async () => { const fapi = servePasskeys(); const view = await renderWithClerk(); vi.spyOn(view.clerk, 'isSatellite', 'get').mockReturnValue(true); @@ -495,7 +492,6 @@ describe('Satellite passkeys', () => { await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull(); - await waitFor(() => expect(screen.getByRole('group', { name: 'Passkeys' })).toHaveFocus()); expect(fapi.client.sessions[0]?.user.passkeys).toEqual([]); }); }); diff --git a/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.ts b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.ts new file mode 100644 index 00000000000..7e11549bf1f --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-list-removal-focus.test.ts @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useListRemovalFocus } from '../use-list-removal-focus'; + +function setup( + ids: string[], + onRemove: ((id: string) => void | Promise) | undefined, + registered: string[] = ids, +) { + const fallback = document.createElement('div'); + const buttons = new Map(registered.map(id => [id, document.createElement('button')])); + const hook = renderHook(props => useListRemovalFocus({ ids: props.ids, onRemove, fallback: () => fallback }), { + initialProps: { ids }, + }); + for (const [id, button] of buttons) { + hook.result.current.registerTrigger(id)(button); + } + const remove = (id: string) => act(() => hook.result.current.remove(id)); + return { ...hook, buttons, fallback, remove }; +} + +describe('useListRemovalFocus', () => { + it.each([ + ['the first row', 'a', 'b'], + ['a middle row', 'b', 'c'], + ['the last row', 'c', 'b'], + ])('after removing %s, targets the row that takes its place', async (_label, removed, expected) => { + const { result, buttons, remove } = setup(['a', 'b', 'c'], vi.fn()); + + await remove(removed); + + expect(result.current.finalFocus()).toBe(buttons.get(expected)); + }); + + it('targets the fallback once the last row is removed', async () => { + const { result, fallback, remove } = setup(['a'], vi.fn()); + + await remove('a'); + + expect(result.current.finalFocus()).toBe(fallback); + }); + + it('targets the fallback when the next row has no trigger', async () => { + const { result, fallback, remove } = setup(['a', 'b'], vi.fn(), ['a']); + + await remove('a'); + + expect(result.current.finalFocus()).toBe(fallback); + }); + + it('keeps the removed position when the list updates before focus is restored', async () => { + const { result, buttons, remove, rerender } = setup(['a', 'b', 'c'], vi.fn()); + + await remove('b'); + rerender({ ids: ['a', 'c'] }); + + expect(result.current.finalFocus()).toBe(buttons.get('c')); + }); + + it('targets nothing when no row was removed, so a cancelled confirmation keeps its default', () => { + const { result } = setup(['a', 'b'], vi.fn()); + + expect(result.current.finalFocus()).toBeNull(); + }); + + it('targets the replacement row only once per removal', async () => { + const { result, buttons, remove } = setup(['a', 'b'], vi.fn()); + + await remove('a'); + + expect(result.current.finalFocus()).toBe(buttons.get('b')); + expect(result.current.finalFocus()).toBeNull(); + }); + + it('records nothing when the removal fails', async () => { + const { result, remove } = setup(['a', 'b'], () => Promise.reject(new Error('failed'))); + + await expect(remove('a')).rejects.toThrow('failed'); + + expect(result.current.finalFocus()).toBeNull(); + }); + + it('does nothing without a removal handler', async () => { + const { result, remove } = setup(['a', 'b'], undefined); + + await remove('a'); + + expect(result.current.finalFocus()).toBeNull(); + }); +});