taler-typescript-core

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

commit 3e231877d691b125be2e12fadb69c9f5923e0289
parent 1fa9854ee9c4394c9b16044c52a53d6b461b1082
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 00:52:21 +0200

web-util: refine story browser navigation and examples

Diffstat:
Mpackages/web-util/src/forms/fields/InputAbsoluteTime.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputAmount.stories.tsx | 12++++++------
Apackages/web-util/src/forms/fields/InputAmount.test.tsx | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/fields/InputAmount.tsx | 11++++++-----
Mpackages/web-util/src/forms/fields/InputChoiceHorizontal.stories.tsx | 8++++----
Mpackages/web-util/src/forms/fields/InputChoiceStacked.stories.tsx | 8++++----
Mpackages/web-util/src/forms/fields/InputDuration.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputDurationText.stories.tsx | 10+++++-----
Mpackages/web-util/src/forms/fields/InputFile.stories.tsx | 22+++++++++++-----------
Mpackages/web-util/src/forms/fields/InputInteger.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputIsoDate.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputSecret.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputSelectOne.stories.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputText.stories.tsx | 10+++++-----
Mpackages/web-util/src/forms/fields/InputTextArea.stories.tsx | 4++--
Mpackages/web-util/src/forms/forms-ui.tsx | 1-
Mpackages/web-util/src/forms/forms-utils.ts | 13+++++++------
Mpackages/web-util/src/forms/gana/VQF_902_9_officer.stories.tsx | 2+-
Mpackages/web-util/src/forms/gana/index.stories.ts | 23+++++++++++++++++++++--
Mpackages/web-util/src/forms/gana/personal-info.stories.tsx | 4++--
Apackages/web-util/src/forms/index.stories.test.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/index.stories.ts | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/web-util/src/stories-utils.test.tsx | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/web-util/src/stories-utils.tsx | 307++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/web-util/src/stories.tsx | 13++++++++++---
26 files changed, 667 insertions(+), 154 deletions(-)

diff --git a/packages/web-util/src/forms/fields/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/fields/InputAbsoluteTime.stories.tsx @@ -21,7 +21,7 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; export default { title: "Input Absolute Time", @@ -57,7 +57,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputAmount.stories.tsx b/packages/web-util/src/forms/fields/InputAmount.stories.tsx @@ -19,10 +19,10 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import { AmountString, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Amount", @@ -35,10 +35,10 @@ export namespace Simplest { } type TargetObject = { - amount: AmountJson; + amount: AmountString; }; const initial: TargetObject = { - amount: Amounts.parseOrThrow("USD:10"), + amount: "CHF:12.50", }; const design: FormDesign = { @@ -51,7 +51,7 @@ const design: FormDesign = { type: "amount", label: "label of the field" as TranslatedString, id: "amount", - currency: "ARS", + currency: "CHF", }, { type: "text", @@ -63,7 +63,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputAmount.test.tsx b/packages/web-util/src/forms/fields/InputAmount.test.tsx @@ -0,0 +1,113 @@ +/* + 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + AmountString, + setupI18n, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { useForm } from "../../hooks/useForm.js"; +import { FormUI } from "../forms-ui.js"; +import { FormDesign } from "../forms-types.js"; + +setupI18n("en", {}); + +function installDom(): Window { + const window = new Window({ url: "https://forms.example/" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + HTMLElement: window.HTMLElement, + HTMLInputElement: window.HTMLInputElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +async function eventually(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt++) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +const design: FormDesign = { + type: "single-column", + fields: [ + { + type: "amount", + id: "amount", + currency: "CHF", + label: "Amount" as TranslatedString, + }, + ], +}; + +test("amount fields emit currency-qualified strings", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + let result: { amount?: AmountString } = {}; + + function Harness() { + const form = useForm<{ amount?: AmountString }>(design, { + amount: "CHF:12.50", + }); + result = form.status.result as { amount?: AmountString }; + return <FormUI design={design} model={form.model} />; + } + + const view = render(<Harness />); + const input = view.getByRole("textbox", { + name: "Amount", + }) as HTMLInputElement; + assert.equal(input.value, "12.5"); + assert.equal(result.amount, "CHF:12.50"); + + input.value = "7.25"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await eventually(() => assert.equal(result.amount, "CHF:7.25")); + assert.equal(typeof result.amount, "string"); + + input.value = ""; + input.dispatchEvent(new Event("input", { bubbles: true })); + await eventually(() => assert.equal(result.amount, undefined)); + + cleanup(); + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/forms/fields/InputAmount.tsx b/packages/web-util/src/forms/fields/InputAmount.tsx @@ -1,10 +1,10 @@ -import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import { AmountString, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { UIFormProps } from "../FormProvider.js"; import { InputLine } from "./InputLine.js"; export function InputAmount( - props: { currency: string } & UIFormProps<AmountJson>, + props: { currency: string } & UIFormProps<AmountString>, ): VNode { return ( <InputLine @@ -17,11 +17,12 @@ export function InputAmount( //@ts-ignore converter={ props.converter ?? { - fromStringUI: (v): AmountJson | undefined => { + fromStringUI: (v): AmountString | undefined => { if (!v) return undefined; - return Amounts.parse(`${props.currency}:${v}`) ?? undefined; + const amount = Amounts.parse(`${props.currency}:${v}`); + return amount ? Amounts.stringify(amount) : undefined; }, - toStringUI: (v: AmountJson) => { + toStringUI: (v: AmountString) => { return v === undefined ? "" : Amounts.stringifyValue(v); }, } diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Choice Horizontal", @@ -35,10 +35,10 @@ export namespace Simplest { } type TargetObject = { - comment: string; + comment?: string; }; const initial: TargetObject = { - comment: "0", + comment: undefined, }; const design: FormDesign = { @@ -71,7 +71,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Empty = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Choice Stacked", @@ -35,10 +35,10 @@ export namespace Simplest { } type TargetObject = { - comment: string; + comment?: string; }; const initial: TargetObject = { - comment: "some initial comment", + comment: undefined, }; const design: FormDesign = { @@ -71,7 +71,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Empty = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputDuration.stories.tsx b/packages/web-util/src/forms/fields/InputDuration.stories.tsx @@ -21,7 +21,7 @@ import { Duration, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; export default { @@ -56,7 +56,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputDurationText.stories.tsx b/packages/web-util/src/forms/fields/InputDurationText.stories.tsx @@ -22,7 +22,7 @@ import { Duration, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Duration Text", @@ -53,12 +53,12 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { - initial, +export const Empty = tests.createExample(TestedComponent, { + initial: { age: undefined }, design, }); -export const Empty = tests.createExample(TestedComponent, { - initial: { age: undefined }, +export const Prefilled = tests.createExample(TestedComponent, { + initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputFile.stories.tsx b/packages/web-util/src/forms/fields/InputFile.stories.tsx @@ -22,23 +22,23 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FileFieldData, FormDesign } from "../forms-types.js"; export default { title: "Input File", }; -export namespace Simplest { - export interface Form { - comment: string; - } -} - type TargetObject = { - pdf?: string; + pdf?: FileFieldData; }; + const initial: TargetObject = { - pdf: "file:CONTRATO DE LOCACIÓN DE VIVIENDA BULNES.docx.pdf;data:application/pdf;base64,JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PC9UaXRsZSA8RkVGRjAwNDMwMDRGMDA0RTAwNTQwMDUyMDA0MTAwNTQwMDRGMDAyMDAwNDQwMDQ1MDAyMDAwNEMwMDRGMDA0MzAwNDEwMDQzMDA0OTAwRDMwMDRFMDAyMDAwNDQwMDQ1MDAyMDAwNTYwMDQ5MDA1NjAwNDkwMDQ1MDA0RTAwNDQwMDQxMDAyMDAwNDIwMDU1MDA0QzAwNEUwMDQ1MDA1MzAwMkUwMDY0MDA2RjAwNjMwMDc4PgovUHJvZHVjZXIgKFNraWEvUERGIG0xMzQgR29vZ2xlIERvY3MgUmVuZGVyZXIpPj4KZW5kb2JqCjMgMCBvYmoKPDwvY2EgMQovQk0gL05vcm1hbD4+CmVuZG9iago3IDAgb2JqCjw8L0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAxMDMxNT4+IHN0cmVhbQp4nMVd3Y4muW29n6foF9ha/f8AhoHpnh0jFwbiYN8gjg0EyEWc9wciSqKqSlJ1fRRrZr1o90zvtr5TKkmkSJ5D+SbSP7/I9H/BqLf//J8v//tl8zb/FL+nH8o3+Oc//vJW/vCvf3759S/67Z//9wX+vY/mTUql3/71X1/+8eVv3QjBbsIKIfSbF1swMcYAI4r8H6QRyx/2EX/997c//enXv37827f04z//+f3bx5f337/8+j19hnv7/R9fJCK2cTPW6LfNKm10+qTfYdRfpNlMKH//+9ufhFAufbZKXzJ9+fSVwCjz57ff//uL85sCoOU/1D7/MOKvt5+r/PPffifgCHGTProQRjxC04eLenMJ6fhs6XlUoI8npd2CldbpZ/BJJTaVHtfPMH4v8w/vQZn6/UOID/i5pH+WEnY+GSvAlRSb8AnHc3OrtMpz8dCrV9pt3igZx+F0LDOJ31UoqzwjN/SP0lJt6", + pdf: { + CONTENTS: "JVBERi0xLjQKJcTl8uXrCg==", + ENCODING: "base64", + FILENAME: "sample.pdf", + MIME_TYPE: "application/pdf", + }, }; const design: FormDesign = { @@ -55,7 +55,7 @@ const design: FormDesign = { accept: ".png", tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString, - help: "Max size of 2 mega bytes" as TranslatedString, + help: "Maximum file size: 2 MB" as TranslatedString, }, { type: "file", @@ -65,7 +65,7 @@ const design: FormDesign = { accept: ".pdf", tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString, - help: "Max size of 2 mega bytes" as TranslatedString, + help: "Maximum file size: 2 MB" as TranslatedString, }, ], }, diff --git a/packages/web-util/src/forms/fields/InputInteger.stories.tsx b/packages/web-util/src/forms/fields/InputInteger.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Integer", @@ -74,7 +74,7 @@ const design2: FormDesign = { }, ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputIsoDate.stories.tsx b/packages/web-util/src/forms/fields/InputIsoDate.stories.tsx @@ -21,7 +21,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; export default { title: "Input Iso Date", @@ -58,7 +58,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputSecret.stories.tsx b/packages/web-util/src/forms/fields/InputSecret.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input secret", @@ -51,7 +51,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx @@ -96,11 +96,11 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Empty = tests.createExample(TestedComponent, { initial, design, }); -export const WithInitialValue = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial: { pets: ["one"], }, diff --git a/packages/web-util/src/forms/fields/InputSelectOne.stories.tsx b/packages/web-util/src/forms/fields/InputSelectOne.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Select One", @@ -73,7 +73,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputText.stories.tsx b/packages/web-util/src/forms/fields/InputText.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Text", @@ -64,12 +64,12 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { - initial, +export const Empty = tests.createExample(TestedComponent, { + initial: { comment: undefined }, design, }); -export const Empty = tests.createExample(TestedComponent, { - initial: { comment: undefined }, +export const Prefilled = tests.createExample(TestedComponent, { + initial, design, }); diff --git a/packages/web-util/src/forms/fields/InputTextArea.stories.tsx b/packages/web-util/src/forms/fields/InputTextArea.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; -import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { FormDesign } from "../forms-types.js"; export default { title: "Input Text Area", @@ -57,7 +57,7 @@ const design: FormDesign = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const Prefilled = tests.createExample(TestedComponent, { initial, design, }); diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -36,7 +36,6 @@ export function DefaultForm<T>({ return ( <div> - <hr class="mt-3 mb-3" /> <FormUI design={design} model={handler} disabled={disabled} /> <hr class="mt-3 mb-3" /> <label> diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -1,6 +1,6 @@ import { AbsoluteTime, - AmountJson, + AmountString, Amounts, assertUnreachable, TranslatedString, @@ -472,18 +472,19 @@ const nullConverter: StringConverter<string> = { }, }; -function amountConverter(config: any): StringConverter<AmountJson> { +function amountConverter(config: any): StringConverter<AmountString> { const currency = config["currency"]; if (!currency || typeof currency !== "string") { throw Error(`amount converter needs a currency`); } return { - fromStringUI(v: string | undefined): AmountJson | undefined { + fromStringUI(v: string | undefined): AmountString | undefined { if (!v) return undefined; - return Amounts.parse(`${currency}:${v}`) ?? undefined; + const amount = Amounts.parse(`${currency}:${v}`); + return amount ? Amounts.stringify(amount) : undefined; }, toStringUI(v: unknown): string { - return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + return v === undefined ? "" : Amounts.stringifyValue(v as AmountString); }, }; } @@ -509,7 +510,7 @@ function absTimeConverter(config: any): StringConverter<AbsoluteTime> { if (d.t_ms === "never") return "never"; try { return format(d.t_ms, pattern); - } catch (e) { + } catch { return ""; } }, diff --git a/packages/web-util/src/forms/gana/VQF_902_9_officer.stories.tsx b/packages/web-util/src/forms/gana/VQF_902_9_officer.stories.tsx @@ -24,7 +24,7 @@ import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; import { VQF_902_9_officer } from "./VQF_902_9_officer.js"; export default { - title: "VQF_902_9_officer", + title: "vqf_902_9_officer", }; setupI18n("en", {}); diff --git a/packages/web-util/src/forms/gana/index.stories.ts b/packages/web-util/src/forms/gana/index.stories.ts @@ -1,4 +1,19 @@ -export * as accept_tops_stories from "./accept-tos.stories.js"; +import acceptTosMeta, { + AlreadyAccepted, + EmptyForm as EmptyAcceptTosForm, + LinkOnly, +} from "./accept-tos.stories.js"; +import vqf9021CustomerMeta, { + DisabledForm, + EmptyForm as EmptyVqf9021CustomerForm, +} from "./VQF_902_1_customer.stories.js"; + +export const accept_tops_stories = { + default: acceptTosMeta, + EmptyForm: EmptyAcceptTosForm, + LinkOnly, + AlreadyAccepted, +}; export * as generic_note_stories from "./generic_note.stories.js"; export * as generic_upload_stories from "./generic_upload.stories.js"; export * as personal_info_stories from "./personal-info.stories.js"; @@ -6,7 +21,11 @@ export * as personal_info_stories from "./personal-info.stories.js"; export * as vqf_902_11_customer_stories from "./VQF_902_11_customer.stories.js"; export * as vqf_902_11_officer_stories from "./VQF_902_11_officer.stories.js"; export * as vqf_902_14_stories from "./VQF_902_14.stories.js"; -export * as vqf_902_1_customer_stories from "./VQF_902_1_customer.stories.js"; +export const vqf_902_1_customer_stories = { + default: vqf9021CustomerMeta, + EmptyForm: EmptyVqf9021CustomerForm, + DisabledForm, +}; export * as vqf_902_1_officer_stories from "./VQF_902_1_officer.stories.js"; export * as vqf_902_4_stories from "./VQF_902_4.stories.js"; export * as vqf_902_5_stories from "./VQF_902_5.stories.js"; diff --git a/packages/web-util/src/forms/gana/personal-info.stories.tsx b/packages/web-util/src/forms/gana/personal-info.stories.tsx @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, i18n, setupI18n } from "@gnu-taler/taler-util"; +import { AmountString, i18n, setupI18n } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm } from "../forms-ui.js"; import { personalInfo } from "./personal-info.js"; @@ -31,7 +31,7 @@ export const Empty = tests.createExample(DefaultForm, { export const Prefilled = tests.createExample(DefaultForm, { initial: { trucker: "yes", - money: Amounts.parseOrThrow("YEIN:100"), + money: "YEIN:100" as AmountString, }, design, }); diff --git a/packages/web-util/src/forms/index.stories.test.ts b/packages/web-util/src/forms/index.stories.test.ts @@ -0,0 +1,54 @@ +/* + 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseGroupImport } from "../stories-utils.js"; +import { formPrimitives, genericForms, vqfForms } from "./index.stories.js"; + +test("form stories are grouped and empty datasets are listed first", () => { + const groups = parseGroupImport({ + "form primitives": formPrimitives, + "generic forms": genericForms, + "VQF forms": vqfForms, + }); + + assert.deepEqual( + groups.map((group) => group.title), + ["form primitives", "generic forms", "VQF forms"], + ); + assert.ok(groups.every((group) => group.list.length > 0)); + + for (const group of groups) { + for (const component of group.list) { + assert.equal( + component.examples.some((dataset) => dataset.name === "SimpleComment"), + false, + `${component.name} should use a descriptive dataset name`, + ); + const emptyDatasetIndex = component.examples.findIndex( + (dataset) => dataset.name === "Empty" || dataset.name === "EmptyForm", + ); + if (emptyDatasetIndex >= 0) { + assert.equal( + emptyDatasetIndex, + 0, + `${component.name} should list its empty dataset first`, + ); + } + } + } +}); diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts @@ -1,17 +1,56 @@ -export * as a5 from "./fields/InputAbsoluteTime.stories.js"; -export * as a1 from "./fields/InputAmount.stories.js"; -export * as a2 from "./fields/InputArray.stories.js"; -export * as a3 from "./fields/InputChoiceHorizontal.stories.js"; -export * as a4 from "./fields/InputChoiceStacked.stories.js"; -export * as a15 from "./fields/InputDuration.stories.js"; -export * as a16 from "./fields/InputDurationText.stories.js"; -export * as a6 from "./fields/InputFile.stories.js"; -export * as a7 from "./fields/InputInteger.stories.js"; -export * as a18 from "./fields/InputIsoDate.stories.js"; -export * as a14 from "./fields/InputSecret.stories.js"; -export * as a9 from "./fields/InputSelectMultiple.stories.js"; -export * as a10 from "./fields/InputSelectOne.stories.js"; -export * as a11 from "./fields/InputText.stories.js"; -export * as a12 from "./fields/InputTextArea.stories.js"; -export * as a13 from "./fields/InputToggle.stories.js"; -export * as a17 from "./gana/index.stories.js"; +import * as inputAbsoluteTime from "./fields/InputAbsoluteTime.stories.js"; +import * as inputAmount from "./fields/InputAmount.stories.js"; +import * as inputArray from "./fields/InputArray.stories.js"; +import * as inputChoiceHorizontal from "./fields/InputChoiceHorizontal.stories.js"; +import * as inputChoiceStacked from "./fields/InputChoiceStacked.stories.js"; +import * as inputDuration from "./fields/InputDuration.stories.js"; +import * as inputDurationText from "./fields/InputDurationText.stories.js"; +import * as inputFile from "./fields/InputFile.stories.js"; +import * as inputInteger from "./fields/InputInteger.stories.js"; +import * as inputIsoDate from "./fields/InputIsoDate.stories.js"; +import * as inputSecret from "./fields/InputSecret.stories.js"; +import * as inputSelectMultiple from "./fields/InputSelectMultiple.stories.js"; +import * as inputSelectOne from "./fields/InputSelectOne.stories.js"; +import * as inputText from "./fields/InputText.stories.js"; +import * as inputTextArea from "./fields/InputTextArea.stories.js"; +import * as inputToggle from "./fields/InputToggle.stories.js"; +import * as ganaStories from "./gana/index.stories.js"; + +export const formPrimitives = { + inputAbsoluteTime, + inputAmount, + inputArray, + inputChoiceHorizontal, + inputChoiceStacked, + inputDuration, + inputDurationText, + inputFile, + inputInteger, + inputIsoDate, + inputSecret, + inputSelectMultiple, + inputSelectOne, + inputText, + inputTextArea, + inputToggle, +}; + +export const genericForms = { + acceptTos: ganaStories.accept_tops_stories, + genericNote: ganaStories.generic_note_stories, + genericUpload: ganaStories.generic_upload_stories, + multiUpload: ganaStories.multi_upload_stories, + personalInfo: ganaStories.personal_info_stories, +}; + +export const vqfForms = { + vqf9021Customer: ganaStories.vqf_902_1_customer_stories, + vqf9021Officer: ganaStories.vqf_902_1_officer_stories, + vqf9024: ganaStories.vqf_902_4_stories, + vqf9025: ganaStories.vqf_902_5_stories, + vqf9029Customer: ganaStories.vqf_902_9_customer_stories, + vqf9029Officer: ganaStories.vqf_902_9_officer_stories, + vqf90211Customer: ganaStories.vqf_902_11_customer_stories, + vqf90211Officer: ganaStories.vqf_902_11_officer_stories, + vqf90214: ganaStories.vqf_902_14_stories, +}; diff --git a/packages/web-util/src/stories-utils.test.tsx b/packages/web-util/src/stories-utils.test.tsx @@ -69,7 +69,22 @@ function mountStories(): void { forms: { ExampleForm: { default: { title: "Example form" }, - Basic: () => <div>Mobile-compatible story</div>, + Basic: () => ( + <div> + Mobile-compatible story + <a href="data:text/plain;base64,dGVzdA==" download="sample.txt"> + Download a copy. + </a> + <a href="/next-page">Open another page</a> + </div> + ), + Alternate: () => <div>Alternate dataset</div>, + }, + }, + components: { + ExampleNotice: { + default: { title: "Example notice" }, + Secondary: () => <div>Secondary story</div>, }, }, }, @@ -126,6 +141,25 @@ test("mobile navigation behaves as an accessible slide-over", async () => { const groupButton = document.querySelector<HTMLButtonElement>( 'button[aria-controls="story-group-forms"]', )!; + const groupButtons = Array.from( + document.querySelectorAll<HTMLButtonElement>(".taler-stories-group-button"), + ); + assert.equal(groupButtons.length, 2); + assert.ok( + groupButtons.every( + (button) => button.getAttribute("aria-expanded") === "true", + ), + ); + const componentLinks = Array.from( + document.querySelectorAll<HTMLAnchorElement>( + ".taler-stories-component-link", + ), + ); + assert.deepEqual( + componentLinks.map((link) => link.textContent), + ["Example form", "Example notice"], + ); + const lastComponentLink = componentLinks.at(-1)!; await act(() => { window.dispatchEvent( new window.KeyboardEvent("keydown", { @@ -135,9 +169,8 @@ test("mobile navigation behaves as an accessible slide-over", async () => { }), ); }); - assert.equal(document.activeElement, groupButton); + assert.equal(document.activeElement, lastComponentLink); - await act(() => groupButton.click()); assert.equal(groupButton.getAttribute("aria-expanded"), "true"); await act(() => document @@ -152,11 +185,77 @@ test("mobile navigation behaves as an accessible slide-over", async () => { document.querySelector("main")!.textContent, /Mobile-compatible/, ); + const storyHeading = document.querySelector("main h1"); + assert.equal(storyHeading?.textContent, "Example form›Basic"); + assert.equal(storyHeading?.className, "taler-stories-story-title"); + assert.equal( + storyHeading?.querySelector(".taler-stories-story-dataset")?.textContent, + "Basic", + ); assert.equal(window.location.hash, "#forms-Example%20form-Basic"); assert.equal(menuButton.getAttribute("aria-expanded"), "false"); assert.equal(document.body.style.overflow, ""); assert.equal(document.activeElement, menuButton); + let alertMessage: string | undefined; + Object.defineProperty(globalThis, "alert", { + configurable: true, + writable: true, + value: (message: string) => { + alertMessage = message; + }, + }); + const downloadEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + const downloadLink = + document.querySelector<HTMLAnchorElement>("a[download]")!; + assert.equal(downloadLink.dispatchEvent(downloadEvent), true); + assert.equal(downloadEvent.defaultPrevented, false); + assert.equal(alertMessage, undefined); + + const navigationEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }); + const navigationLink = document.querySelector<HTMLAnchorElement>( + 'a[href="/next-page"]', + )!; + assert.equal(navigationLink.dispatchEvent(navigationEvent), false); + assert.equal(navigationEvent.defaultPrevented, true); + assert.equal(alertMessage, "should navigate to: /next-page"); + + const datasetSelect = document.querySelector<HTMLSelectElement>( + ".taler-stories-dataset-selector select", + )!; + const datasetPosition = document.querySelector<HTMLElement>( + ".taler-stories-dataset-position", + )!; + assert.equal(datasetSelect.value, "Basic"); + assert.equal(datasetPosition.textContent, "1/2"); + assert.equal(datasetPosition.getAttribute("aria-label"), "Dataset 1 of 2"); + assert.deepEqual( + Array.from(datasetSelect.options).map((option) => option.value), + ["Basic", "Alternate"], + ); + datasetSelect.value = "Alternate"; + datasetSelect.dispatchEvent(new Event("change", { bubbles: true })); + await eventually(() => + assert.match( + document.querySelector("main")!.textContent, + /Alternate dataset/, + ), + ); + assert.equal(storyHeading?.textContent, "Example form›Alternate"); + assert.equal( + storyHeading?.querySelector(".taler-stories-story-dataset")?.textContent, + "Alternate", + ); + assert.equal(datasetPosition.textContent, "2/2"); + assert.equal(datasetPosition.getAttribute("aria-label"), "Dataset 2 of 2"); + assert.equal(window.location.hash, "#forms-Example%20form-Alternate"); + await act(() => menuButton.click()); await eventually(() => assert.equal(document.body.style.overflow, "hidden")); await act(() => diff --git a/packages/web-util/src/stories-utils.tsx b/packages/web-util/src/stories-utils.tsx @@ -168,11 +168,14 @@ const STORY_BROWSER_STYLES = ` font-weight: 700; } - .taler-stories-group-button:hover, + .taler-stories-group-button:hover { + background: #e3e8f0; + } + .taler-stories-group-button:focus-visible { background: #e3e8f0; - outline: 2px solid #526fbd; - outline-offset: -2px; + outline: none; + box-shadow: inset 0 0 0 2px #526fbd; } .taler-stories-group-chevron { @@ -183,27 +186,14 @@ const STORY_BROWSER_STYLES = ` transform: rotate(90deg); } - .taler-stories-components, - .taler-stories-examples { + .taler-stories-components { margin: 0; padding: 0; list-style: none; - } - - .taler-stories-components { padding: 8px; } - .taler-stories-component-name { - display: block; - padding: 6px 8px 4px; - color: #596579; - font-size: 13px; - font-weight: 700; - overflow-wrap: anywhere; - } - - .taler-stories-example-link { + .taler-stories-component-link { display: flex; align-items: center; min-height: 44px; @@ -215,14 +205,14 @@ const STORY_BROWSER_STYLES = ` overflow-wrap: anywhere; } - .taler-stories-example-link:hover, - .taler-stories-example-link:focus-visible { + .taler-stories-component-link:hover, + .taler-stories-component-link:focus-visible { background: #edf1f7; outline: 2px solid #526fbd; outline-offset: -2px; } - .taler-stories-example-link[aria-current="page"] { + .taler-stories-component-link[aria-current="page"] { color: #ffffff; background: #3156a3; font-weight: 700; @@ -234,6 +224,109 @@ const STORY_BROWSER_STYLES = ` padding: clamp(16px, 3vw, 32px); } + .taler-stories-story-header { + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 1px solid #d9dee8; + } + + .taler-stories-story-title { + display: flex; + flex-wrap: wrap; + gap: 0.3em; + margin: 0; + color: #172033; + font-size: clamp(24px, 3vw, 32px); + line-height: 1.2; + overflow-wrap: anywhere; + } + + .taler-stories-story-title-separator { + color: #7a8497; + font-weight: 400; + } + + .taler-stories-story-dataset { + color: #3b455a; + font-weight: 500; + } + + .taler-stories-dataset-selector { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + color: #3b455a; + font-size: 14px; + font-weight: 600; + } + + .taler-stories-dataset-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + min-width: min(320px, 100%); + border: 1px solid #aab4c5; + border-radius: 7px; + background: #ffffff; + box-shadow: 0 1px 2px rgba(23, 32, 51, 0.08); + } + + .taler-stories-dataset-control:hover { + border-color: #6d7f9e; + } + + .taler-stories-dataset-control:focus-within { + border-color: #3156a3; + outline: 2px solid rgba(49, 86, 163, 0.25); + outline-offset: 1px; + } + + .taler-stories-dataset-selector select { + grid-column: 1 / -1; + grid-row: 1; + z-index: 1; + width: 100%; + min-height: 36px; + min-width: 0; + appearance: none; + border: 0; + border-radius: 7px; + padding: 6px 72px 6px 10px; + color: #172033; + background: transparent; + cursor: pointer; + font: inherit; + font-weight: 500; + outline: none; + } + + .taler-stories-dataset-position { + grid-column: 2; + grid-row: 1; + z-index: 2; + padding-left: 8px; + border-left: 1px solid #d9dee8; + color: #667188; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 20px; + pointer-events: none; + } + + .taler-stories-dataset-chevron { + grid-column: 3; + grid-row: 1; + z-index: 2; + width: 0; + height: 0; + margin: 0 11px 0 8px; + border-right: 5px solid transparent; + border-left: 5px solid transparent; + border-top: 6px solid #52617a; + pointer-events: none; + } + .taler-stories-backdrop { display: none; } @@ -320,6 +413,26 @@ const STORY_BROWSER_STYLES = ` min-width: 0; padding: 12px; } + + .taler-stories-story-header { + margin-bottom: 16px; + padding-bottom: 10px; + } + + .taler-stories-story-title { + font-size: 22px; + } + + .taler-stories-dataset-selector { + align-items: stretch; + flex-direction: column; + gap: 6px; + } + + .taler-stories-dataset-control { + width: 100%; + min-width: 0; + } } @media (prefers-reduced-motion: reduce) { @@ -377,6 +490,23 @@ function getContentForExample( return () => example.render.component(example.render.props); } +function getComponentForExample( + item: ExampleItem | undefined, + allExamples: Group[], +): ComponentItem | undefined { + if (!item) { + return undefined; + } + return allExamples + .find((group) => group.title === item.group) + ?.list.find((component) => component.name === item.component); +} + +function getExampleId(item: ExampleItem): string { + const encode = encodeURIComponent; + return `${encode(item.group)}-${encode(item.component)}-${encode(item.name)}`; +} + function ExampleList({ name, list, @@ -389,9 +519,9 @@ function ExampleList({ examples: ExampleItem[]; }[]; selected: ExampleItem | undefined; - onSelectStory: (i: ExampleItem, id: string) => void; + onSelectStory: (i: ExampleItem) => void; }): VNode { - const [isOpen, setOpen] = useState(selected?.group === name); + const [isOpen, setOpen] = useState(true); const groupId = `story-group-${encodeURIComponent(name)}`; useEffect(() => { @@ -415,38 +545,31 @@ function ExampleList({ </span> </button> <ul id={groupId} class="taler-stories-components" hidden={!isOpen}> - {list.map((k) => ( - <li key={k.name}> - <span class="taler-stories-component-name">{k.name}</span> - <ul class="taler-stories-examples"> - {k.examples.map((r) => { - const e = encodeURIComponent; - const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; - const isSelected = - selected && - selected.component === r.component && - selected.group === r.group && - selected.name === r.name; - return ( - <li id={eId} key={r.name}> - <a - class="taler-stories-example-link" - href={`#${eId}`} - aria-current={isSelected ? "page" : undefined} - onClick={(e) => { - e.preventDefault(); - history.pushState({}, "", `#${eId}`); - onSelectStory(r, eId); - }} - > - {r.name} - </a> - </li> - ); - })} - </ul> - </li> - ))} + {list.flatMap((component) => { + const firstDataset = component.examples[0]; + if (!firstDataset) { + return []; + } + const exampleId = getExampleId(firstDataset); + const isSelected = + selected?.group === firstDataset.group && + selected.component === firstDataset.component; + return [ + <li id={exampleId} key={component.name}> + <a + class="taler-stories-component-link" + href={`#${exampleId}`} + aria-current={isSelected ? "page" : undefined} + onClick={(event) => { + event.preventDefault(); + onSelectStory(firstDataset); + }} + > + {component.name} + </a> + </li>, + ]; + })} </ul> </section> ); @@ -475,7 +598,12 @@ function PreventLinkNavigation({ } const link = event.target.closest<HTMLAnchorElement>("a[href]"); const href = link?.getAttribute("href"); - if (!link || !href || !container.contains(link)) { + if ( + !link || + !href || + !container.contains(link) || + link.hasAttribute("download") + ) { return; } alert(`should navigate to: ${href}`); @@ -807,9 +935,21 @@ function Application({ const GroupWrapper = getWrapperForGroup(selected?.group || "default"); const ExampleContent = getContentForExample(selected, examplesInGroups); + const selectedComponent = getComponentForExample(selected, examplesInGroups); + const datasetCount = selectedComponent?.examples.length ?? 0; + const datasetIndex = + selectedComponent?.examples.findIndex( + (dataset) => dataset.name === selected?.name, + ) ?? -1; const selectedLabel = selected ? `${selected.component} / ${selected.name}` : "Select a story"; + const selectStory = (item: ExampleItem): void => { + const exampleId = getExampleId(item); + history.pushState({}, "", `#${exampleId}`); + updateSelected(item); + setNavigationOpen(false); + }; return ( <Fragment> @@ -883,13 +1023,7 @@ function Application({ name={group.title} list={group.list} selected={selected} - onSelectStory={(item, htmlId) => { - document.getElementById(htmlId)?.scrollIntoView({ - block: "center", - }); - updateSelected(item); - setNavigationOpen(false); - }} + onSelectStory={selectStory} /> ))} </nav> @@ -899,6 +1033,53 @@ function Application({ class="taler-stories-content" aria-hidden={isMobile && navigationOpen ? "true" : undefined} > + {selected && ( + <header class="taler-stories-story-header"> + <h1 class="taler-stories-story-title"> + <span>{selected.component}</span> + <span + class="taler-stories-story-title-separator" + aria-hidden="true" + > + › + </span> + <span class="taler-stories-story-dataset">{selected.name}</span> + </h1> + <label class="taler-stories-dataset-selector"> + <span>Dataset</span> + <span class="taler-stories-dataset-control"> + <select + value={selected.name} + onChange={(event) => { + const dataset = selectedComponent?.examples.find( + (example) => example.name === event.currentTarget.value, + ); + if (dataset) { + selectStory(dataset); + } + }} + > + {selectedComponent?.examples.map((dataset) => ( + <option key={dataset.name} value={dataset.name}> + {dataset.name} + </option> + ))} + </select> + <span + class="taler-stories-dataset-position" + aria-live="polite" + aria-label={`Dataset ${datasetIndex + 1} of ${datasetCount}`} + > + {datasetIndex + 1}/{datasetCount} + </span> + <span + class="taler-stories-dataset-chevron" + aria-hidden="true" + /> + </span> + </label> + </header> + )} <ErrorReport selected={selected}> <PreventLinkNavigation> <GroupWrapper> diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx @@ -22,13 +22,20 @@ /** * Imports. */ -import * as forms from "./forms/index.stories.js"; -import * as hooks from "./hooks/index.stories.js"; +import { + formPrimitives, + genericForms, + vqfForms, +} from "./forms/index.stories.js"; import { renderStories } from "./stories-utils.js"; function main(): void { renderStories( - { forms, hooks }, + { + "form primitives": formPrimitives, + "generic forms": genericForms, + "VQF forms": vqfForms, + }, { strings: {}, },