commit cbe04ebac54c7587ee0624b660d60ce019eb4864
parent fa1cb3b8f8aab5fe8de07fe21b31e67f29f8f986
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 20:42:19 +0100
merchant web UI: preserve modal focus across renders
Diffstat:
2 files changed, 203 insertions(+), 4 deletions(-)
diff --git a/packages/taler-merchant-webui/src/ui/Modal.test.tsx b/packages/taler-merchant-webui/src/ui/Modal.test.tsx
@@ -0,0 +1,189 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { render } from "preact";
+import { useState } from "preact/hooks";
+import { act } from "preact/test-utils";
+import { Modal } from "./Modal.js";
+
+test("typing preserves modal focus and Escape uses the latest callback", async () => {
+ const container = document.createElement("div");
+ const trigger = document.createElement("button");
+ document.body.append(container, trigger);
+ trigger.focus();
+ let closedValue: string | undefined;
+
+ function CategoryDialog() {
+ const [name, setName] = useState("");
+ const [isOpen, setIsOpen] = useState(true);
+ return (
+ <Modal
+ isOpen={isOpen}
+ title="Category"
+ onClose={() => {
+ closedValue = name;
+ setIsOpen(false);
+ }}
+ >
+ <input
+ value={name}
+ onInput={(event) => setName(event.currentTarget.value)}
+ />
+ </Modal>
+ );
+ }
+
+ try {
+ await act(() => render(<CategoryDialog />, container));
+ const dialog = document.querySelector('[role="dialog"]')!;
+ const input = dialog.querySelector("input")!;
+ assert.equal(document.activeElement, dialog.querySelector("button"));
+ input.focus();
+ for (const character of "Coffee") {
+ await act(() => {
+ input.value += character;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ assert.equal(document.activeElement, input);
+ }
+ assert.equal(input.value, "Coffee");
+ await act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ assert.equal(closedValue, "Coffee");
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ assert.equal(document.activeElement, trigger);
+ } finally {
+ await act(() => render(null, container));
+ container.remove();
+ trigger.remove();
+ }
+});
+
+test("closed modals leave focus alone and restore each actual opener", async () => {
+ const container = document.createElement("div");
+ const first = document.createElement("button");
+ const second = document.createElement("button");
+ document.body.append(container, first, second);
+ const show = (isOpen: boolean) =>
+ act(() =>
+ render(
+ <Modal isOpen={isOpen} title="Dialog" onClose={() => {}}>
+ <input />
+ <button>Last</button>
+ </Modal>,
+ container,
+ ),
+ );
+ try {
+ first.focus();
+ await show(false);
+ for (const opener of [second, first]) {
+ opener.focus();
+ await show(true);
+ assert.equal(container.inert, true);
+ const dialog = document.querySelector('[role="dialog"]')!;
+ const buttons = dialog.querySelectorAll("button");
+ const close = buttons[0]!;
+ const last = buttons[1]!;
+ last.focus();
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Tab", cancelable: true }),
+ );
+ assert.equal(document.activeElement, close);
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey: true,
+ cancelable: true,
+ }),
+ );
+ assert.equal(document.activeElement, last);
+ await show(false);
+ assert.equal(document.activeElement, opener);
+ assert.equal(container.inert, false);
+ assert.equal(container.hasAttribute("aria-hidden"), false);
+ }
+ second.focus();
+ await act(() => render(null, container));
+ assert.equal(document.activeElement, second);
+ } finally {
+ await act(() => render(null, container));
+ container.remove();
+ first.remove();
+ second.remove();
+ }
+});
+
+test("obsolete modal initialization cannot affect a reopened dialog", () => {
+ const container = document.createElement("div");
+ const opener = document.createElement("button");
+ document.body.append(container, opener);
+ opener.focus();
+ const queued: VoidFunction[] = [];
+ const originalQueue = globalThis.queueMicrotask;
+ globalThis.queueMicrotask = (callback) => {
+ queued.push(callback);
+ };
+ try {
+ act(() =>
+ render(
+ <Modal isOpen title="Dialog" onClose={() => {}}>
+ Content
+ </Modal>,
+ container,
+ ),
+ );
+ assert.ok(queued.length);
+ act(() =>
+ render(
+ <Modal isOpen={false} title="Dialog" onClose={() => {}}>
+ Content
+ </Modal>,
+ container,
+ ),
+ );
+ act(() =>
+ render(
+ <Modal isOpen title="Dialog" onClose={() => {}}>
+ <fieldset disabled>
+ <input />
+ <button>Save</button>
+ </fieldset>
+ </Modal>,
+ container,
+ ),
+ );
+ assert.equal(queued.length, 2);
+ queued[0]!();
+ assert.equal(document.activeElement, opener);
+ assert.notEqual(container.inert, true);
+ queued[1]!();
+ const close = document.querySelector<HTMLButtonElement>(
+ '[role="dialog"] button',
+ )!;
+ assert.equal(document.activeElement, close);
+ // Disabled fieldset descendants must not become focus-trap endpoints.
+ const tab = new KeyboardEvent("keydown", { key: "Tab", cancelable: true });
+ window.dispatchEvent(tab);
+ assert.equal(tab.defaultPrevented, true);
+ assert.equal(document.activeElement, close);
+ act(() => render(null, container));
+ assert.equal(document.activeElement, opener);
+ assert.equal(container.hasAttribute("aria-hidden"), false);
+ assert.notEqual(container.inert, true);
+ } finally {
+ globalThis.queueMicrotask = originalQueue;
+ render(null, container);
+ container.remove();
+ opener.remove();
+ }
+});
diff --git a/packages/taler-merchant-webui/src/ui/Modal.tsx b/packages/taler-merchant-webui/src/ui/Modal.tsx
@@ -49,16 +49,25 @@ export function Modal({
const { t } = useTranslation();
const titleId = useId();
const overlayRef = useRef<HTMLDivElement>(null);
+ // Keep Escape current without restarting focus management on parent renders.
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
useEffect(() => {
+ if (!isOpen) return;
+ let cancelled = false;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
- onClose();
+ onCloseRef.current();
}
if (e.key === "Tab" && isOpen) {
const focusable = Array.from(
overlayRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [],
+ ).filter(
+ (element) =>
+ !element.matches(":disabled") &&
+ !element.closest("fieldset[disabled]"),
);
if (focusable.length === 0) {
e.preventDefault();
@@ -85,7 +94,7 @@ export function Modal({
window.addEventListener("keydown", handleKeyDown);
queueMicrotask(() => {
const overlay = overlayRef.current;
- if (!overlay) return;
+ if (cancelled || !overlay) return;
for (const child of Array.from(document.body.children)) {
if (child === overlay || !(child instanceof HTMLElement)) continue;
changed.push({
@@ -104,15 +113,16 @@ export function Modal({
});
}
return () => {
+ cancelled = true;
window.removeEventListener("keydown", handleKeyDown);
for (const item of changed) {
item.element.inert = item.inert;
if (item.hidden === null) item.element.removeAttribute("aria-hidden");
else item.element.setAttribute("aria-hidden", item.hidden);
}
- previousFocus?.focus();
+ if (previousFocus?.isConnected) previousFocus.focus();
};
- }, [isOpen, onClose]);
+ }, [isOpen]);
if (!isOpen) return null;