commit 519800af0da61bcda22b44b34c7d6d89d42f45ef
parent 37605197af72b4afa5fc41cfd2db1bc319a2ff19
Author: Florian Dold <dold@taler.net>
Date: Wed, 4 Nov 2026 23:54:02 +0100
merchant web UI: add multi-select product and category popovers
Keep selections as chips and open a stable popover from a separate Add
row. Support checkbox toggles, keyboard navigation, Done, and optional
inline category creation with pending and failure feedback.
Diffstat:
5 files changed, 865 insertions(+), 0 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.test.tsx b/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.test.tsx
@@ -0,0 +1,222 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { render } from "preact";
+import { useState } from "preact/hooks";
+import { act } from "preact/test-utils";
+import { TokenSelector } from "./TokenSelector.js";
+import { normalizeApiFailure } from "../../api/failure.js";
+import { STORIES } from "../../stories/story-data.js";
+
+function mount(node: preact.VNode) {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ act(() => render(node, root));
+ return {
+ root,
+ close: () => {
+ act(() => render(null, root));
+ root.remove();
+ },
+ };
+}
+function search(root: HTMLElement, value: string) {
+ if (!root.querySelector('input[role="combobox"]')) {
+ act(() =>
+ root
+ .querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]')!
+ .click(),
+ );
+ }
+ const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')!;
+ act(() => {
+ input.focus();
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ return input;
+}
+function key(input: HTMLElement, key: string) {
+ const event = new KeyboardEvent("keydown", {
+ key,
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ input.dispatchEvent(event);
+ });
+ return event;
+}
+const options = [
+ { id: "a", name: "Coffee" },
+ { id: "b", name: "Coffee" },
+ { id: "cake", name: "Cake" },
+];
+function Fixture() {
+ const [selected, setSelected] = useState<typeof options>([]);
+ return (
+ <form>
+ <TokenSelector
+ selected={selected}
+ onChange={setSelected}
+ options={options}
+ label="Search"
+ addLabel="+ Add item"
+ emptyMessage="Empty"
+ loading={false}
+ unavailable={false}
+ />
+ <button type="button">Outside</button>
+ </form>
+ );
+}
+
+test("multi-select searches by name and ID, preserves chips, and supports keyboard toggling", () => {
+ const { root, close } = mount(<Fixture />);
+ try {
+ assert.equal(root.querySelector('[role="listbox"]'), null);
+ const trigger = root.querySelector<HTMLButtonElement>(
+ 'button[aria-haspopup="dialog"]',
+ )!;
+ const input = search(root, "COFFEE");
+ assert.equal(document.activeElement, input);
+ assert.equal(root.querySelectorAll('[role="option"]').length, 2);
+ key(input, "ArrowDown");
+ assert.ok(input.getAttribute("aria-activedescendant"));
+ assert.equal(key(input, "Enter").defaultPrevented, true);
+ key(input, "ArrowDown");
+ key(input, "Enter");
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 2);
+ assert.equal(root.querySelectorAll('[aria-selected="true"]').length, 2);
+ assert.equal(input.value, "COFFEE");
+ search(root, "cake");
+ assert.equal(root.querySelectorAll('[role="option"]').length, 1);
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 2);
+ search(root, "b");
+ assert.equal(root.querySelectorAll('[role="option"]').length, 1);
+ key(input, "ArrowUp");
+ key(input, "Enter");
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 1);
+ key(input, "Escape");
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
+ assert.equal(root.querySelector('[role="combobox"]'), null);
+ assert.equal(document.activeElement, trigger);
+ assert.equal(search(root, "").value, "");
+ key(root.querySelector<HTMLInputElement>('[role="combobox"]')!, "Escape");
+ act(() =>
+ root.querySelector<HTMLButtonElement>("[data-chip-remove]")!.click(),
+ );
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 0);
+ assert.equal(document.activeElement, trigger);
+ search(root, "missing");
+ assert.match(root.textContent!, /No matches/);
+ act(() => {
+ document.body.dispatchEvent(
+ new MouseEvent("mousedown", { bubbles: true }),
+ );
+ });
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
+ assert.equal(root.querySelector('[role="combobox"]'), null);
+ search(root, "");
+ act(() => root.querySelector<HTMLButtonElement>("form > button")!.focus());
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
+ assert.equal(root.querySelector('[role="combobox"]'), null);
+ } finally {
+ close();
+ }
+});
+
+test("catalogue datasets reset selection and expose loading, missing data and retry", async () => {
+ const story = STORIES.find((s) => s.id === "ui-token-selector")!;
+ assert.equal(story.category, "UI Components");
+ assert.equal(story.dataSets?.length, 6);
+ const { root, close } = mount(story.render("preselected"));
+ try {
+ const inputs = root.querySelectorAll<HTMLButtonElement>(
+ 'button[aria-haspopup="dialog"]',
+ );
+ assert.equal(inputs.length, 2);
+ assert.notEqual(
+ inputs[0]!.getAttribute("aria-controls"),
+ inputs[1]!.getAttribute("aria-controls"),
+ );
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 5);
+ act(() => render(story.render("empty-selection"), root));
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 0);
+ search(root, "Hot");
+ act(() => root.querySelector<HTMLElement>('[role="option"]')!.click());
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 1);
+ act(() => render(story.render("loading"), root));
+ search(root, "");
+ assert.match(root.textContent!, /Loading/);
+ assert.doesNotMatch(root.textContent!, /Unavailable —/);
+ assert.equal(root.querySelectorAll("[data-chip-remove]").length, 5);
+ act(() => render(story.render("unavailable"), root));
+ assert.match(root.textContent!, /Retired coffee blend/);
+ assert.match(root.textContent!, /Unavailable — remove before saving/);
+ act(() => render(story.render("empty-catalog"), root));
+ search(root, "");
+ assert.match(root.textContent!, /No product categories are available/);
+ act(() => render(story.render("failure"), root));
+ assert.doesNotMatch(root.textContent!, /Unavailable —/);
+ const retry = [...root.querySelectorAll<HTMLButtonElement>("button")].find(
+ (b) => b.textContent?.includes("Refresh"),
+ );
+ assert.ok(retry);
+ await act(async () => retry.click());
+ search(root, "Hot");
+ assert.equal(root.querySelectorAll('[role="option"]').length, 1);
+ assert.doesNotMatch(root.textContent!, /Could not load/);
+ } finally {
+ close();
+ }
+});
+
+test("refresh failures retain cached choices and refreshed chip names without discarding saved IDs", () => {
+ const selected = [{ id: "a", name: "Old coffee" }];
+ const view = (
+ data: typeof options | undefined,
+ loading = false,
+ unavailable = false,
+ failed = false,
+ ) => (
+ <TokenSelector
+ selected={selected}
+ onChange={() => {}}
+ options={data ?? []}
+ resource={{
+ data,
+ error: failed ? normalizeApiFailure(new Error("Offline")) : undefined,
+ isLoading: loading,
+ isRefreshing: false,
+ refresh: async () => {},
+ }}
+ loading={loading}
+ unavailable={unavailable}
+ label="Search"
+ addLabel="+ Add item"
+ emptyMessage="Empty"
+ />
+ );
+ const { root, close } = mount(view(undefined, true));
+ try {
+ assert.match(root.textContent!, /Old coffee/);
+ assert.doesNotMatch(root.textContent!, /Unavailable —/);
+ act(() => render(view(undefined, false, true), root));
+ assert.doesNotMatch(root.textContent!, /Unavailable —/);
+ act(() => render(view(options, false, false, true), root));
+ assert.ok(root.querySelector('[aria-label="Remove Coffee (a)"]'));
+ search(root, "Coffee");
+ assert.equal(root.querySelectorAll('[role="option"]').length, 2);
+ assert.equal(selected[0]!.id, "a");
+ act(() => render(view([]), root));
+ assert.match(root.textContent!, /Old coffee/);
+ assert.match(root.textContent!, /Unavailable — remove before saving/);
+ } finally {
+ close();
+ }
+});
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx b/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx
@@ -0,0 +1,483 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import {
+ useCallback,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "preact/hooks";
+import type { RemoteResource } from "../../api/contracts.js";
+import { useTranslation } from "../../context/translation.js";
+import { ReadErrorBanner } from "../../ui/ReadErrorBanner.js";
+import { formatErrorMessage } from "../../utils/errors.js";
+import { useOutsideDismiss } from "../../ui/dropdownHooks.js";
+
+export function TokenSelector<T extends { id: string | number; name: string }>({
+ selected,
+ onChange,
+ onCreateCategory,
+ options,
+ resource,
+ loading,
+ unavailable,
+ emptyMessage,
+ label,
+ addLabel,
+}: {
+ selected: T[];
+ onChange: (value: T[]) => void;
+ onCreateCategory?: (name: string) => Promise<T>;
+ options: T[];
+ resource?: RemoteResource<T[]>;
+ loading: boolean;
+ unavailable: boolean;
+ emptyMessage: string;
+ label: string;
+ addLabel: string;
+}) {
+ const { t } = useTranslation();
+ const id = useId();
+ const wrapper = useRef<HTMLDivElement>(null);
+ const trigger = useRef<HTMLButtonElement>(null);
+ const input = useRef<HTMLInputElement>(null);
+ const list = useRef<HTMLDivElement>(null);
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [active, setActive] = useState(-1);
+ const [reservedHeight, setReservedHeight] = useState(0);
+ const [placement, setPlacement] = useState({
+ top: 0,
+ left: 0,
+ width: 320,
+ height: 340,
+ });
+ const [announcement, setAnnouncement] = useState("");
+ const [creating, setCreating] = useState(false);
+ const creatingRef = useRef(false);
+ const [creationError, setCreationError] = useState("");
+ const mounted = useRef(true);
+ useEffect(
+ () => () => {
+ mounted.current = false;
+ },
+ [],
+ );
+ const latest = useRef({ selected, onChange, open });
+ latest.current = { selected, onChange, open };
+ const query = search.trim().toLocaleLowerCase();
+ const choices =
+ loading || unavailable
+ ? []
+ : options.filter((entry) =>
+ `${entry.name} ${entry.id}`.toLocaleLowerCase().includes(query),
+ );
+ const close = useCallback(() => {
+ setOpen(false);
+ setSearch("");
+ setActive(-1);
+ setCreationError("");
+ }, []);
+ const finish = useCallback(() => {
+ close();
+ trigger.current?.focus({ preventScroll: true });
+ }, [close]);
+ useOutsideDismiss(open, wrapper, close);
+ useEffect(() => {
+ if (!open) return;
+ const element = wrapper.current;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ finish();
+ }
+ };
+ element?.addEventListener("keydown", onKeyDown);
+ return () => element?.removeEventListener("keydown", onKeyDown);
+ }, [open, finish]);
+ useLayoutEffect(() => {
+ // Removing chips near the page bottom must not shrink the document and
+ // clamp its scroll position while someone is using the open menu.
+ setReservedHeight((previous) =>
+ open
+ ? Math.max(
+ previous,
+ wrapper.current?.getBoundingClientRect().height ?? 0,
+ )
+ : 0,
+ );
+ }, [open, selected, options, loading, unavailable]);
+ useLayoutEffect(() => {
+ if (!open) return;
+ const rect = trigger.current?.getBoundingClientRect();
+ const parent = wrapper.current?.getBoundingClientRect();
+ if (!rect || !parent) return;
+ // Anchor to the opening position within the selector. Adding chips moves
+ // the Add button, but must not move a row out from under the pointer.
+ let anchor = {
+ top: rect.top - parent.top,
+ left: rect.left - parent.left,
+ height: rect.height,
+ };
+ const position = () => {
+ const bounds = wrapper.current?.getBoundingClientRect();
+ if (!bounds) return;
+ const top = bounds.top + anchor.top;
+ const bottom = top + anchor.height;
+ const below = window.innerHeight - bottom - 12;
+ const above = top - 12;
+ const upwards = below < 340 && above > below;
+ const width = Math.min(360, window.innerWidth - 24);
+ const height = Math.max(0, Math.min(340, upwards ? above : below));
+ setPlacement({
+ top: Math.max(
+ 12,
+ Math.min(
+ upwards ? top - height - 4 : bottom + 4,
+ window.innerHeight - height - 12,
+ ),
+ ),
+ left: Math.max(
+ 12,
+ Math.min(bounds.left + anchor.left, window.innerWidth - width - 12),
+ ),
+ width,
+ height,
+ });
+ };
+ const resize = () => {
+ const rect = trigger.current?.getBoundingClientRect();
+ const bounds = wrapper.current?.getBoundingClientRect();
+ if (rect && bounds)
+ anchor = {
+ top: rect.top - bounds.top,
+ left: rect.left - bounds.left,
+ height: rect.height,
+ };
+ position();
+ };
+ const scroll = (event: Event) => {
+ // Scrolling search results must not reposition the popover itself.
+ if (event.target === list.current) return;
+ position();
+ };
+ position();
+ window.addEventListener("resize", resize);
+ window.addEventListener("scroll", scroll, true);
+ return () => {
+ window.removeEventListener("resize", resize);
+ window.removeEventListener("scroll", scroll, true);
+ };
+ }, [open]);
+ useLayoutEffect(() => {
+ if (open) input.current?.focus({ preventScroll: true });
+ }, [open]);
+ useEffect(() => {
+ const container = list.current;
+ const option = container?.querySelector<HTMLElement>(
+ `[data-index="${active}"]`,
+ );
+ if (!container || !option) return;
+ const bounds = container.getBoundingClientRect();
+ const item = option.getBoundingClientRect();
+ if (item.top < bounds.top) container.scrollTop -= bounds.top - item.top;
+ else if (item.bottom > bounds.bottom)
+ container.scrollTop += item.bottom - bounds.bottom;
+ }, [active]);
+ const toggle = (entry: T) => {
+ const exists = selected.some((s) => s.id === entry.id);
+ onChange(
+ exists ? selected.filter((s) => s.id !== entry.id) : [...selected, entry],
+ );
+ setAnnouncement(
+ exists ? t`Removed ${entry.name}` : t`Selected ${entry.name}`,
+ );
+ };
+ const canCreate =
+ !!onCreateCategory &&
+ !!query &&
+ choices.length === 0 &&
+ !loading &&
+ !unavailable;
+ const createCategory = async () => {
+ if (!canCreate || !onCreateCategory || creatingRef.current) return;
+ creatingRef.current = true;
+ setCreating(true);
+ setCreationError("");
+ input.current?.focus({ preventScroll: true });
+ try {
+ const category = await onCreateCategory(search.trim());
+ if (!mounted.current) return;
+ const current = latest.current;
+ if (!current.selected.some((item) => item.id === category.id)) {
+ current.onChange([...current.selected, category]);
+ }
+ setSearch("");
+ setActive(-1);
+ setAnnouncement(t`Selected ${category.name}`);
+ if (current.open) input.current?.focus({ preventScroll: true });
+ } catch (cause) {
+ if (mounted.current)
+ setCreationError(
+ formatErrorMessage(cause, t`Could not create the category`, t),
+ );
+ } finally {
+ creatingRef.current = false;
+ if (mounted.current) setCreating(false);
+ }
+ };
+ const activeChoice =
+ open && active >= 0 && active < choices.length ? active : -1;
+ return (
+ <div
+ ref={wrapper}
+ class="relative space-y-2"
+ style={{
+ overflowAnchor: "none",
+ minHeight: open ? reservedHeight : undefined,
+ }}
+ onFocusOut={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null))
+ close();
+ }}
+ >
+ {resource && (
+ <ReadErrorBanner
+ resource={resource}
+ title={t`Could not load eligible products or categories`}
+ />
+ )}
+ {!open && (loading || (!unavailable && options.length === 0)) && (
+ <p role="status" class="text-sm text-gray-500">
+ {loading ? t`Loading…` : emptyMessage}
+ </p>
+ )}
+ {selected.length > 0 && (
+ <ul
+ aria-label={t`Selected items: ${label}`}
+ class="flex flex-wrap gap-2"
+ >
+ {selected.map((snapshot, index) => {
+ const entry =
+ options.find((option) => option.id === snapshot.id) ?? snapshot;
+ const missing =
+ (!resource || resource.data !== undefined) &&
+ !loading &&
+ !unavailable &&
+ !options.some((option) => option.id === snapshot.id);
+ return (
+ <li
+ key={entry.id}
+ class={`inline-flex max-w-full items-center gap-1 rounded-lg border py-1 pl-2 pr-1 text-sm ${missing ? "border-amber-300 bg-amber-50" : "border-blue-200 bg-blue-50"}`}
+ >
+ <span class="min-w-0 break-words">
+ {entry.name}
+ <span class="block text-xs text-gray-500">{entry.id}</span>
+ {missing && (
+ <span class="block text-xs text-amber-800">{t`Unavailable — remove before saving`}</span>
+ )}
+ </span>
+ <button
+ type="button"
+ aria-label={t`Remove ${entry.name} (${entry.id})`}
+ class="shrink-0 rounded p-2 text-gray-600 hover:bg-blue-100 focus:ring-2 focus:ring-blue-500"
+ onClick={() => {
+ const buttons =
+ wrapper.current?.querySelectorAll<HTMLButtonElement>(
+ "[data-chip-remove]",
+ );
+ const next = buttons?.[index + 1] ?? buttons?.[index - 1];
+ if (next) next.focus({ preventScroll: true });
+ else trigger.current?.focus({ preventScroll: true });
+ toggle(entry);
+ }}
+ data-chip-remove
+ >
+ ×
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ <div>
+ <button
+ ref={trigger}
+ type="button"
+ aria-expanded={open}
+ aria-haspopup="dialog"
+ aria-controls={`${id}-popover`}
+ onClick={() => (open ? close() : setOpen(true))}
+ class="rounded-lg border border-dashed border-blue-300 px-3 py-2 text-sm text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {addLabel}
+ </button>
+ </div>
+ {open && (
+ <div
+ id={`${id}-popover`}
+ role="dialog"
+ aria-label={addLabel}
+ class="fixed z-40 flex flex-col gap-2 rounded-lg border border-gray-300 bg-white p-2 shadow-lg"
+ style={{
+ marginTop: 0,
+ top: `${placement.top}px`,
+ left: `${placement.left}px`,
+ width: `${placement.width}px`,
+ height: `${placement.height}px`,
+ }}
+ >
+ <input
+ ref={input}
+ type="search"
+ role="combobox"
+ aria-label={label}
+ aria-expanded={open}
+ aria-controls={`${id}-options`}
+ aria-autocomplete="list"
+ aria-activedescendant={
+ activeChoice < 0 ? undefined : `${id}-option-${activeChoice}`
+ }
+ placeholder={`${label}…`}
+ autoComplete="off"
+ readOnly={creating}
+ value={search}
+ onFocus={() => setOpen(true)}
+ onClick={() => setOpen(true)}
+ onInput={(event) => {
+ setSearch(event.currentTarget.value);
+ setCreationError("");
+ setActive(-1);
+ setOpen(true);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ setOpen(true);
+ const count = choices.length;
+ setActive(
+ count === 0
+ ? -1
+ : event.key === "ArrowDown"
+ ? (activeChoice + 1) % count
+ : activeChoice < 0
+ ? count - 1
+ : (activeChoice + count - 1) % count,
+ );
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ if (activeChoice >= 0) toggle(choices[activeChoice]!);
+ else setOpen(true);
+ }
+ }}
+ class="w-full shrink-0 rounded-lg border border-transparent bg-gray-50 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-gray-300 focus:outline-none"
+ />
+ <div
+ ref={list}
+ tabIndex={-1}
+ class="min-h-0 flex-1 overflow-auto overscroll-contain"
+ >
+ <div
+ id={`${id}-options`}
+ role="listbox"
+ aria-label={label}
+ aria-multiselectable="true"
+ aria-busy={loading}
+ >
+ {choices.map((entry, index) => {
+ const checked = selected.some((s) => s.id === entry.id);
+ return (
+ <div
+ tabIndex={-1}
+ key={entry.id}
+ id={`${id}-option-${index}`}
+ data-index={index}
+ role="option"
+ aria-selected={checked}
+ class={`flex w-full text-left cursor-pointer items-start gap-2 px-3 py-2 text-sm ${activeChoice === index ? "bg-blue-100" : checked ? "bg-blue-50" : "hover:bg-gray-50"}`}
+ onMouseDown={(event) => event.preventDefault()}
+ onClick={() => {
+ setActive(index);
+ toggle(entry);
+ input.current?.focus({ preventScroll: true });
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setActive(index);
+ toggle(entry);
+ input.current?.focus({ preventScroll: true });
+ }
+ }}
+ >
+ {/* Selection is exposed by the listbox option's aria-selected;
+ the native checkbox supplies its familiar visual state. */}
+ <input
+ type="checkbox"
+ checked={checked}
+ readOnly
+ tabIndex={-1}
+ aria-hidden="true"
+ class="pointer-events-none mt-1 h-4 w-4 shrink-0 accent-blue-600"
+ />
+ <span class="min-w-0 break-words">
+ {entry.name}
+ <span class="block text-xs text-gray-500">
+ {entry.id}
+ </span>
+ </span>
+ </div>
+ );
+ })}
+ {choices.length === 0 && (
+ <p role="status" class="px-3 py-3 text-sm text-gray-500">
+ {loading
+ ? t`Loading…`
+ : unavailable
+ ? t`Could not load eligible products or categories`
+ : options.length
+ ? t`No matches`
+ : emptyMessage}
+ </p>
+ )}
+ </div>
+ {creationError && (
+ <p role="alert" class="px-3 py-2 text-sm text-red-800">
+ {creationError}
+ </p>
+ )}
+ {canCreate && (
+ <button
+ type="button"
+ disabled={creating}
+ data-create-category
+ onClick={createCategory}
+ class="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 disabled:opacity-50"
+ >
+ {creating
+ ? t`Creating category…`
+ : t`Create category “${search.trim()}”`}
+ </button>
+ )}
+ </div>
+ <div class="flex shrink-0 justify-end">
+ <button
+ type="button"
+ onClick={finish}
+ class="rounded-lg px-3 py-1.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >{t`Done`}</button>
+ </div>
+ </div>
+ )}
+
+ <span class="sr-only" role="status" aria-live="polite">
+ {announcement}
+ </span>
+ </div>
+ );
+}
diff --git a/packages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx b/packages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx
@@ -0,0 +1,118 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import { useState } from "preact/hooks";
+import { TokenSelector } from "../screens/token-setup/TokenSelector.js";
+import { useTranslation } from "../context/translation.js";
+import { normalizeApiFailure } from "../api/failure.js";
+
+const categories = [
+ { id: 1, name: "Hot drinks" },
+ { id: 2, name: "Pastries" },
+ { id: 3, name: "Merchandise" },
+ { id: 4, name: "Cold drinks" },
+ { id: 5, name: "Seasonal specials" },
+];
+const products = [
+ { id: "espresso", name: "Espresso" },
+ { id: "cappuccino-small", name: "Cappuccino" },
+ { id: "cappuccino-large", name: "Cappuccino" },
+ { id: "croissant", name: "Croissant" },
+ { id: "mug", name: "Coffee mug" },
+ {
+ id: "coffee-box",
+ name: "Seasonal single-origin coffee tasting selection with reusable gift box",
+ },
+ ...[
+ "Latte",
+ "Mocha",
+ "Americano",
+ "Tea",
+ "Iced coffee",
+ "Hot chocolate",
+ "Cookie",
+ "Brownie",
+ "Baguette",
+ "Lemonade",
+ ].map((name, index) => ({ id: `cafe-${index}`, name })),
+];
+export function TokenSelectorStory({
+ dataSet = "preselected",
+}: {
+ dataSet?: string;
+}) {
+ const { t } = useTranslation();
+ const [selectedCategories, setCategories] = useState(
+ dataSet === "empty-selection" || dataSet === "empty-catalog"
+ ? []
+ : categories.slice(0, 2),
+ );
+ const [selectedProducts, setProducts] = useState(
+ dataSet === "empty-selection" || dataSet === "empty-catalog"
+ ? []
+ : dataSet === "unavailable"
+ ? [products[0]!, { id: "retired", name: "Retired coffee blend" }]
+ : [products[0]!, products[1]!, products[5]!],
+ );
+ const [categoryOptions, setCategoryOptions] = useState(
+ dataSet === "empty-catalog" ? [] : categories,
+ );
+ const [recovered, setRecovered] = useState(false);
+ const loading = dataSet === "loading";
+ const unavailable = dataSet === "failure" && !recovered;
+ const empty = dataSet === "empty-catalog";
+ const error = unavailable
+ ? normalizeApiFailure(new Error("Sample catalog is unavailable"))
+ : undefined;
+ const resource = <T,>(options: T[]) => ({
+ data: loading || unavailable ? undefined : options,
+ error,
+ isLoading: loading,
+ isRefreshing: false,
+ refresh: async () => {
+ setRecovered(true);
+ },
+ });
+ return (
+ <div class="max-w-2xl space-y-6 pb-16">
+ <section class="space-y-2">
+ <h3 class="text-sm font-bold">{t`Product categories`}</h3>
+ <TokenSelector
+ selected={selectedCategories}
+ onChange={setCategories}
+ onCreateCategory={async (name) => {
+ const category = {
+ id: Math.max(0, ...categoryOptions.map((item) => item.id)) + 1,
+ name,
+ };
+ setCategoryOptions((items) => [...items, category]);
+ return category;
+ }}
+ options={categoryOptions}
+ resource={resource(categoryOptions)}
+ loading={loading}
+ unavailable={unavailable}
+ emptyMessage={t`No product categories are available. Create a category or select an individual product.`}
+ label={t`Search categories`}
+ addLabel={t`+ Add product category`}
+ />
+ </section>
+ <section class="space-y-2">
+ <h3 class="text-sm font-bold">{t`Individual products`}</h3>
+ <TokenSelector
+ selected={selectedProducts}
+ onChange={setProducts}
+ options={empty ? [] : products}
+ resource={resource(empty ? [] : products)}
+ loading={loading}
+ unavailable={unavailable}
+ emptyMessage={t`No inventory products are available. Add a product or select a product category.`}
+ label={t`Search products`}
+ addLabel={t`+ Add inventory product`}
+ />
+ </section>
+ </div>
+ );
+}
diff --git a/packages/taler-merchant-webui/src/stories/story-data.tsx b/packages/taler-merchant-webui/src/stories/story-data.tsx
@@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { TokenSelectorStory } from "./TokenSelectorStory.js";
import type { VNode } from "preact";
import { useState } from "preact/hooks";
import {
@@ -669,6 +670,25 @@ const storyResource = <T,>(data: T) => ({
export const STORIES: Story[] = [
{
+ id: "ui-token-selector",
+ category: "UI Components",
+ name: "Product and category multi-select",
+ description:
+ "Search products and categories, select multiple items, and remove selected chips.",
+ dataSets: [
+ { id: "preselected", label: "Preselected" },
+ { id: "empty-selection", label: "Empty selection" },
+ { id: "unavailable", label: "Unavailable selection" },
+ { id: "loading", label: "Loading" },
+ { id: "empty-catalog", label: "Empty catalog" },
+ { id: "failure", label: "Load failure" },
+ ],
+ defaultDataSetId: "preselected",
+ render: (dataSetId = "preselected") => (
+ <TokenSelectorStory key={dataSetId} dataSet={dataSetId} />
+ ),
+ },
+ {
id: "administration-accounts",
category: "Administration",
name: "Merchant Account Administration",
diff --git a/packages/taler-merchant-webui/src/stories/story-messages.ts b/packages/taler-merchant-webui/src/stories/story-messages.ts
@@ -16,6 +16,22 @@ import type { TranslateFn } from "../context/translation.js";
*/
export function translateStoryMetadata(source: string, t: TranslateFn): string {
switch (source) {
+ case "Load failure":
+ return t`Load failure`;
+ case "Empty catalog":
+ return t`Empty catalog`;
+ case "Loading":
+ return t`Loading`;
+ case "Unavailable selection":
+ return t`Unavailable selection`;
+ case "Empty selection":
+ return t`Empty selection`;
+ case "Preselected":
+ return t`Preselected`;
+ case "Search products and categories, select multiple items, and remove selected chips.":
+ return t`Search products and categories, select multiple items, and remove selected chips.`;
+ case "Product and category multi-select":
+ return t`Product and category multi-select`;
case "3x4 touch numeric numpad for ad-hoc quick charge payments.":
return t`3x4 touch numeric numpad for ad-hoc quick charge payments.`;
case "4-step setup status guide summarizing business info, payout accounts, verification, and selling options.":
@@ -78,6 +94,12 @@ export function translateStoryMetadata(source: string, t: TranslateFn): string {
return t`Copyable Account`;
case "Create Access Token":
return t`Create Access Token`;
+ case "Guided Discount Setup":
+ return t`Guided Discount Setup`;
+ case "Guided setup for discount stamps and passes.":
+ return t`Guided setup for discount stamps and passes.`;
+ case "Create Discount Stamps":
+ return t`Create Discount Stamps`;
case "Create Discount":
return t`Create Discount`;
case "Create Merchant Account":