diff --git a/frontend/e2e/registry.spec.ts b/frontend/e2e/registry.spec.ts index c935d29375..bbc759f190 100644 --- a/frontend/e2e/registry.spec.ts +++ b/frontend/e2e/registry.spec.ts @@ -167,6 +167,69 @@ async function installRegistryMocks(page: Page): Promise { }); } +async function submitDuplicateName(page: Page): Promise<() => void> { + let releaseCreate: (() => void) | undefined; + await page.route(/\/api\/converters$/, async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + await new Promise((resolve) => { + releaseCreate = resolve; + }); + await route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ + detail: "Converter instance 'caesar-custom' already exists", + }), + }); + }); + + await page.getByRole("button", { name: "Create First Converter" }).click(); + await page.getByRole("combobox", { name: "Converter type" }).click(); + await page.getByTestId("converter-type-option-CaesarConverter").click(); + await page.getByLabel("Registry name").fill("caesar-custom"); + await page.getByLabel("caesar_offset *").fill("5"); + await page.getByRole("button", { name: "Add Converter" }).click(); + await expect(page.getByRole("button", { name: "Adding..." })).toBeVisible(); + + return () => releaseCreate?.(); +} + +async function createCaesarConverter(page: Page, name: string): Promise { + await page.getByRole("combobox", { name: "Converter type" }).click(); + await page.getByTestId("converter-type-option-CaesarConverter").click(); + await page.getByLabel("Registry name").fill(name); + await page.getByLabel("caesar_offset *").fill("5"); + await page.getByRole("button", { name: "Add Converter" }).click(); +} + +async function submitSlowCreate(page: Page, name: string): Promise<() => void> { + let releaseCreate: (() => void) | undefined; + await page.route(/\/api\/converters$/, async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + await new Promise((resolve) => { + releaseCreate = resolve; + }); + // Hand the request to the registry mock so the refresh the response + // triggers has the new converter to show. + await route.fallback(); + }); + + await page.getByRole("button", { name: "New Converter" }).click(); + await createCaesarConverter(page, name); + await expect(page.getByRole("button", { name: "Adding..." })).toBeVisible(); + // Chromium runs the unfocusing steps for the disabled primary action a tick + // after it is disabled, so give it time to land before pressing Escape. + await page.waitForTimeout(250); + + return () => releaseCreate?.(); +} + test.describe("Converter Registry", () => { test.beforeEach(async ({ page }) => { await installRegistryMocks(page); @@ -192,6 +255,105 @@ test.describe("Converter Registry", () => { await expect(page.getByText("No Converters Registered")).toBeVisible(); }); + test("restores focus to the trigger when the add dialog is dismissed", async ({ page }) => { + const trigger = page.getByRole("button", { name: "New Converter" }); + await trigger.click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + }); + + test("moves focus to New Converter once the first converter is created", async ({ page }) => { + await page.getByRole("button", { name: "Create First Converter" }).click(); + await page.getByRole("combobox", { name: "Converter type" }).click(); + await page.getByTestId("converter-type-option-CaesarConverter").click(); + await page.getByLabel("Registry name").fill("caesar-focus"); + await page.getByLabel("caesar_offset *").fill("5"); + await page.getByRole("button", { name: "Add Converter" }).click(); + + // The empty-state trigger unmounts with the list it belonged to, so focus + // falls back to the always-mounted header action. + await expect(page.getByText("caesar-focus")).toBeVisible(); + await expect(page.getByRole("button", { name: "New Converter" })).toBeFocused(); + }); + + test("keeps the add dialog operable while the create request is in flight", async ({ page }) => { + const release = await submitDuplicateName(page); + + // Chromium runs the unfocusing steps for the disabled primary action a tick + // after it is disabled, so give it time to land before checking. + await page.waitForTimeout(250); + const dialog = page.getByRole("dialog"); + await expect(dialog.locator(":focus")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + release(); + }); + + test("moves focus to the error when the registry rejects the name", async ({ page }) => { + const release = await submitDuplicateName(page); + release(); + + const alert = page.getByRole("alert"); + await expect(alert).toContainText(/already exists/i); + await expect(alert).toBeFocused(); + + // Escape only reaches the dialog surface while focus is still inside it. + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + }); + + test("leaves the removal dialog in place when a dismissed creation succeeds", async ({ page }) => { + await page.getByRole("button", { name: "Create First Converter" }).click(); + await createCaesarConverter(page, "caesar-first"); + await expect(page.getByText("caesar-first")).toBeVisible(); + + const release = await submitSlowCreate(page, "caesar-late"); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + + const removeTrigger = page.getByRole("button", { name: "Remove caesar-first" }); + await removeTrigger.click(); + const removeDialog = page.getByRole("dialog"); + await expect(removeDialog).toBeVisible(); + + release(); + await expect(page.getByText("caesar-late")).toBeVisible(); + + // Moving focus out makes Tabster mark the still-visible dialog aria-hidden, + // and Escape then stops dismissing it, so the late response has to leave + // both the removal dialog's state and its focus alone. + await expect(removeDialog.locator(":focus")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(removeDialog).toBeHidden(); + // The row that opened the dialog was replaced by the refresh, so focus + // falls back to the always-mounted header action. + await expect(page.getByRole("button", { name: "New Converter" })).toBeFocused(); + }); + + test("keeps a stale submission failure out of a reopened add dialog", async ({ page }) => { + const release = await submitDuplicateName(page); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + + await page.getByRole("button", { name: "New Converter" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("combobox", { name: "Converter type" })).toBeVisible(); + + release(); + + // The request settles either way; only its failure is dropped, so the + // reopened dialog keeps the empty form the user opened. + await expect(dialog.getByRole("button", { name: "Add Converter" })).toBeEnabled(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(dialog.getByLabel("Registry name")).toHaveValue(""); + }); + test("uses the available viewport height for the converter type list", async ({ page }) => { await page.getByRole("button", { name: "New Converter" }).click(); await page.getByRole("combobox", { name: "Converter type" }).click(); diff --git a/frontend/src/components/Registry/ConverterRegistry.test.tsx b/frontend/src/components/Registry/ConverterRegistry.test.tsx index 8202b1a86d..5576a294eb 100644 --- a/frontend/src/components/Registry/ConverterRegistry.test.tsx +++ b/frontend/src/components/Registry/ConverterRegistry.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' @@ -13,10 +13,41 @@ jest.mock('@/services/api', () => ({ }, })) -jest.mock('./CreateConverterDialog', () => ({ - __esModule: true, - default: ({ open }: { open: boolean }) => open ?
Create converter
: null, -})) +// Holds the onCreated of the render that "started" a slow create, the way an +// in-flight request does, so a test can resolve it after that dialog is gone. +let mockPendingCreate: ((converterId: string) => void) | undefined + +// The real dialog loads converter type metadata of its own, but it stays a Fluent +// Dialog here so that focus restoration is exercised against real dialog behaviour. +jest.mock('./CreateConverterDialog', () => { + const fluent = jest.requireActual('@fluentui/react-components') + return { + __esModule: true, + default: ({ open, onClose, onCreated }: { + open: boolean + onClose: () => void + onCreated: (converterId: string) => void + }) => ( + { if (!data.open) onClose() }} + > + + + Create converter + + Cancel + onCreated('base64-default')}>Create + { mockPendingCreate = onCreated }}> + Create slowly + + + + + + ), + } +}) const mockedConvertersApi = convertersApi as jest.Mocked const converter = { @@ -41,13 +72,50 @@ function renderRegistry() { ) } +// The queued restore runs in a frame callback, so a test has to let one pass +// before it can claim focus was left alone. +async function settleFrame() { + await act(async () => { + await new Promise((resolve) => { requestAnimationFrame(() => resolve()) }) + }) +} + +// Run frame callbacks where they are registered, so a restore scheduled on a +// frame lands before React commits the update that requested it. Same lever as +// `should focus a submission error even when a frame runs before React renders +// it` in CreateConverterDialog.test.tsx. +function runFramesImmediately() { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(performance.now()) + return 0 + }) +} + +async function startSlowCreate(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'New Converter' })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Create slowly' })) + await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + // Let the restore queued by the dismissal land, so what follows can only be + // the work of the late response. + await settleFrame() +} + describe('ConverterRegistry', () => { beforeEach(() => { jest.clearAllMocks() + mockPendingCreate = undefined mockedConvertersApi.listConverters.mockResolvedValue({ items: [converter] }) mockedConvertersApi.deleteConverter.mockResolvedValue() }) + // `clearAllMocks` resets calls but leaves spies installed, and these tests + // replace `requestAnimationFrame`. + afterEach(() => { + jest.restoreAllMocks() + }) + it('lists registered converter instances and configuration', async () => { renderRegistry() @@ -104,4 +172,236 @@ describe('ConverterRegistry', () => { expect(mockedConvertersApi.deleteConverter).toHaveBeenCalledWith('base64-default') expect(await screen.findByText('No Converters Registered')).toBeInTheDocument() }) + + it('should restore focus to New Converter after the add dialog is dismissed', async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + const trigger = screen.getByRole('button', { name: 'New Converter' }) + + await user.click(trigger) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(trigger).toHaveFocus()) + }) + + it('should restore focus to New Converter when the add dialog is dismissed with Escape', async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + const trigger = screen.getByRole('button', { name: 'New Converter' }) + + await user.click(trigger) + await screen.findByRole('dialog') + await user.keyboard('{Escape}') + + await waitFor(() => expect(trigger).toHaveFocus()) + }) + + it('should restore focus to Create First Converter after the add dialog is dismissed', async () => { + mockedConvertersApi.listConverters.mockResolvedValue({ items: [] }) + const user = userEvent.setup() + renderRegistry() + const trigger = await screen.findByRole('button', { name: 'Create First Converter' }) + + await user.click(trigger) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(trigger).toHaveFocus()) + }) + + it("should restore focus to the converter's Remove button after cancelling removal", async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + const trigger = screen.getByRole('button', { name: 'Remove base64-default' }) + + await user.click(trigger) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(trigger).toHaveFocus()) + }) + + it("should restore focus to the converter's Remove button when removal is dismissed with Escape", async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + const trigger = screen.getByRole('button', { name: 'Remove base64-default' }) + + await user.click(trigger) + await screen.findByRole('dialog') + await user.keyboard('{Escape}') + + await waitFor(() => expect(trigger).toHaveFocus()) + }) + + it('should move focus to New Converter after a successful removal', async () => { + mockedConvertersApi.listConverters + .mockResolvedValueOnce({ items: [converter] }) + .mockResolvedValueOnce({ items: [] }) + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + const newConverter = screen.getByRole('button', { name: 'New Converter' }) + + await user.click(screen.getByRole('button', { name: 'Remove base64-default' })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Remove' })) + + expect(await screen.findByText('No Converters Registered')).toBeInTheDocument() + await waitFor(() => expect(newConverter).toHaveFocus()) + }) + + it('should move focus to New Converter after the first converter is created', async () => { + mockedConvertersApi.listConverters + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [converter] }) + const user = userEvent.setup() + renderRegistry() + const newConverter = screen.getByRole('button', { name: 'New Converter' }) + + await user.click(await screen.findByRole('button', { name: 'Create First Converter' })) + const dialog = await screen.findByRole('dialog') + await user.click(within(dialog).getByRole('button', { name: 'Create' })) + + expect(await screen.findByText('base64-default')).toBeInTheDocument() + await waitFor(() => expect(newConverter).toHaveFocus()) + }) + + it('should move focus to New Converter when a frame runs before the registry refresh commits', async () => { + // The restore used to run on a frame, which React does not wait for: the + // callback could land while the empty-state button was still connected, + // take focus, and lose it to when the refresh unmounted that button. + mockedConvertersApi.listConverters + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [converter] }) + const user = userEvent.setup() + renderRegistry() + // Captured before the dialog opens: Tabster hides the rest of the page from + // the accessibility tree while a modal is up, deferred, so re-querying here + // is flaky. + const newConverter = screen.getByRole('button', { name: 'New Converter' }) + const firstConverter = await screen.findByRole('button', { name: 'Create First Converter' }) + + await user.click(firstConverter) + const dialog = await screen.findByRole('dialog') + runFramesImmediately() + await user.click(within(dialog).getByRole('button', { name: 'Create' })) + + expect(await screen.findByText('base64-default')).toBeInTheDocument() + // The trigger really was removed, so this is the doomed-node case rather + // than a test that would pass for the wrong reason. + expect(firstConverter.isConnected).toBe(false) + expect(document.activeElement).not.toBe(document.body) + await waitFor(() => expect(newConverter).toHaveFocus()) + }) + + it('should move focus to New Converter when a frame runs before a removal refresh commits', async () => { + // Same shape on the removal path: the row that opened the dialog is about + // to be unmounted by the refresh. + mockedConvertersApi.listConverters + .mockResolvedValueOnce({ items: [converter] }) + .mockResolvedValueOnce({ items: [] }) + const user = userEvent.setup() + renderRegistry() + const newConverter = screen.getByRole('button', { name: 'New Converter' }) + await screen.findByText('base64-default') + const remove = screen.getByRole('button', { name: 'Remove base64-default' }) + + await user.click(remove) + const dialog = await screen.findByRole('dialog') + runFramesImmediately() + await user.click(within(dialog).getByRole('button', { name: 'Remove' })) + + expect(await screen.findByText('No Converters Registered')).toBeInTheDocument() + expect(remove.isConnected).toBe(false) + expect(document.activeElement).not.toBe(document.body) + await waitFor(() => expect(newConverter).toHaveFocus()) + }) + + it('should leave focus alone when a dialog opens while the removal refresh is in flight', async () => { + let releaseRefresh: (() => void) | undefined + mockedConvertersApi.listConverters + .mockResolvedValueOnce({ items: [converter] }) + .mockImplementationOnce(() => new Promise((resolve) => { + releaseRefresh = () => resolve({ items: [] }) + })) + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + + await user.click(screen.getByRole('button', { name: 'Remove base64-default' })) + const removeDialog = await screen.findByRole('dialog') + await user.click(within(removeDialog).getByRole('button', { name: 'Remove' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + // A restore is queued while the refresh runs; opening another dialog inside + // that window must not hand focus back to the control behind it. + await user.click(screen.getByRole('button', { name: 'New Converter' })) + const addDialog = await screen.findByRole('dialog') + await act(async () => { releaseRefresh?.() }) + await act(async () => { + await new Promise((resolve) => { requestAnimationFrame(() => resolve()) }) + }) + + expect(addDialog).toContainElement(document.activeElement as HTMLElement) + }) + + it('should leave the removal dialog in place when a dismissed creation succeeds', async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + await startSlowCreate(user) + + await user.click(screen.getByRole('button', { name: 'Remove base64-default' })) + const removeDialog = await screen.findByRole('dialog') + await act(async () => { mockPendingCreate?.('caesar-custom') }) + await settleFrame() + + // The response still refreshes the list, but the dialog the user is looking + // at keeps its state and its focus. + expect(mockedConvertersApi.listConverters).toHaveBeenCalledTimes(2) + expect(removeDialog).toBeInTheDocument() + expect(removeDialog).toContainElement(document.activeElement as HTMLElement) + }) + + it('should not move focus when a creation succeeds after its dialog was dismissed', async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + const newConverter = screen.getByRole('button', { name: 'New Converter' }) + await startSlowCreate(user) + + // Dismissal already restored focus to the trigger; move it away so a second + // restore would be visible. + await waitFor(() => expect(newConverter).toHaveFocus()) + await user.tab({ shift: true }) + expect(newConverter).not.toHaveFocus() + + await act(async () => { mockPendingCreate?.('caesar-custom') }) + await settleFrame() + + expect(mockedConvertersApi.listConverters).toHaveBeenCalledTimes(2) + expect(newConverter).not.toHaveFocus() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should keep a reopened add dialog open when the earlier creation succeeds', async () => { + const user = userEvent.setup() + renderRegistry() + await screen.findByText('base64-default') + await startSlowCreate(user) + + await user.click(screen.getByRole('button', { name: 'New Converter' })) + const reopened = await screen.findByRole('dialog') + await act(async () => { mockPendingCreate?.('caesar-custom') }) + await settleFrame() + + expect(mockedConvertersApi.listConverters).toHaveBeenCalledTimes(2) + expect(reopened).toBeInTheDocument() + expect(reopened).toContainElement(document.activeElement as HTMLElement) + }) }) diff --git a/frontend/src/components/Registry/ConverterRegistry.tsx b/frontend/src/components/Registry/ConverterRegistry.tsx index 855612b684..55f94e1459 100644 --- a/frontend/src/components/Registry/ConverterRegistry.tsx +++ b/frontend/src/components/Registry/ConverterRegistry.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MouseEvent } from 'react' import { Badge, @@ -17,6 +18,7 @@ import { TableHeaderCell, TableRow, Text, + useRestoreFocusTarget, } from '@fluentui/react-components' import { AddRegular, ArrowSyncRegular, DeleteRegular } from '@fluentui/react-icons' @@ -46,6 +48,22 @@ function formatParameters(identifier: ConverterIdentifier): string { return parameters.join('\n') || '—' } +// One token per dialog opening. A create response can land after the dialog it +// was submitted from is gone, and by then another dialog may be open, so the +// response is matched by token identity; a single "a dialog is open" boolean +// cannot tell the dialog in front of the user from the one it belongs to. +interface DialogToken { + dialog: 'create' | 'remove' +} + +// A request to hand the keyboard back, honoured after React commits the render +// that asked for it. `trigger` is the control the dialog was opened from. Each +// request is a fresh object so two dismissals in a row are two restores, even +// when they aim at the same control. +interface FocusRestore { + trigger: HTMLElement | null +} + interface DataTypeBadgesProps { dataTypes: string[] | null | undefined } @@ -67,9 +85,58 @@ export default function ConverterRegistry() { const [converters, setConverters] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [createOpen, setCreateOpen] = useState(false) + const [createToken, setCreateToken] = useState(null) const [converterToRemove, setConverterToRemove] = useState(null) const [removing, setRemoving] = useState(false) + const [focusRestore, setFocusRestore] = useState(null) + // Both dialogs open from state rather than from a DialogTrigger, so mark the + // opening controls as restore targets the way FeedbackDialog does; that is what + // hands focus back when a dialog is dismissed. Tabster cannot restore to a + // control that unmounted along with the dialog, so the paths that remove the + // trigger restore focus explicitly as well. + const restoreFocusTarget = useRestoreFocusTarget() + const newConverterRef = useRef(null) + const createTriggerRef = useRef(null) + const removeTriggerRef = useRef(null) + // The token of the dialog on screen, or null when none is. Kept in a ref + // because an in-flight create request outlives the render that started it, + // and because the restore below has to read it as of the commit it runs in + // rather than as of the render that requested it. + const openDialogRef = useRef(null) + + const openDialog = (dialog: DialogToken['dialog']): DialogToken => { + const token: DialogToken = { dialog } + openDialogRef.current = token + return token + } + + const restoreFocus = (trigger: HTMLElement | null) => { + setFocusRestore({ trigger }) + } + + // Hand the keyboard back once React has committed the render that closed the + // dialog, the way a submission failure waits for the render that adds its + // message bar. A frame callback could not be trusted with this: React + // schedules its own commit, so the callback could land first, and from there + // the control that opened the dialog still looked connected even though the + // same handler had already started the refresh that unmounts it — focus went + // to a doomed node and ended up on . An effect cannot run before that + // commit, so the tree it inspects is the one the user is about to see. + // + // New Converter is always mounted, so it is the fallback whenever the control + // that opened the dialog is gone: a removed row, or the empty-state button + // once the registry holds a converter. Nothing clears `focusRestore` here — + // a synchronous setState in an effect is what `react-hooks/set-state-in-effect` + // forbids, and a stale value is harmless because only a new request can + // re-run this. + useEffect(() => { + if (!focusRestore) return + if (openDialogRef.current) return + const target = focusRestore.trigger?.isConnected + ? focusRestore.trigger + : newConverterRef.current + target?.focus() + }, [focusRestore]) const loadConverters = useCallback(async () => { setLoading(true) @@ -90,13 +157,41 @@ export default function ConverterRegistry() { }) }, [loadConverters]) + const openCreateDialog = (event: MouseEvent) => { + createTriggerRef.current = event.currentTarget + setCreateToken(openDialog('create')) + } + + const closeCreateDialog = () => { + openDialogRef.current = null + setCreateToken(null) + restoreFocus(createTriggerRef.current) + } + + const openRemoveDialog = (event: MouseEvent, converter: ConverterInstance) => { + removeTriggerRef.current = event.currentTarget + openDialog('remove') + setConverterToRemove(converter) + } + + const dismissRemoveDialog = () => { + openDialogRef.current = null + setConverterToRemove(null) + restoreFocus(removeTriggerRef.current) + } + const removeConverter = async () => { if (!converterToRemove) return setRemoving(true) setError(null) try { await convertersApi.deleteConverter(converterToRemove.converter_id) - setConverterToRemove(null) + // The row that opened the dialog is about to unmount, so this restores to + // New Converter. Doing it before the refresh keeps focus off while + // the list reloads. No token check is needed: Cancel is disabled and the + // Escape handler is ignored while `removing`, so this dialog is still the + // one on screen. + dismissRemoveDialog() await loadConverters() } catch (err) { setError(toApiError(err).detail) @@ -123,10 +218,12 @@ export default function ConverterRegistry() { Refresh @@ -147,7 +244,12 @@ export default function ConverterRegistry() {
No Converters Registered Add a configured converter to the registry. -
@@ -184,11 +286,12 @@ export default function ConverterRegistry() { @@ -201,17 +304,22 @@ export default function ConverterRegistry() { )} setCreateOpen(false)} + open={createToken !== null} + onClose={closeCreateDialog} onCreated={() => { - setCreateOpen(false) + // This closure holds the token the create request was submitted + // under. A response that outlived its own dialog still refreshes the + // list, but only the opening it belongs to may clear the dialog state + // and move focus, so it cannot pull focus out of a dialog the user + // opened in the meantime or close one they reopened. + if (createToken && openDialogRef.current === createToken) closeCreateDialog() void loadConverters() }} /> { if (!data.open && !removing) setConverterToRemove(null) }} + onOpenChange={(_, data) => { if (!data.open && !removing) dismissRemoveDialog() }} > @@ -222,7 +330,7 @@ export default function ConverterRegistry() { : ''} - +