taler-typescript-core

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

commit b90c8b01c375c656426d13d241e80263d2fe03ca
parent 8dcc6a041e36e43d9b34c4eb3f881a77a64f4a55
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 14:23:32 +0200

web-util: support exclusive multi-select choices

Diffstat:
Mpackages/web-util/src/forms/fields/InputChoiceStacked.tsx | 3+++
Mpackages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx | 18++++++++++++++++++
Apackages/web-util/src/forms/fields/InputSelectMultiple.test.tsx | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/fields/InputSelectMultiple.tsx | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/web-util/src/forms/forms-types.ts | 6++++++
5 files changed, 349 insertions(+), 92 deletions(-)

diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx @@ -14,6 +14,9 @@ import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; export interface ChoiceS<V> { label: TranslatedString; description?: TranslatedString; + disabled?: boolean; + disabledReason?: TranslatedString; + exclusiveGroup?: string; value: V; } diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx @@ -58,16 +58,34 @@ const design: FormDesign = { choices: [ { label: "one label" as TranslatedString, + description: + "Descriptions are rendered and included in search" as TranslatedString, value: "one", }, { label: "two label" as TranslatedString, + description: "First exclusive option" as TranslatedString, + exclusiveGroup: "exclusive-example", + disabledReason: + "Only one exclusive option can be selected" as TranslatedString, value: "two", }, { label: "five label" as TranslatedString, + description: "Second exclusive option" as TranslatedString, + exclusiveGroup: "exclusive-example", + disabledReason: + "Only one exclusive option can be selected" as TranslatedString, value: "five", }, + { + label: "disabled label" as TranslatedString, + description: + "An unavailable choice remains visible" as TranslatedString, + disabled: true, + disabledReason: "Disabled for this example" as TranslatedString, + value: "disabled", + }, ], }, { diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.test.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.test.tsx @@ -0,0 +1,135 @@ +/* + 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. +*/ +import { setupI18n, TranslatedString } from "@gnu-taler/taler-util"; +import { Window } from "happy-dom"; +import assert from "node:assert/strict"; +import test from "node:test"; +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, + 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: "selectMultiple", + id: "measures", + label: "Immediate measures" as TranslatedString, + unique: true, + choices: [ + { + label: "Instant A", + description: "Freeze the account immediately", + value: "instant-a", + exclusiveGroup: "instant", + disabledReason: "Only one instant measure can be selected", + }, + { + label: "Instant B", + description: "Escalate the account immediately", + value: "instant-b", + exclusiveGroup: "instant", + disabledReason: "Only one instant measure can be selected", + }, + { + label: "Customer form", + description: "Collect a recent utility bill", + value: "form", + }, + ], + }, + ], +}; + +test("multi-select searches descriptions and enforces exclusive groups", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + let result: { measures?: string[] } = {}; + + function Harness() { + const form = useForm<{ measures: string[] }>(design, { measures: [] }); + result = form.status.result; + return <FormUI design={design} model={form.model} />; + } + + const view = render(<Harness />); + const input = view.getByRole("combobox") as HTMLInputElement; + input.focus(); + input.value = "utility"; + input.dispatchEvent(new Event("change", { bubbles: true })); + await eventually(() => { + const options = view.getAllByRole("option"); + assert.equal(options.length, 1); + assert.match(options[0].textContent ?? "", /Customer form/); + assert.equal(options[0].classList.contains("w-full"), true); + }); + + input.value = "Instant A"; + input.dispatchEvent(new Event("change", { bubbles: true })); + const firstInstant = await view.findByRole("option", { name: /Instant A/ }); + (firstInstant as HTMLButtonElement).click(); + await eventually(() => assert.deepEqual(result.measures, ["instant-a"])); + + input.click(); + await eventually(() => { + const secondInstant = view.getByRole("option", { name: /Instant B/ }); + assert.equal(secondInstant.getAttribute("aria-disabled"), "true"); + assert.equal((secondInstant as HTMLButtonElement).disabled, true); + assert.match( + secondInstant.textContent ?? "", + /Only one instant measure can be selected/, + ); + const customerForm = view.getByRole("option", { name: /Customer form/ }); + assert.equal(customerForm.getAttribute("aria-disabled"), "false"); + }); + + cleanup(); + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx @@ -1,3 +1,13 @@ +/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role -- ARIA combobox popups use listbox semantics while the input retains keyboard focus. */ +/* + This file is part of GNU Taler + (C) 2022-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. +*/ + import { Fragment, VNode, h } from "preact"; import { useId, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; @@ -6,9 +16,6 @@ import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { ChoiceS } from "./InputChoiceStacked.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -/** - * @type ChoiceVal result type of the choice (for example: "choiceA" | "choiceB") - */ export function InputSelectMultiple<ChoiceVal>( props: { choices: ChoiceS<ChoiceVal>[]; @@ -32,30 +39,62 @@ export function InputSelectMultiple<ChoiceVal>( props.handler ?? noHandlerPropsAndNoContextForField(props.name); const [filter, setFilter] = useState<string | undefined>(undefined); + const [activeIndex, setActiveIndex] = useState(0); const [dirty, setDirty] = useState<boolean>(); const id = `select-many-${useId()}`; const inputRef = useRef<HTMLInputElement>(null); - - if (hidden) { - return <Fragment />; - } const normalizedFilter = (filter ?? "").toLocaleLowerCase(); - const choiceMap = choices.reduce( - (prev, curr) => { - return { ...prev, [curr.value as string]: curr.label }; - }, - {} as Record<string, string>, + const list = (value ?? []) as string[]; + const choiceMap = new Map( + choices.map((choice) => [String(choice.value), choice]), ); - const list = (value ?? []) as string[]; + if (hidden) return <Fragment />; + + const groupAlreadySelected = (choice: ChoiceS<ChoiceVal>): boolean => + !!choice.exclusiveGroup && + list.some((selectedValue) => { + const selected = choiceMap.get(String(selectedValue)); + return ( + selected !== undefined && + selected.exclusiveGroup === choice.exclusiveGroup && + String(selected.value) !== String(choice.value) + ); + }); + + const unavailableReason = (choice: ChoiceS<ChoiceVal>) => { + if (choice.disabled || groupAlreadySelected(choice)) { + return choice.disabledReason ?? i18n.str`This option is not available.`; + } + if (max !== undefined && list.length >= max) { + return i18n.str`The maximum number of selections has been reached.`; + } + return undefined; + }; + const filteredChoices = filter === undefined ? undefined - : choices.filter((v) => { - const match = v.label.toLocaleLowerCase().includes(normalizedFilter); + : choices.filter((choice) => { + const match = [choice.label, choice.description].some((text) => + text?.toLocaleLowerCase().includes(normalizedFilter), + ); if (!unique) return match; - return match && list.indexOf(v.value as string) === -1; + return match && !list.includes(String(choice.value)); }); + const noItems = + filter === undefined + ? undefined + : filteredChoices === undefined || !filteredChoices.length; + + const choose = (choice: ChoiceS<ChoiceVal>) => { + if (unavailableReason(choice)) return; + if (unique && list.includes(String(choice.value))) return; + onChange([...list, choice.value] as any); + setFilter(undefined); + setDirty(true); + }; + return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -63,10 +102,10 @@ export function InputSelectMultiple<ChoiceVal>( technicalName={props.technicalName} required={required} tooltip={tooltip} - name={props.name as string} + name={id} /> - {!props.disabled && ( + {!props.disabled ? ( <div class="relative mt-2"> <input ref={inputRef} @@ -74,38 +113,68 @@ export function InputSelectMultiple<ChoiceVal>( type="text" value={filter ?? ""} autoComplete="off" - onChange={(e) => { - setFilter(e.currentTarget.value); + onChange={(event) => { + setFilter(event.currentTarget.value); + setActiveIndex(0); setDirty(true); }} - onBlur={(e) => { - setFilter(undefined); - }} - onFocus={(e) => { - setFilter(""); - }} - onClick={(e) => { - setFilter(""); + onKeyDown={(event) => { + if (!filteredChoices?.length) return; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setActiveIndex((current) => + Math.min(current + 1, filteredChoices.length - 1), + ); + break; + case "ArrowUp": + event.preventDefault(); + setActiveIndex((current) => Math.max(current - 1, 0)); + break; + case "Enter": + event.preventDefault(); + choose(filteredChoices[activeIndex]); + break; + case "Escape": + event.preventDefault(); + setFilter(undefined); + break; + } }} + onBlur={() => setFilter(undefined)} + onFocus={() => setFilter("")} + onClick={() => setFilter("")} placeholder={placeholder} - class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 dark:bg-gray-950 dark:text-gray-100 dark:ring-gray-600 sm:text-sm sm:leading-6" role="combobox" aria-controls={`${id}-options`} - aria-expanded="false" + aria-expanded={filter !== undefined} + aria-autocomplete="list" + aria-activedescendant={ + filteredChoices?.length + ? `${id}-option-${activeIndex}` + : undefined + } + aria-describedby={ + [ + help ? `${id}-description` : undefined, + error ? `${id}-error` : undefined, + ] + .filter(Boolean) + .join(" ") || undefined + } /> <button type="button" - disabled={props.disabled} - onMouseDown={(e) => { - // Input element should not lose focus - e.preventDefault(); - }} - onClick={(e) => { + onMouseDown={(event) => event.preventDefault()} + onClick={() => { setFilter(filter === undefined ? "" : undefined); + setActiveIndex(0); setDirty(true); inputRef.current?.focus(); }} - class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-600" + aria-label={i18n.str`Toggle choices`} > <svg class="h-5 w-5 text-gray-400" @@ -121,97 +190,123 @@ export function InputSelectMultiple<ChoiceVal>( </svg> </button> - {filter === undefined ? undefined : filteredChoices === undefined || - !filteredChoices.length ? ( + {noItems ? ( <ul - class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 dark:bg-gray-950 dark:ring-gray-700 sm:text-sm" id={`${id}-options`} role="listbox" > - <li class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"> - <span class="block truncate font-bold"> - <i18n.Translate>No element found</i18n.Translate> - </span> + <li + class="px-3 py-2 text-gray-500 dark:text-gray-400" + role="option" + aria-disabled="true" + aria-selected="false" + > + <i18n.Translate>No element found</i18n.Translate> </li> </ul> - ) : ( + ) : filteredChoices ? ( <ul - class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 dark:bg-gray-950 dark:ring-gray-700 sm:text-sm" id={`${id}-options`} role="listbox" > - {filteredChoices.map((v, idx) => { + {filteredChoices.map((choice, index) => { + const reason = unavailableReason(choice); + const active = index === activeIndex; return ( - <li - key={idx} - class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" - id={`${id}-option-${idx}`} - role="option" - onMouseDown={(e) => { - // Input element should not lose focus - e.preventDefault(); - }} - onClick={() => { - setFilter(undefined); - if (unique && list.indexOf(v.value as string) !== -1) { - return; - } - if (max !== undefined && list.length >= max) { - return; - } - const newValue = [...list]; - newValue.push(v.value as string); - onChange(newValue as any); - }} - > - <span class="block truncate">{v.label}</span> + <li key={String(choice.value)} role="none"> + <button + type="button" + id={`${id}-option-${index}`} + role="option" + aria-selected="false" + aria-disabled={!!reason} + disabled={!!reason} + tabIndex={-1} + onMouseEnter={() => setActiveIndex(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => choose(choice)} + class={`group w-full select-none px-3 py-2 text-left ${ + reason + ? "cursor-not-allowed bg-gray-50 text-gray-500 dark:bg-gray-900 dark:text-gray-400" + : active + ? "cursor-pointer bg-indigo-600 text-white" + : "cursor-pointer text-gray-900 hover:bg-indigo-600 hover:text-white dark:text-gray-100" + }`} + > + <span class="block break-words font-medium"> + {choice.label} + </span> + {choice.description ? ( + <span + class={`mt-0.5 block break-words text-xs ${ + !reason && active + ? "text-indigo-100" + : "text-gray-500 group-hover:text-indigo-100 dark:text-gray-400" + }`} + > + {choice.description} + </span> + ) : undefined} + {reason ? ( + <span class="mt-1 block break-words text-xs font-medium text-amber-700 dark:text-amber-300"> + {reason} + </span> + ) : undefined} + </button> </li> ); })} </ul> - )} + ) : undefined} </div> - )} - {list.map((v, idx) => { - return ( + ) : undefined} + + <div class="flex flex-wrap gap-2"> + {list.map((selectedValue, index) => ( <span - key={idx} - class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mt-2 mr-2 text-xs font-medium text-gray-600" + key={`${selectedValue}-${index}`} + class="mt-2 inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-200" > - {choiceMap[v]} + {choiceMap.get(String(selectedValue))?.label ?? selectedValue} <button type="button" disabled={props.disabled} onClick={() => { const newValue = [...list]; - newValue.splice(idx, 1); + newValue.splice(index, 1); onChange(newValue as any); setFilter(undefined); + setDirty(true); }} - class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" + class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed" + aria-label={i18n.str`Remove ${choiceMap.get(String(selectedValue))?.label ?? selectedValue}`} > - <span class="sr-only"> - <i18n.Translate>Remove</i18n.Translate> - </span> <svg viewBox="0 0 14 14" - class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" + class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75 dark:stroke-gray-200/60" + aria-hidden="true" > <path d="M4 4l6 6m0-6l-6 6" /> </svg> - <span class="absolute -inset-1"></span> </button> </span> - ); - })} - {help && ( - <p class="mt-2 text-sm text-gray-500" id="email-description"> + ))} + </div> + {help ? ( + <p + class="mt-2 text-sm text-gray-500 dark:text-gray-400" + id={`${id}-description`} + > {help} </p> - )} - {dirty !== undefined && error && ( - <p class="mt-2 text-sm text-red-600">{error}</p> - )} + ) : undefined} + {dirty !== undefined && error ? ( + <p class="mt-2 text-sm text-red-600" id={`${id}-error`}> + {error} + </p> + ) : undefined} </div> ); } diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -231,6 +231,9 @@ type UIFormFieldSecret = { export interface SelectUiChoice { label: string; description?: string; + disabled?: boolean; + disabledReason?: string; + exclusiveGroup?: string; value: string | boolean; } @@ -442,6 +445,9 @@ const codecForUiFormFieldHtmlIFrame = (): Codec<UIFormElementHtmlIframe> => const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => buildCodecForObject<SelectUiChoice>() .property("description", codecOptional(codecForString())) + .property("disabled", codecOptional(codecForBoolean())) + .property("disabledReason", codecOptional(codecForString())) + .property("exclusiveGroup", codecOptional(codecForString())) .property("label", codecForString()) .property("value", codecForEither(codecForString(), codecForBoolean())) .build("SelectUiChoice");