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:
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");