View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012004 | Taler | merchant portal webui | public | 2026-11-10 23:41 | 2026-11-10 23:43 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | minor | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012004: Merchant: every Modal dialog opens on its close button instead of its first field, and ignores [autofocus] | ||||
| Description | What happens: Modal focused overlay.querySelector('[autofocus], button, [href], input, ...'). A selector list matches in document order, so with a title the header's close button (✕) always wins: [autofocus] can never take effect and no dialog starts in its input. Measured activeElement on open: 'Add a Category' / 'Rename Category' -> BUTTON Close (not #cat_name_input); Quick Edit Price -> BUTTON Close (not #quick_price_input); Admin 'Permanently purge merchant account' -> BUTTON Close (not the type-the-ID-to-confirm field #purge-account-id); Settings 'Permanently purge merchant account' -> BUTTON Close (not #purge-current-account-id). Expected: Start on an element marked autofocus if any, else the first text field the dialog asks for (name, price, type-to-confirm ID); confirmations with nothing to type keep starting on the safe close button, never on the destructive action or a 'force deletion' checkbox. Where: - packages/taler-merchant-webui/src/ui/Modal.tsx:53 - packages/taler-merchant-webui/src/ui/Modal.tsx:137 - packages/taler-merchant-webui/src/ui/Modal.test.tsx:70 Measured in headless Chromium (document.activeElement): before: BUTTON[aria-label=Close] (category add, quick price, admin purge, settings purge, product delete) after: INPUT#cat_name_input / INPUT#quick_price_input[number] / INPUT#purge-account-id / INPUT#purge-current-account-id; product delete stays BUTTON Close Proposed fix: New initialFocusTarget(): separate queries in priority order — enabled [autofocus]; else first enabled, non-readonly text-entry input/textarea (checkbox/radio/hidden/button/file skipped, so a currency <select> or 'force deletion' checkbox is passed over); else the previous first-focusable fallback (the close button). Disabled and disabled-fieldset elements are skipped everywhere. Modal.test.tsx: first test now expects the field, new test covers autofocus priority, select/readonly/checkbox skipping, and checkbox-only confirmation -> close button. Call sites whose start changes: InventoryScreen category create/rename (#cat_name_input), InventoryScreen Quick Edit Price (#quick_price_input, number input past the currency select), AdminAccountsScreen purge (#purge-account-id), BusinessSettingsScreen purge (#purge-current-account-id), ReportsScreen Add/Edit Product Group (#grp_name_input) and Add/Edit Money Pot (#pot_name_input) [out-of-scope file, shared fix]. Unchanged (still close button, deliberately): ConfirmDeleteModal (Access revoke, Webhooks, Subscriptions, Devices delete), Devices rotate-key + new-key display, Inventory delete category, Inventory delete product (force-delete checkbox NOT focused), AdminAccounts/BusinessSettings disable, BankMonitoringDialog while loading, PosScreen unpaid-sale decision, TemplateDetails delete + QR, Reports delete group/pot/cancel report. Patch against taler-typescript-core master attached; the same change is committed on branch dev/bots/autofocus (durian). Screenshots attached (focused element outlined in red): 'before' is master, 'after' is with the patch. Verified: tsc ok (only pre-existing unrelated errors in utils/inventory*.ts, api/client.ts); eslint clean; prettier clean; Modal.test.js 4/4 pass; Chromium screenshots before/after for modal-category-add, modal-quick-price, modal-product-delete, modal-admin-purge, modal-settings-purge (same $S/shots/merchant-a--<name>--{before,after}.png naming) | ||||
| Tags | No tags attached. | ||||
| Attached Files | merchant-a--modal-initial-focus.diff (4,896 bytes)
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(<CategoryDialog />, 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(
+ <Modal isOpen title="Dialog" onClose={() => {}}>
+ {content}
+ </Modal>,
+ container,
+ ),
+ );
+ const close = () => act(() => render(null, container));
+ try {
+ await open(
+ <>
+ <input type="checkbox" />
+ <input readOnly value="fixed" />
+ <select>
+ <option>EUR</option>
+ </select>
+ <input id="amount" type="number" />
+ </>,
+ );
+ assert.equal(document.activeElement?.id, "amount");
+ await close();
+ await open(
+ <>
+ <input id="name" />
+ {/* eslint-disable-next-line jsx-a11y/no-autofocus -- the marker under test */}
+ <input id="code" autoFocus />
+ </>,
+ );
+ assert.equal(document.activeElement?.id, "code");
+ await close();
+ await open(
+ <>
+ <label>
+ <input type="checkbox" /> Force deletion
+ </label>
+ <button>Delete</button>
+ </>,
+ );
+ 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<HTMLElement>(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<HTMLElement>(
- '[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
- )
- ?.focus();
+ initialFocusTarget(overlay)?.focus();
});
}
return () => {
| ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-10 23:41 | bot-cg | New Issue | |
| 2026-11-10 23:41 | bot-cg | Relationship added | child of 0012002 |
| 2026-11-10 23:41 | bot-cg | Relationship added | related to 0012018 |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-a--modal-initial-focus.diff | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-a--modal-category-add--before.png | |
| 2026-11-10 23:43 | bot-cg | File Added: merchant-a--modal-category-add--after.png |