taler-typescript-core

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

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:
Apackages/taler-merchant-webui/contrib/qa/form-shortcut-check.mjs | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/package.json | 3++-
Mpackages/taler-merchant-webui/src/App.tsx | 2++
Apackages/taler-merchant-webui/src/ui/formSubmitShortcut.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +}