commit 1284f78c5dc62c619d9171ab0e35cc7d7ffb2a26
parent 9203141730979089707a3ae2b77428c561c3fa85
Author: Florian Dold <dold@taler.net>
Date: Wed, 4 Nov 2026 23:56:08 +0100
merchant web UI: submit and advance forms with Ctrl+Enter
Route Ctrl+Enter through the active form and its enabled submit action.
Respect native validation, modal isolation, repeated keys, and focused
controls. Include browser coverage for forms and wizard navigation.
Diffstat:
4 files changed, 431 insertions(+), 1 deletion(-)
diff --git a/packages/taler-merchant-webui/contrib/qa/form-shortcut-check.mjs b/packages/taler-merchant-webui/contrib/qa/form-shortcut-check.mjs
@@ -0,0 +1,344 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import assert from "node:assert/strict";
+import { createServer } from "node:http";
+import { readFileSync } from "node:fs";
+import { build } from "esbuild";
+import puppeteer from "puppeteer-core";
+import { buildOptions } from "../../esbuild.config.mjs";
+const bundle = await build({
+ ...buildOptions({ outdir: "unused", dev: true }),
+ entryPoints: undefined,
+ stdin: {
+ resolveDir: process.cwd(),
+ loader: "tsx",
+ contents: `
+ import { render } from 'preact';
+ import { App } from './src/App.tsx';
+ import { CreateSubscriptionScreen } from './src/screens/CreateSubscriptionScreen.tsx';
+ import { installFormSubmitShortcut } from './src/ui/formSubmitShortcut.ts';
+ if (location.pathname === '/app') render(<App />, document.getElementById('app'));
+ else {
+ window.removeShortcut = installFormSubmitShortcut(document);
+ if (location.pathname === '/wizard') render(<CreateSubscriptionScreen setupMode="wizard" primaryCurrency="CHF"
+ categories={[{id:1,name:'Coffee',productCount:1},{id:2,name:'Cake',productCount:1}]}
+ onCreateFamily={async payload => { window.saved = payload; }} />, document.getElementById('app'));
+ }
+ `,
+ },
+ splitting: false,
+ sourcemap: false,
+ write: false,
+});
+const script = bundle.outputFiles.find((f) => f.path.endsWith(".js")).text;
+const css = readFileSync("dist/prod/style.css");
+const server = createServer((req, res) => {
+ const pathname = new URL(req.url, "http://local").pathname;
+ res.setHeader(
+ "Content-Type",
+ pathname === "/app.js"
+ ? "text/javascript"
+ : pathname === "/style.css"
+ ? "text/css"
+ : "text/html",
+ );
+ res.end(
+ pathname === "/app.js"
+ ? script
+ : pathname === "/style.css"
+ ? css
+ : '<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/style.css"><div id="app"></div><script type="module" src="/app.js"></script>',
+ );
+});
+await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+
+const browser = await puppeteer.launch({
+ executablePath: process.env.CHROME_BIN || "/usr/bin/google-chrome",
+ headless: true,
+ args: ["--no-sandbox"],
+});
+let checks = 0;
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1280, height: 1000 });
+ await page.evaluateOnNewDocument(() =>
+ localStorage.setItem("taler_merchant_lang", "en"),
+ );
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ const settle = () =>
+ page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
+ ),
+ );
+ const shortcut = async () => {
+ await page.keyboard.down("Control");
+ await page.keyboard.press("Enter");
+ await page.keyboard.up("Control");
+ await settle();
+ };
+ const fixture = async (html) => {
+ await page.goto(origin);
+ await page.waitForFunction(
+ () => typeof window.removeShortcut === "function",
+ );
+ await page.evaluate((html) => {
+ document.body.innerHTML =
+ html + '<button id="outside" type="button">Outside</button>';
+ window.submits = [];
+ for (const form of document.forms)
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ window.submits.push({ form: form.id, button: event.submitter?.id });
+ });
+ document.getElementById("outside").focus();
+ }, html);
+ };
+ const expect = async (expected, message) => {
+ assert.deepEqual(
+ await page.evaluate(() => window.submits),
+ expected,
+ message,
+ );
+ checks++;
+ };
+ const simple =
+ '<form id="first"><input id="required" required><textarea id="notes"></textarea><button id="save">Save</button></form>';
+ await fixture(simple);
+ await page.focus("#notes");
+ await shortcut();
+ await expect([], "native validation");
+ assert.equal(
+ await page.evaluate(() => document.activeElement.id),
+ "required",
+ );
+ await page.type("#required", "Valid");
+ await page.focus("#notes");
+ await page.keyboard.press("Enter");
+ await expect([], "ordinary textarea Enter");
+ assert.equal(await page.$eval("#notes", (el) => el.value), "\n");
+ await shortcut();
+ await expect(
+ [{ form: "first", button: "save" }],
+ "textarea submission with default submitter",
+ );
+ assert.equal(await page.$eval("#notes", (el) => el.value), "\n");
+
+ for (const attribute of ["disabled", 'aria-disabled="true"']) {
+ await fixture(
+ `<form id="first"><textarea id="notes"></textarea><button id="save" ${attribute}>Save</button><button id="other">Other action</button></form>`,
+ );
+ await page.focus("#notes");
+ await shortcut();
+ await expect(
+ [],
+ `disabled primary (${attribute}) must not trigger another action`,
+ );
+ }
+ await fixture(
+ '<form id="first"><textarea id="notes"></textarea><fieldset disabled><button id="save">Save</button></fieldset></form>',
+ );
+ await page.focus("#notes");
+ await shortcut();
+ await expect([], "disabled fieldset");
+ await fixture(
+ '<form id="first" aria-busy="true"><textarea id="notes"></textarea><button id="save">Save</button></form>',
+ );
+ await page.focus("#notes");
+ await shortcut();
+ await expect([], "pending form");
+
+ await fixture(
+ '<form id="first"><input id="one"><button id="save">Save</button></form><form id="second"><input id="two"><button id="next">Next</button></form>',
+ );
+ await shortcut();
+ await expect([], "ambiguous forms without focus");
+ await page.focus("#two");
+ await shortcut();
+ await expect([{ form: "second", button: "next" }], "focused form");
+ await fixture(
+ '<form id="first"></form><textarea id="external" form="first"></textarea><button id="save" form="first">Save</button>',
+ );
+ await page.focus("#external");
+ await shortcut();
+ await expect(
+ [{ form: "first", button: "save" }],
+ "external associated controls",
+ );
+
+ for (const attribute of [
+ "hidden",
+ 'style="display:none"',
+ "inert",
+ 'style="visibility:hidden"',
+ ]) {
+ await fixture(
+ `<div ${attribute}><form id="hidden"><button>Hidden</button></form></div><form id="first"><button id="save">Save</button></form>`,
+ );
+ await shortcut();
+ await expect(
+ [{ form: "first", button: "save" }],
+ `sole visible form (${attribute})`,
+ );
+ }
+ await fixture('<form id="first"><textarea id="notes"></textarea></form>');
+ await page.focus("#notes");
+ await shortcut();
+ await expect([], "no submit action");
+ await fixture(
+ '<form id="first" novalidate><input required><button id="save">Save</button></form>',
+ );
+ await shortcut();
+ await expect([{ form: "first", button: "save" }], "custom validation forms");
+
+ const background =
+ '<form id="background"><button id="background-save">Background</button></form>';
+ await fixture(
+ background +
+ '<div role="dialog" aria-modal="true"><button id="close" type="button">Close</button><form id="dialog"><button id="save">Save</button></form></div>',
+ );
+ await page.focus("#close");
+ await shortcut();
+ await expect(
+ [{ form: "dialog", button: "save" }],
+ "dialog focus outside its form",
+ );
+ await fixture(
+ background +
+ '<div role="dialog" aria-modal="true"><button id="close" type="button">Close</button></div>',
+ );
+ await page.focus("#close");
+ await shortcut();
+ await expect([], "dialog without a form must not submit the background");
+
+ await fixture(
+ '<form id="first"><textarea id="notes"></textarea><button id="save">Save</button></form>',
+ );
+ await page.focus("#notes");
+ for (const flags of [
+ { repeat: true },
+ { isComposing: true },
+ { shiftKey: true },
+ { altKey: true },
+ { ctrlKey: false },
+ ]) {
+ await page.$eval(
+ "#notes",
+ (el, flags) =>
+ el.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ ctrlKey: true,
+ bubbles: true,
+ cancelable: true,
+ ...flags,
+ }),
+ ),
+ flags,
+ );
+ }
+ await expect([], "repeat, composition and other shortcuts");
+ await page.evaluate(() => window.removeShortcut());
+ await shortcut();
+ await expect([], "listener cleanup");
+
+ // Exercise real wizard validation, selector keyboard handling, and save.
+ await page.goto(origin + "/wizard");
+ await page.waitForSelector("#sub_name_input");
+ await page.focus("#sub_name_input");
+ await shortcut();
+ assert.equal(
+ await page.$eval("#token-step-heading", (el) => el.textContent),
+ "Details",
+ );
+ assert.ok(await page.$('[role="alert"]'), "wizard validation is preserved");
+ assert.equal(await page.evaluate(() => window.saved), undefined);
+ checks++;
+ await page.type("#sub_name_input", "Coffee stamps");
+ await page.type("#sub_desc_input", "A free coffee after five stamps.");
+ await page.keyboard.down("Control");
+ await page.keyboard.down("Enter");
+ await settle();
+ await page.keyboard.down("Enter");
+ await settle();
+ await page.keyboard.up("Enter");
+ await page.keyboard.up("Control");
+ assert.match(
+ await page.$eval("#token-step-heading", (el) => el.textContent),
+ /Collecting discount stamps/,
+ );
+ assert.equal(
+ await page.$('[role="alert"]'),
+ null,
+ "holding the shortcut does not validate the next step",
+ );
+ await page.click('input[name="token-issuance-scope"][value="any"]');
+ await shortcut();
+ await page.focus('input[name="discount_type"][value="free_item"]');
+ await page.keyboard.press("Space");
+ await page.waitForSelector('input[name="stamp_count"]');
+ await page.click('label:has(> input[name="stamp_count"][value="5"])');
+ await shortcut();
+ assert.ok(await page.$("#redemption-products-error"));
+ assert.ok(await page.$('[role="alert"]'));
+ await page.focus('button[aria-haspopup="dialog"]');
+ await page.keyboard.press("Space");
+ await page.waitForSelector('[role="option"]');
+ await page.click('[role="option"]');
+ await page.waitForFunction(
+ () => !document.querySelector("#redemption-products-error"),
+ );
+ assert.equal(
+ await page.$('[role="alert"]'),
+ null,
+ "selecting a category clears the summary too",
+ );
+ assert.ok(
+ await page.$('[role="combobox"]'),
+ "clearing feedback keeps the selector open",
+ );
+ await page.focus('[role="combobox"]');
+ await page.keyboard.press("ArrowDown");
+ await shortcut();
+ assert.equal(
+ await page.$eval("#token-step-heading", (el) => el.textContent),
+ "Validity",
+ );
+ await shortcut();
+ assert.equal(
+ await page.$eval("#token-step-heading", (el) => el.textContent),
+ "Review",
+ );
+ assert.equal(await page.evaluate(() => window.saved), undefined);
+ await shortcut();
+ const saved = await page.evaluate(() => window.saved);
+ assert.ok(saved, "the shortcut submits the final review");
+ assert.equal(
+ saved.extraData.experimental_discount.product_selectors.length,
+ 1,
+ "Ctrl+Enter did not toggle a selector option",
+ );
+ checks++;
+
+ // Confirm the production App installs the listener, including Storybook.
+ await page.goto(origin + "/app#/storybook/integration-token-wizard");
+ await page.waitForSelector("#sub_name_input");
+ await page.type("#sub_name_input", "Coffee stamps");
+ await page.type("#sub_desc_input", "Collect stamps.");
+ await shortcut();
+ assert.match(
+ await page.$eval("#token-step-heading", (el) => el.textContent),
+ /Collecting discount stamps/,
+ );
+ checks++;
+ console.log(
+ `PASS ${checks} form shortcut checks, including native validation, dialogs, and wizard next/save`,
+ );
+} finally {
+ await browser.close();
+ await new Promise((resolve) => server.close(resolve));
+}
diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json
@@ -24,7 +24,8 @@
"visual:compare": "pnpm build && node visual/run.mjs compare",
"visual:inspect": "node visual/run.mjs inspect",
"visual:update": "pnpm build && node visual/run.mjs update",
- "pretty": "prettier --write src"
+ "pretty": "prettier --write src",
+ "test:form-shortcut-browser": "node contrib/qa/form-shortcut-check.mjs"
},
"dependencies": {
"@gnu-taler/taler-util": "workspace:*",
diff --git a/packages/taler-merchant-webui/src/App.tsx b/packages/taler-merchant-webui/src/App.tsx
@@ -27,6 +27,7 @@ import {
import { useHashLocation } from "./routing/useHashLocation.js";
import { useQueryParams } from "./routing/useQueryParams.js";
import { Layout } from "./ui/Layout.js";
+import { installFormSubmitShortcut } from "./ui/formSubmitShortcut.js";
import { OrderListRoute } from "./routes/OrderListRoute.js";
import type { PayoutAccount } from "./screens/MoneyInScreen.js";
import { lazy, Suspense } from "preact/compat";
@@ -1185,6 +1186,7 @@ function AppRoute(): VNode {
}
export function App(): VNode {
+ useEffect(() => installFormSubmitShortcut(document), []);
return (
<TranslationProvider>
<Router hook={useHashLocation}>
diff --git a/packages/taler-merchant-webui/src/ui/formSubmitShortcut.ts b/packages/taler-merchant-webui/src/ui/formSubmitShortcut.ts
@@ -0,0 +1,83 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+function visible(element: Element): boolean {
+ return (
+ !element.closest('[hidden], [inert], [aria-hidden="true"]') &&
+ element.getClientRects().length > 0 &&
+ getComputedStyle(element).visibility === "visible"
+ );
+}
+
+/** One delegated listener covers routed forms and dialogs mounted in portals. */
+export function installFormSubmitShortcut(document: Document): () => void {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key !== "Enter" ||
+ !event.ctrlKey ||
+ event.altKey ||
+ event.shiftKey ||
+ event.metaKey ||
+ event.isComposing ||
+ event.defaultPrevented
+ )
+ return;
+
+ const dialogs = Array.from(
+ document.querySelectorAll(
+ 'dialog[open], [role="dialog"][aria-modal="true"]',
+ ),
+ ).filter(visible);
+ const scope = dialogs.at(-1) ?? document;
+ const target = event.target instanceof Element ? event.target : undefined;
+ const control = target?.closest("button, input, select, textarea") as
+ | HTMLButtonElement
+ | HTMLInputElement
+ | HTMLSelectElement
+ | HTMLTextAreaElement
+ | null;
+ const owner = control?.form ?? target?.closest("form");
+ let form =
+ owner && scope.contains(owner) && visible(owner) ? owner : undefined;
+ if (!form) {
+ const forms = Array.from(scope.querySelectorAll("form")).filter(visible);
+ if (forms.length !== 1) return;
+ form = forms[0];
+ }
+ if (!form) return;
+
+ // Capture before textareas and selectors handle Enter, and suppress held
+ // keys so a single shortcut cannot advance several wizard steps.
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.repeat) return;
+
+ const submitter = Array.from(
+ document.querySelectorAll<HTMLButtonElement | HTMLInputElement>(
+ 'button, input[type="submit"], input[type="image"]',
+ ),
+ ).find(
+ (button) =>
+ button.form === form &&
+ (button.type === "submit" || button.type === "image") &&
+ visible(button),
+ );
+ // Do not choose a secondary submit action when the default is disabled.
+ if (
+ !submitter ||
+ submitter.matches(":disabled") ||
+ submitter.getAttribute("aria-disabled") === "true" ||
+ form.getAttribute("aria-busy") === "true"
+ )
+ return;
+
+ // Preserve native constraint validation, submitter identity and the form's
+ // own submission handler (including wizard validation and pending guards).
+ form.requestSubmit(submitter);
+ };
+ document.addEventListener("keydown", handleKeyDown, true);
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
+}