taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Apackages/taler-merchant-webui/src/ui/Modal.test.tsx | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/ui/Modal.tsx | 18++++++++++++++----
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;