diff --git a/packages/taler-merchant-webui/src/ui/Modal.test.tsx b/packages/taler-merchant-webui/src/ui/Modal.test.tsx index 935428976..a6bebcccb 100644 --- a/packages/taler-merchant-webui/src/ui/Modal.test.tsx +++ b/packages/taler-merchant-webui/src/ui/Modal.test.tsx @@ -9,7 +9,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { render } from "preact"; +import { render, type ComponentChildren } from "preact"; import { useState } from "preact/hooks"; import { act } from "preact/test-utils"; import { Modal } from "./Modal.js"; @@ -45,8 +45,7 @@ test("typing preserves modal focus and Escape uses the latest callback", async ( await act(() => render(, container)); const dialog = document.querySelector('[role="dialog"]')!; const input = dialog.querySelector("input")!; - assert.equal(document.activeElement, dialog.querySelector("button")); - input.focus(); + assert.equal(document.activeElement, input); for (const character of "Coffee") { await act(() => { input.value += character; @@ -68,6 +67,59 @@ test("typing preserves modal focus and Escape uses the latest callback", async ( } }); +test("an opened dialog starts in its field, never on a destructive option", async () => { + const container = document.createElement("div"); + document.body.append(container); + const open = (content: ComponentChildren) => + act(() => + render( + {}}> + {content} + , + container, + ), + ); + const close = () => act(() => render(null, container)); + try { + await open( + <> + + + + + , + ); + assert.equal(document.activeElement?.id, "amount"); + await close(); + await open( + <> + + {/* eslint-disable-next-line jsx-a11y/no-autofocus -- the marker under test */} + + , + ); + assert.equal(document.activeElement?.id, "code"); + await close(); + await open( + <> + + + , + ); + assert.equal( + document.activeElement, + document.querySelector('[role="dialog"] button'), + ); + } finally { + await close(); + container.remove(); + } +}); + test("closed modals leave focus alone and restore each actual opener", async () => { const container = document.createElement("div"); const first = document.createElement("button"); diff --git a/packages/taler-merchant-webui/src/ui/Modal.tsx b/packages/taler-merchant-webui/src/ui/Modal.tsx index 82cc910dd..a2c446000 100644 --- a/packages/taler-merchant-webui/src/ui/Modal.tsx +++ b/packages/taler-merchant-webui/src/ui/Modal.tsx @@ -38,6 +38,35 @@ const maxWidthMap = { "4xl": "max-w-4xl", }; +/** + * Where focus starts in an opened dialog: an element marked `autofocus` + * (Preact only sets the attribute, which browsers ignore after page load), + * else the first field to type into, else the first control, which is the + * close button when the dialog has a title. + * + * Separate queries, because one selector list matches in document order and + * the close button precedes everything in the content. Checkboxes and radios + * are skipped: in a confirmation they are options such as "force deletion", + * so a dialog without a text field starts on its close button, never on the + * destructive action. + */ +function initialFocusTarget(overlay: HTMLElement): HTMLElement | undefined { + const first = (selector: string) => + Array.from(overlay.querySelectorAll(selector)).find( + (element) => + !element.matches(":disabled") && !element.closest("fieldset[disabled]"), + ); + return ( + first("[autofocus]") ?? + first( + 'input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="file"]):not([readonly]), textarea:not([readonly])', + ) ?? + first( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + ); +} + export function Modal({ isOpen, onClose, @@ -105,11 +134,7 @@ export function Modal({ child.inert = true; child.setAttribute("aria-hidden", "true"); } - overlay - .querySelector( - '[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ) - ?.focus(); + initialFocusTarget(overlay)?.focus(); }); } return () => {