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 () => {