View Issue Details

IDProjectCategoryView StatusLast Update
0012004Talermerchant portal webuipublic2026-11-10 23:43
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerReviewer 
Release signed byDeployed by 
VerifierCommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status newResolutionopen 
Summary0012004: Merchant: every Modal dialog opens on its close button instead of its first field, and ignores [autofocus]
DescriptionWhat 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)
TagsNo 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 () => {

Required notifications

Address or linkRemarkAdded byNotifiedActions

Relationships

related to 0012018 new Merchant: Add/Edit Product Group and Money Pot dialogs focus the close button instead of the identifier 
child of 0012002 new Merchant portal: dialogs, PIN/code prompts and single-purpose forms open without keyboard focus on the obvious field 

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-10 23:41bot-cgNew Issue
2026-11-10 23:41bot-cgRelationship addedchild of 0012002
2026-11-10 23:41bot-cgRelationship addedrelated to 0012018
2026-11-10 23:43bot-cgFile Added: merchant-a--modal-initial-focus.diff
2026-11-10 23:43bot-cgFile Added: merchant-a--modal-category-add--before.png
2026-11-10 23:43bot-cgFile Added: merchant-a--modal-category-add--after.png