commit 912f4894e24f02cb4f90a5e584b9035f28de6402
parent a2b410c5a31dc054b99b278732035f083b75e849
Author: Florian Dold <dold@taler.net>
Date: Sun, 23 Aug 2026 16:52:47 +0200
web-util: improve list field editing
Diffstat:
4 files changed, 477 insertions(+), 199 deletions(-)
diff --git a/packages/web-util/src/forms/fields/InputArray.test.tsx b/packages/web-util/src/forms/fields/InputArray.test.tsx
@@ -0,0 +1,238 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { 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() {
+ const window = new Window({ url: "https://kyc.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,
+ HTMLButtonElement: window.HTMLButtonElement,
+ 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: "array",
+ id: "people",
+ label: "People" as TranslatedString,
+ labelFieldId: "name",
+ validator(value) {
+ return value?.length
+ ? undefined
+ : ("Can't be empty" as TranslatedString);
+ },
+ fields: [
+ {
+ type: "text",
+ id: "name",
+ label: "Name" as TranslatedString,
+ required: true,
+ },
+ {
+ type: "textArea",
+ id: "address",
+ label: "Address" as TranslatedString,
+ required: true,
+ },
+ ],
+ },
+ ],
+};
+
+test("array items can be added, edited, cancelled, and deleted", async () => {
+ const window = installDom();
+ const { cleanup, render } = await import("@testing-library/preact");
+ let result: Record<string, any> = {};
+
+ function Harness() {
+ const form = useForm<Record<string, any>>(design, {});
+ result = form.status.result;
+ return <FormUI design={design} model={form.model} />;
+ }
+
+ const view = render(<Harness />);
+ assert.equal(view.queryAllByRole("radio").length, 0);
+ const originalAddButton = view.getByRole("button", { name: "Add item" });
+ originalAddButton.click();
+ await view.findByRole("button", { name: "Cancel" });
+
+ const nameInput =
+ view.container.querySelector<HTMLInputElement>('input[name="name"]');
+ const addressInput = view.container.querySelector<HTMLTextAreaElement>(
+ 'textarea[name="address"]',
+ );
+ assert.ok(nameInput);
+ assert.ok(addressInput);
+ assert.equal(view.getByRole("textbox", { name: "Name" }) === nameInput, true);
+ assert.equal(
+ view.getByRole("textbox", { name: "Address" }) === addressInput,
+ true,
+ );
+
+ nameInput.value = "Alex Example";
+ nameInput.dispatchEvent(new window.Event("change", { bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ addressInput.value = "Main Street 12, 8000 Zürich";
+ addressInput.dispatchEvent(new window.Event("change", { bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ let confirmAddButton: HTMLElement | undefined;
+ await eventually(() => {
+ const addButtons = view.getAllByRole("button", { name: "Add item" });
+ confirmAddButton = addButtons.find(
+ (button: HTMLElement) => !(button as HTMLButtonElement).disabled,
+ );
+ assert.ok(confirmAddButton);
+ });
+ assert.ok(confirmAddButton);
+ confirmAddButton.click();
+
+ await eventually(() =>
+ assert.deepEqual(result.people, [
+ {
+ name: "Alex Example",
+ address: "Main Street 12, 8000 Zürich",
+ },
+ ]),
+ );
+
+ await eventually(() =>
+ assert.equal(
+ (view.getByRole("button", { name: "Add item" }) as HTMLButtonElement)
+ .disabled,
+ false,
+ ),
+ );
+ view.getByRole("button", { name: "Add item" }).click();
+ await view.findByRole("button", { name: "Cancel" });
+ const secondNameInput =
+ view.container.querySelector<HTMLInputElement>('input[name="name"]');
+ assert.ok(secondNameInput);
+ secondNameInput.value = "Discarded";
+ secondNameInput.dispatchEvent(new window.Event("change", { bubbles: true }));
+ view.getByRole("button", { name: "Cancel" }).click();
+ await eventually(() => assert.equal(result.people.length, 1));
+ await eventually(() =>
+ assert.equal(
+ (
+ view.getByRole("button", {
+ name: "Edit Alex Example",
+ }) as HTMLButtonElement
+ ).disabled,
+ false,
+ ),
+ );
+
+ const editButton = view.getByRole("button", {
+ name: "Edit Alex Example",
+ });
+ editButton.click();
+ await view.findByRole("button", { name: "Save changes" });
+ const editNameInput =
+ view.container.querySelector<HTMLInputElement>('input[name="name"]');
+ assert.ok(editNameInput);
+ assert.equal(editNameInput.value, "Alex Example");
+ editNameInput.value = "Alex Updated";
+ editNameInput.dispatchEvent(new window.Event("change", { bubbles: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ view.getByRole("button", { name: "Save changes" }).click();
+ await eventually(() => assert.equal(result.people[0].name, "Alex Updated"));
+ await eventually(() =>
+ assert.equal(
+ (
+ view.getByRole("button", {
+ name: "Edit Alex Updated",
+ }) as HTMLButtonElement
+ ).disabled,
+ false,
+ ),
+ );
+
+ view.getByRole("button", { name: "Edit Alex Updated" }).click();
+ (await view.findByRole("button", { name: "Delete" })).click();
+ assert.equal(
+ (await view.findByRole("alert")).textContent?.includes("Delete this item?"),
+ true,
+ );
+ view.getByRole("button", { name: "Keep item" }).click();
+ assert.ok(await view.findByRole("button", { name: "Save changes" }));
+
+ view.getByRole("button", { name: "Delete" }).click();
+ (await view.findByRole("button", { name: "Delete item" })).click();
+ await eventually(() => assert.deepEqual(result.people, []));
+
+ cleanup();
+ await window.happyDOM.abort();
+});
+
+test("array validation and disabled presentation remain available", async () => {
+ const window = installDom();
+ const { cleanup, render } = await import("@testing-library/preact");
+
+ function EmptyHarness() {
+ const form = useForm<Record<string, any>>(design, {});
+ return <FormUI design={design} model={form.model} />;
+ }
+
+ const emptyView = render(<EmptyHarness />);
+ emptyView.getByRole("button", { name: "Add item" }).click();
+ (await emptyView.findByRole("button", { name: "Cancel" })).click();
+ assert.equal(
+ (await emptyView.findByRole("alert")).textContent,
+ "Can't be empty",
+ );
+ cleanup();
+
+ function DisabledHarness() {
+ const form = useForm<Record<string, any>>(design, {
+ people: [{ name: "Alex Example", address: "Example address" }],
+ } as any);
+ return <FormUI design={design} model={form.model} disabled />;
+ }
+
+ const disabledView = render(<DisabledHarness />);
+ assert.ok(disabledView.getByText("Alex Example"));
+ assert.equal(disabledView.queryAllByRole("button").length, 0);
+
+ cleanup();
+ await window.happyDOM.abort();
+});
diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx
@@ -1,15 +1,16 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { Fragment, h, VNode } from "preact";
-import { useId, useMemo, useState } from "preact/hooks";
+import { useEffect, useId, useMemo, useRef, useState } from "preact/hooks";
import {
getValueFromPath,
RecursivePartial,
useForm,
} from "../../hooks/useForm.js";
import { useTranslationContext } from "../../context/translation.js";
-import { SingleColumnFormSectionUI } from "../forms-ui.js";
+import { RenderAllFieldsByUiConfig } from "../forms-ui.js";
import { UIFormProps } from "../FormProvider.js";
import { UIFormElementConfig } from "../forms-types.js";
+import { convertFormConfigToUiField } from "../forms-utils.js";
import { LabelWithTooltipMaybeRequired } from "./InputLine.js";
export function noHandlerPropsAndNoContextForField(
@@ -21,6 +22,7 @@ export function noHandlerPropsAndNoContextForField(
}
type FormType = {};
+type Editor = { type: "add" } | { type: "edit"; index: number };
const EMPTY_FORM = {};
function ArrayForm({
@@ -29,14 +31,14 @@ function ArrayForm({
onClose,
onRemove,
onConfirm,
- name,
+ isNew,
}: {
fields: UIFormElementConfig[];
- selected: Record<string, string | undefined> | undefined;
+ selected: Record<string, unknown> | undefined;
onClose: () => void;
onRemove: () => void;
onConfirm: (r: RecursivePartial<FormType>) => void;
- name: string;
+ isNew: boolean;
}): VNode {
const { i18n } = useTranslationContext();
const design = useMemo(
@@ -44,55 +46,84 @@ function ArrayForm({
[fields],
);
const form = useForm<FormType>(design, selected ?? EMPTY_FORM);
+ const editorRef = useRef<HTMLDivElement>(null);
+ const [confirmingRemoval, setConfirmingRemoval] = useState(false);
+
+ useEffect(() => {
+ editorRef.current
+ ?.querySelector<HTMLElement>(
+ "input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])",
+ )
+ ?.focus();
+ }, []);
return (
- <div class="px-4 py-6">
- <div class="grid grid-cols-1 gap-y-8 ">
- <SingleColumnFormSectionUI
- fields={fields}
- model={form.model}
- name={name}
+ <div ref={editorRef} class="border-t border-gray-200 px-3 py-4 sm:px-4">
+ <div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
+ <RenderAllFieldsByUiConfig
+ fields={convertFormConfigToUiField(i18n, "root", fields, form.model)}
/>
</div>
- {/* <pre>{JSON.stringify(form.status, undefined, 2)}</pre> */}
- <div class="flex items-center justify-end gap-x-6 mt-4">
- <button
- type="button"
- onClick={onClose}
- class="block px-3 py-2 text-sm font-semibold leading-6 text-gray-900"
+ {confirmingRemoval ? (
+ <div
+ class="mt-5 rounded-md border border-red-200 bg-red-50 p-3"
+ role="alert"
>
- <i18n.Translate>Cancel</i18n.Translate>
- </button>
+ <p class="text-sm font-medium text-red-800">
+ <i18n.Translate>Delete this item?</i18n.Translate>
+ </p>
+ <div class="mt-3 flex flex-wrap items-center justify-end gap-3">
+ <button
+ type="button"
+ onClick={() => setConfirmingRemoval(false)}
+ class="rounded-md px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Keep item</i18n.Translate>
+ </button>
+ <button
+ type="button"
+ onClick={onRemove}
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ >
+ <i18n.Translate>Delete item</i18n.Translate>
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div class="mt-5 flex flex-wrap items-center justify-end gap-3">
+ <button
+ type="button"
+ onClick={onClose}
+ class="rounded-md px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
- <button
- type="button"
- disabled={selected === undefined}
- onClick={() => {
- onRemove();
- }}
- // onClick={() => {
- // const newValue = [...list];
- // newValue.splice(selectedIndex, 1);
- // onChange(newValue as any);
- // // setSelectedIndex(undefined);
- // }}
- class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"
- >
- <i18n.Translate>Remove</i18n.Translate>
- </button>
+ {!isNew && (
+ <button
+ type="button"
+ onClick={() => setConfirmingRemoval(true)}
+ class="rounded-md px-3 py-2 text-sm font-semibold text-red-700 hover:bg-red-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ )}
- <button
- type="button"
- disabled={form.status.status !== "ok"}
- onClick={() => {
- onConfirm(form.status.result);
- }}
- class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-indigo-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"
- >
- <i18n.Translate>Confirm</i18n.Translate>
- </button>
- </div>
+ <button
+ type="button"
+ disabled={form.status.status !== "ok"}
+ onClick={() => onConfirm(form.status.result)}
+ class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 disabled:shadow-none"
+ >
+ {isNew ? (
+ <i18n.Translate>Add item</i18n.Translate>
+ ) : (
+ <i18n.Translate>Save changes</i18n.Translate>
+ )}
+ </button>
+ </div>
+ )}
</div>
);
}
@@ -108,20 +139,87 @@ export function InputArray(
const { value, onChange, error } =
props.handler ?? noHandlerPropsAndNoContextForField(props.name);
- const [dirty, setDirty] = useState<boolean>(); // FIXME: dirty state should come from handler
+ const [dirty, setDirty] = useState(false);
+ const [editor, setEditor] = useState<Editor | undefined>();
+ const returnFocusRef = useRef<HTMLButtonElement | null>(null);
+ const generatedId = useId();
+ const fieldId = `array-${generatedId}`;
+ const labelId = `${fieldId}-label`;
+ const helpId = `${fieldId}-help`;
+ const errorId = `${fieldId}-error`;
- //@ts-ignore
- const list = (value ?? []) as Array<Record<string, string>>;
- const [selectedIndex, setSelectedIndex] = useState<number | undefined>(
- undefined,
- );
- const optionGroup = `array-${useId()}`;
+ const list = (value ?? []) as unknown as Array<Record<string, unknown>>;
+
+ useEffect(() => {
+ if (editor?.type === "edit" && editor.index >= list.length) {
+ setEditor(undefined);
+ }
+ }, [editor, list.length]);
if (hidden) {
return <Fragment />;
}
- const selected =
- selectedIndex === undefined ? undefined : list[selectedIndex];
+
+ const describedBy = [
+ help ? helpId : undefined,
+ dirty && error ? errorId : undefined,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ function closeEditor(): void {
+ setDirty(true);
+ setEditor(undefined);
+ setTimeout(() => returnFocusRef.current?.focus(), 0);
+ }
+
+ function labelForItem(item: Record<string, unknown>, index: number) {
+ const labelValue = getValueFromPath(item, labelField.split("."));
+ if (Array.isArray(labelValue)) {
+ return labelValue.length > 0
+ ? labelValue.join(", ")
+ : i18n.str`Item ${index + 1}`;
+ }
+ if (labelValue === undefined || labelValue === null || labelValue === "") {
+ return i18n.str`Item ${index + 1}`;
+ }
+ return String(labelValue) as TranslatedString;
+ }
+
+ function renderEditor(selected: Record<string, unknown> | undefined) {
+ if (!editor || props.disabled) return undefined;
+ const isNew = editor.type === "add";
+ return (
+ <ArrayForm
+ key={isNew ? "add" : `edit-${editor.index}`}
+ fields={fields}
+ isNew={isNew}
+ selected={selected}
+ onRemove={() => {
+ if (editor.type !== "edit") return;
+ const newValue = [...list];
+ newValue.splice(editor.index, 1);
+ onChange(newValue as any);
+ closeEditor();
+ }}
+ onClose={closeEditor}
+ onConfirm={(newItem) => {
+ const newValue = [...list];
+ if (editor.type === "add") {
+ newValue.push(newItem as Record<string, unknown>);
+ } else {
+ newValue.splice(
+ editor.index,
+ 1,
+ newItem as Record<string, unknown>,
+ );
+ }
+ onChange(newValue as any);
+ closeEditor();
+ }}
+ />
+ );
+ }
return (
<div class="sm:col-span-6">
@@ -129,159 +227,77 @@ export function InputArray(
label={label}
required={required}
tooltip={tooltip}
- name={String(props.name)}
+ labelId={labelId}
/>
{help && (
- <p class="mt-2 text-sm text-gray-500" id="email-description">
+ <p class="mt-2 text-sm text-gray-500" id={helpId}>
{help}
</p>
)}
- {dirty !== undefined && error && (
- <p class="mt-2 text-sm text-red-600" id="email-error">
+ {dirty && error && (
+ <p class="mt-2 text-sm text-red-600" id={errorId} role="alert">
{error}
</p>
)}
- <div class="overflow-visible ring-1 ring-gray-900/5 rounded-xl p-4">
- <div class="-space-y-px rounded-md bg-white ">
- {list.map((v, idx) => {
- const labelValue =
- getValueFromPath(v, labelField.split(".")) ??
- `<<Item ${idx + 1}>>`;
- const label = Array.isArray(labelValue)
- ? labelValue.join(", ")
- : labelValue;
- return (
- <Option
- id={`${optionGroup}-${idx}`}
- groupName={optionGroup}
- label={label as TranslatedString}
- key={idx}
- isSelected={selectedIndex === idx}
- isLast={idx === list.length - 1}
- disabled={
- props.disabled ||
- (selectedIndex !== undefined && selectedIndex !== idx)
- }
- isFirst={idx === 0}
- onClick={() => {
- setSelectedIndex(selectedIndex === idx ? undefined : idx);
- }}
- />
- );
- })}
- {!props.disabled && (
- <div class="pt-2">
- <Option
- id={`${optionGroup}-new`}
- groupName={optionGroup}
- label={i18n.str`Add new...`}
- isSelected={selectedIndex === list.length}
- isLast
- isFirst
- disabled={
- selectedIndex !== undefined && selectedIndex !== list.length
- }
- onClick={() => {
- setSelectedIndex(
- selectedIndex === list.length ? undefined : list.length,
- );
- }}
- />
- </div>
- )}
- </div>
- {!props.disabled && selectedIndex !== undefined && (
- <ArrayForm
- name={props.name as string}
- fields={fields}
- onRemove={() => {
- const newValue = [...list];
- newValue.splice(selectedIndex, 1);
- onChange(newValue as any);
- setDirty(true);
- setSelectedIndex(undefined);
- }}
- onClose={() => {
- setDirty(true);
- setSelectedIndex(undefined);
- }}
- onConfirm={(value) => {
- const newValue = [...list];
- newValue.splice(selectedIndex, 1, value);
- onChange(newValue as any);
- setDirty(true);
- setSelectedIndex(undefined);
- }}
- selected={selected}
- />
+ <div
+ class="mt-2 overflow-hidden rounded-lg border border-gray-200 bg-white"
+ role="group"
+ aria-labelledby={labelId}
+ aria-describedby={describedBy || undefined}
+ >
+ {list.length > 0 && (
+ <ul class="divide-y divide-gray-200">
+ {list.map((item, index) => {
+ const itemLabel = labelForItem(item, index);
+ const isEditing =
+ editor?.type === "edit" && editor.index === index;
+ return (
+ <li key={index} class={isEditing ? "bg-indigo-50/50" : ""}>
+ <div class="flex min-h-14 items-center justify-between gap-3 px-3 py-2 sm:px-4">
+ <span class="min-w-0 break-words text-sm font-medium text-gray-900">
+ {itemLabel}
+ </span>
+ {!props.disabled && (
+ <button
+ type="button"
+ disabled={editor !== undefined}
+ aria-label={i18n.str`Edit ${itemLabel}`}
+ onClick={(event) => {
+ returnFocusRef.current = event.currentTarget;
+ setEditor({ type: "edit", index });
+ }}
+ class="shrink-0 rounded-md px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:text-gray-400"
+ >
+ <i18n.Translate>Edit</i18n.Translate>
+ </button>
+ )}
+ </div>
+ {isEditing ? renderEditor(item) : undefined}
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ {!props.disabled && (
+ <div class={list.length > 0 ? "border-t border-gray-200 p-3" : "p-3"}>
+ <button
+ id={`${fieldId}-add`}
+ type="button"
+ disabled={editor !== undefined}
+ onClick={(event) => {
+ returnFocusRef.current = event.currentTarget;
+ setEditor({ type: "add" });
+ }}
+ class="w-full rounded-md border border-dashed border-gray-300 px-3 py-2.5 text-sm font-semibold text-indigo-700 hover:border-indigo-300 hover:bg-indigo-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400"
+ >
+ <i18n.Translate>Add item</i18n.Translate>
+ </button>
+ {editor?.type === "add" ? renderEditor(undefined) : undefined}
+ </div>
)}
</div>
</div>
);
}
-
-function Option({
- id,
- groupName,
- label,
- disabled,
- isFirst,
- isLast,
- isSelected,
- onClick,
-}: {
- id: string;
- groupName: string;
- label: TranslatedString;
- isFirst?: boolean;
- isLast?: boolean;
- isSelected?: boolean;
- disabled?: boolean;
- onClick: () => void;
-}): VNode {
- let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey";
- if (isFirst) {
- clazz += " rounded-tl-md rounded-tr-md ";
- }
- if (isLast) {
- clazz += " rounded-bl-md rounded-br-md ";
- }
- if (isSelected) {
- clazz += " z-10 border-indigo-200 bg-indigo-50 ";
- } else {
- clazz += " border-gray-200";
- }
- if (disabled) {
- clazz +=
- " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray";
- } else {
- clazz += " cursor-pointer";
- }
- return (
- <label class={clazz} for={id}>
- <input
- id={id}
- type="radio"
- name={groupName}
- checked={isSelected}
- disabled={disabled}
- onClick={onClick}
- class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600"
- aria-labelledby={`${id}-label`}
- />
- <span class="ml-3 flex flex-col">
- <span id={`${id}-label`} disabled class="block text-sm font-medium">
- {label}
- </span>
- {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */}
- {/* <span
- id="privacy-setting-0-description"
- class="block text-sm"
- >
- This project would be available to anyone who has the link
- </span> */}
- </span>
- </label>
- );
-}
diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx
@@ -1,6 +1,6 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { ComponentChildren, Fragment, VNode, h } from "preact";
-import { useEffect, useRef } from "preact/hooks";
+import { useEffect, useId, useRef } from "preact/hooks";
import { composeRef, saveRef } from "../../components/utils.js";
import { Addon, UIFormProps } from "../FormProvider.js";
import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
@@ -26,15 +26,18 @@ export function LabelWithTooltipMaybeRequired({
required,
tooltip,
name,
+ labelId,
}: {
label: TranslatedString;
required?: boolean;
tooltip?: TranslatedString;
name?: string;
+ labelId?: string;
}): VNode {
const Label = (
<div class="flex justify-between">
<label
+ id={labelId}
for={name}
class="block text-sm font-medium leading-6 text-gray-900"
>
@@ -181,6 +184,7 @@ export function InputLine<Value = string>(
hidden,
} = props;
const input = useRef<HTMLTextAreaElement | HTMLInputElement>();
+ const inputId = `input-${useId()}`;
const { value, onChange, error } =
props.handler ?? noHandlerPropsAndNoContextForField(props.name);
@@ -248,11 +252,13 @@ export function InputLine<Value = string>(
return (
<InputWrapper
{...props}
+ name={inputId}
help={props.help}
disabled={disabled ?? false}
error={showError ? error : undefined}
>
<textarea
+ id={inputId}
rows={4}
ref={composeRef(saveRef(input))}
// ref={composeRef(saveRef(input), doAutoFocus)} FIXME: enable autofocus when form has focus
@@ -276,11 +282,13 @@ export function InputLine<Value = string>(
return (
<InputWrapper
{...props}
+ name={inputId}
help={props.help}
disabled={disabled ?? false}
error={showError ? error : undefined}
>
<input
+ id={inputId}
name={String(name)}
ref={composeRef(saveRef(input))}
// ref={composeRef(saveRef(input), doAutoFocus)} FIXME: enable autofocus when form has focus
diff --git a/packages/web-util/src/forms/gana/VQF_902_11_customer.stories.tsx b/packages/web-util/src/forms/gana/VQF_902_11_customer.stories.tsx
@@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
-import { i18n, setupI18n } from "@gnu-taler/taler-util";
+import { i18n, setupI18n, TalerFormAttributes } from "@gnu-taler/taler-util";
import * as tests from "../../tests/hook.js";
import { DefaultForm as TestedComponent } from "../forms-ui.js";
import { VQF_902_11_customer } from "./VQF_902_11_customer.js";
@@ -33,3 +33,19 @@ export const EmptyForm = tests.createExample(TestedComponent, {
initial: {},
design: VQF_902_11_customer(i18n),
});
+
+export const PrefilledForm = tests.createExample(TestedComponent, {
+ initial: {
+ [TalerFormAttributes.IDENTITY_LIST]: [
+ {
+ [TalerFormAttributes.FULL_NAME]: "Alex Example",
+ [TalerFormAttributes.DOMICILE_ADDRESS]: "Main Street 12, 8000 Zürich",
+ },
+ {
+ [TalerFormAttributes.FULL_NAME]: "Sam Sample",
+ [TalerFormAttributes.DOMICILE_ADDRESS]: "Market Square 4, 3011 Bern",
+ },
+ ],
+ },
+ design: VQF_902_11_customer(i18n),
+});