Files
finance-duck/web/src/ui.tsx
T
Lars Nolden c569ae7dbf Keep the armed combobox row visible while scrolling
The dropdown caps at 264px and a category registry easily exceeds it;
arrow navigation now scrolls the armed row into view with
block: nearest so the list follows the keyboard in both directions.
2026-09-14 12:05:16 +02:00

979 lines
28 KiB
TypeScript

import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
X,
Inbox,
AlertCircle,
CalendarDays,
ChevronLeft,
ChevronRight,
Plus,
} from "lucide-react";
import type { Category, Dataset, Filter, State, VerifiedModel } from "./api";
import {
categoryPath,
DEFAULT_MONTHS,
defaultFilter,
monthStart,
request,
yearStart,
} from "./api";
export function Modal({
title,
children,
close,
wide = false,
}: {
title: string;
children: ReactNode;
close: () => void;
wide?: boolean;
}) {
const ref = useRef<HTMLDialogElement>(null);
const titleID = useId();
useEffect(() => {
const dialog = ref.current;
dialog?.showModal();
return () => dialog?.close();
}, []);
return (
<dialog
ref={ref}
aria-labelledby={titleID}
className={wide ? "modal wide" : "modal"}
onCancel={(e) => {
e.preventDefault();
close();
}}
>
<div className="modal-header">
<h2 id={titleID}>{title}</h2>
<button
className="icon-button"
aria-label="Close dialog"
onClick={close}
>
<X size={20} />
</button>
</div>
{children}
</dialog>
);
}
// ModelOptions loads the server-verified model list once and renders it as a
// datalist: the input stays free text so an unlisted model is still usable
// when the catalog is unreachable.
export function ModelOptions({ id }: { id: string }) {
const [models, setModels] = useState<VerifiedModel[]>([]);
useEffect(() => {
request<VerifiedModel[]>("/api/models")
.then(setModels)
.catch(() => {});
}, []);
return (
<datalist id={id}>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</datalist>
);
}
export function Field({
label,
children,
hint,
}: {
label: string;
children: ReactNode;
hint?: string;
}) {
return (
<label className="field">
<span>{label}</span>
{children}
{hint && <small>{hint}</small>}
</label>
);
}
export interface ComboOption {
value: string;
label: string;
icon?: ReactNode;
}
// ComboCreate is one "create it now" row a Combobox offers when the typed
// text matches nothing: running it is expected to persist the new entity and
// select it through the caller's own onChange.
export interface ComboCreate {
key: string;
label: string;
run: () => Promise<void> | void;
}
// Combobox is a free-text input that autocompletes against a fixed option
// list: typing filters by label, Enter takes the exact or only match, and
// picking an option reports its value. The caller keeps working with stable
// ids while the user only ever sees names.
export function Combobox({
options,
value,
onChange,
placeholder,
disabled = false,
required = false,
adornment,
emptyText = "No matches.",
create,
}: {
options: ComboOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
adornment?: ReactNode;
emptyText?: string;
create?: (text: string) => ComboCreate[];
}) {
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
// Index into the interactive rows (matches first, then create rows); -1
// means no row is armed and Enter falls back to exact/single-match logic.
const [active, setActive] = useState(-1);
const listID = useId();
const filter = query.trim().toLowerCase();
const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
const exact = filter
? matches.find((o) => o.label.toLowerCase() === filter)
: undefined;
const shown = exact
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = options.find((o) => o.value === value);
const creations =
create && filter && !exact && !disabled ? create(query.trim()) : [];
const total = shown.length + creations.length;
const cursor = active < total ? active : -1;
// The dropdown scrolls at 264px; keep the armed row visible while
// arrowing through a long category list.
useEffect(() => {
if (cursor < 0) return;
document
.getElementById(`${listID}-${cursor}`)
?.scrollIntoView({ block: "nearest" });
}, [cursor, listID]);
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
const runCreate = async (c: ComboCreate) => {
if (creating) return;
setCreating(true);
setCreateError("");
try {
await c.run();
setOpen(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : String(err));
} finally {
setCreating(false);
}
};
return (
<div className="combo">
<input
required={required}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
aria-controls={open ? listID : undefined}
aria-activedescendant={
open && cursor >= 0 ? `${listID}-${cursor}` : undefined
}
disabled={disabled}
value={open ? query : (selected?.label ?? value)}
placeholder={placeholder}
onFocus={() => {
setQuery("");
setActive(-1);
setOpen(true);
}}
onChange={(e) => {
setQuery(e.target.value);
setCreateError("");
setActive(-1);
setOpen(true);
}}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && open && total) {
e.preventDefault();
setActive(
e.key === "ArrowDown"
? (cursor + 1) % total
: (cursor <= 0 ? total : cursor) - 1,
);
}
if (e.key === "Enter" && open) {
e.preventDefault();
if (cursor >= 0 && cursor < shown.length) pick(shown[cursor].value);
else if (cursor >= shown.length)
void runCreate(creations[cursor - shown.length]);
else {
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
if (hit) pick(hit.value);
// Without an armed row, Enter creates only when nothing
// matches at all: minting from a half-typed name is too easy.
else if (!shown.length && creations.length === 1)
void runCreate(creations[0]);
}
}
}}
/>
{adornment && !open && (
<span className="combo-adornment">{adornment}</span>
)}
{open && (
<ul className="combo-options" role="listbox" id={listID}>
{shown.map((o, i) => (
<li key={o.value}>
<button
type="button"
id={`${listID}-${i}`}
className={
i === cursor ? "combo-option active" : "combo-option"
}
role="option"
aria-selected={o.value === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(o.value)}
>
{o.icon}
<span>{o.label}</span>
</button>
</li>
))}
{creations.map((c, i) => (
<li key={c.key}>
<button
type="button"
id={`${listID}-${shown.length + i}`}
className={
shown.length + i === cursor
? "combo-option create active"
: "combo-option create"
}
role="option"
aria-selected={false}
disabled={creating}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void runCreate(c)}
>
<Plus size={14} />
<span>{creating ? "Creating…" : c.label}</span>
</button>
</li>
))}
{createError && (
<li className="combo-empty error" role="alert">
{createError}
</li>
)}
{shown.length === 0 && creations.length === 0 && !createError && (
<li className="combo-empty">{emptyText}</li>
)}
{matches.length > shown.length && (
<li className="combo-empty">
{matches.length - shown.length} more keep typing to narrow down.
</li>
)}
</ul>
)}
</div>
);
}
// Dates are handled as calendar days, never as instants: every helper works on
// the ISO string's integer parts so a browser time zone can never shift a
// booking date. "Sept" follows the four-letter form used in the journal UI.
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sept",
"Oct",
"Nov",
"Dec",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
interface Month {
year: number;
month: number;
}
function dayParts(iso: string): (Month & { day: number }) | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
const [year, month, day] = iso.split("-").map(Number);
if (month < 1 || month > 12 || day < 1 || day > daysInMonth({ year, month }))
return null;
return { year, month, day };
}
function isoDay({ year, month }: Month, day: number) {
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function daysInMonth({ year, month }: Month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
// Monday-first column index of the first day of the month.
function firstWeekday({ year, month }: Month) {
return (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7;
}
function shiftMonth({ year, month }: Month, by: number): Month {
const zero = year * 12 + (month - 1) + by;
return { year: Math.floor(zero / 12), month: (zero % 12) + 1 };
}
// formatDay renders an ISO calendar day as "09 Sept 2026". Unparseable input is
// returned unchanged so an unexpected stored value stays visible instead of
// being replaced by an invented date.
function formatDay(iso: string): string {
const parts = dayParts(iso);
if (!parts) return iso;
return `${String(parts.day).padStart(2, "0")} ${MONTHS[parts.month - 1]} ${parts.year}`;
}
// DateField picks a calendar day by month name instead of the browser's
// numeric date control, which renders day and month ambiguously across
// locales. The value stays an ISO day, so filters and API payloads are
// unchanged. min/max are inclusive ISO days.
export function DateField({
label,
value,
onChange,
min,
max,
hint,
clearable = false,
}: {
label: string;
value: string;
onChange: (day: string) => void;
min?: string;
max?: string;
hint?: string;
clearable?: boolean;
}) {
const [open, setOpen] = useState(false);
const clock = new Date();
const now = isoDay(
{ year: clock.getFullYear(), month: clock.getMonth() + 1 },
clock.getDate(),
);
const selected = dayParts(value);
const [view, setView] = useState<Month>(
() => selected ?? dayParts(now) ?? { year: clock.getFullYear(), month: 1 },
);
const blocked = (day: string) => (!!min && day < min) || (!!max && day > max);
const choose = (day: string) => {
onChange(day);
setOpen(false);
};
const years = [];
const firstYear = (min ? dayParts(min)?.year : undefined) ?? view.year - 12;
const lastYear = (max ? dayParts(max)?.year : undefined) ?? view.year + 2;
for (
let year = Math.min(firstYear, view.year);
year <= Math.max(lastYear, view.year);
year++
)
years.push(year);
const days = [];
for (let day = 1; day <= daysInMonth(view); day++) days.push(day);
// Dismissal must not depend on focus: a click does not focus a button on
// every platform, and Escape then never reaches the popover. preventDefault
// keeps an enclosing dialog open when Escape only closes this picker.
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const outside = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false);
};
const escape = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
setOpen(false);
};
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [open]);
return (
<Field label={label} hint={hint}>
<div className="date-select" ref={box}>
<button
type="button"
className="date-trigger"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => {
if (!open) setView(selected ?? dayParts(now) ?? view);
setOpen(!open);
}}
>
<CalendarDays size={14} />
<span className={value ? "" : "date-placeholder"}>
{value ? formatDay(value) : "Any date"}
</span>
</button>
{open && (
<div className="date-popover" role="dialog" aria-label={label}>
<div className="date-nav">
<button
type="button"
className="icon-button"
aria-label="Previous month"
onClick={() => setView(shiftMonth(view, -1))}
>
<ChevronLeft size={15} />
</button>
<select
aria-label="Month"
value={view.month}
onChange={(e) =>
setView({ ...view, month: Number(e.target.value) })
}
>
{MONTHS.map((name, index) => (
<option key={name} value={index + 1}>
{name}
</option>
))}
</select>
<select
aria-label="Year"
value={view.year}
onChange={(e) =>
setView({ ...view, year: Number(e.target.value) })
}
>
{years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
<button
type="button"
className="icon-button"
aria-label="Next month"
onClick={() => setView(shiftMonth(view, 1))}
>
<ChevronRight size={15} />
</button>
</div>
<div className="date-grid">
{WEEKDAYS.map((weekday) => (
<span key={weekday} className="date-weekday">
{weekday}
</span>
))}
{Array.from({ length: firstWeekday(view) }, (_, i) => (
<span key={`pad${i}`} />
))}
{days.map((day) => {
const iso = isoDay(view, day);
return (
<button
key={day}
type="button"
className={`date-day${iso === value ? " selected" : ""}${iso === now ? " today" : ""}`}
disabled={blocked(iso)}
aria-current={iso === value ? "date" : undefined}
aria-label={formatDay(iso)}
onClick={() => choose(iso)}
>
{day}
</button>
);
})}
</div>
<div className="date-actions">
<button
type="button"
className="button subtle"
disabled={blocked(now)}
onClick={() => choose(now)}
>
Today
</button>
{clearable && value && (
<button
type="button"
className="button subtle"
onClick={() => choose("")}
>
Clear
</button>
)}
</div>
</div>
)}
</div>
</Field>
);
}
export function ErrorMessage({ error }: { error: string }) {
return error ? (
<div className="alert error" role="alert">
<AlertCircle size={18} />
<span>{error}</span>
</div>
) : null;
}
export function Empty({
title,
children,
}: {
title: string;
children?: ReactNode;
}) {
return (
<div className="empty">
<div className="empty-icon">
<Inbox size={30} />
</div>
<h3>{title}</h3>
<div>{children}</div>
</div>
);
}
// createTag persists a new tag and returns its server-minted id, found by
// diffing the returned state against the dataset the caller rendered with.
export async function createTag(
mutate: Mutate,
data: Dataset,
name: string,
): Promise<string> {
const next = await mutate(
"/api/tags",
{ tag: { id: "", name, hint: "" } },
`Tag "${name}" created`,
);
const created = next.data.tags.find(
(t) => !data.tags.some((o) => o.id === t.id),
);
if (!created)
throw new Error(`The server did not return the new tag "${name}".`);
return created.id;
}
export async function createCategory(
mutate: Mutate,
data: Dataset,
category: { name: string; parent_id: string; kind: string },
): Promise<string> {
const next = await mutate(
"/api/categories",
{ category: { id: "", hint: "", ...category } },
`Category "${category.name}" created`,
);
const created = next.data.categories.find(
(c) => !data.categories.some((o) => o.id === c.id),
);
if (!created)
throw new Error(
`The server did not return the new category "${category.name}".`,
);
return created.id;
}
export function TagPicker({
data,
value,
onChange,
mutate,
}: {
data: Dataset;
value: string[];
onChange: (ids: string[]) => void;
mutate?: Mutate;
}) {
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const add = async () => {
const name = draft.trim();
if (!name || busy || !mutate) return;
// An existing tag of the same name is checked instead of duplicated.
const existing = data.tags.find(
(t) => t.name.toLowerCase() === name.toLowerCase(),
);
if (existing) {
if (!value.includes(existing.id)) onChange([...value, existing.id]);
setDraft("");
return;
}
setBusy(true);
setError("");
try {
const id = await createTag(mutate, data, name);
onChange([...value, id]);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<fieldset className="tag-picker">
<legend>Tags</legend>
{data.tags.map((tag) => (
<label className="check-chip" key={tag.id}>
<input
type="checkbox"
checked={value.includes(tag.id)}
onChange={(e) =>
onChange(
e.target.checked
? [...value, tag.id]
: value.filter((id) => id !== tag.id),
)
}
/>
{tag.name}
</label>
))}
{!data.tags.length && !mutate && (
<small>No tags yet. Create them in Tags.</small>
)}
{mutate && (
<span className="tag-add">
<input
value={draft}
maxLength={200}
placeholder="New tag"
aria-label="New tag name"
disabled={busy}
onChange={(e) => {
setDraft(e.target.value);
setError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void add();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Create tag"
disabled={busy || !draft.trim()}
onClick={() => void add()}
>
<Plus size={15} />
</button>
</span>
)}
{error && <small className="tag-add-error">{error}</small>}
</fieldset>
);
}
export function CategoryOptions({
data,
kind,
exclude = [],
}: {
data: Dataset;
kind?: string;
exclude?: string[];
}) {
return (
<>
{data.categories
.filter((c) => (!kind || c.kind === kind) && !exclude.includes(c.id))
.map((c) => (
<option key={c.id} value={c.id}>
{categoryPath(data, c.id)}
</option>
))}
</>
);
}
// CategoryCombobox is the one category picker: options are full paths, and
// with a mutate handle an unmatched name can be created in place. A bare name
// lands under the kind's root; "Parent / Name" creates under that parent.
// leavesOnly matches the server rule that assigned categories must be leaves;
// a freshly created category is always a leaf.
export function CategoryCombobox({
data,
value,
onChange,
mutate,
kind,
leavesOnly = false,
exclude = [],
emptyLabel,
required = false,
disabled = false,
placeholder = "Search categories",
}: {
data: Dataset;
value: string;
onChange: (id: string) => void;
mutate?: Mutate;
kind?: string;
leavesOnly?: boolean;
exclude?: string[];
emptyLabel?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
}) {
const parents = new Set(
data.categories.map((c) => c.parent_id).filter(Boolean),
);
const eligible = (c: Category) =>
(!kind || c.kind === kind) && !exclude.includes(c.id);
const options: ComboOption[] = data.categories
.filter((c) => eligible(c) && (!leavesOnly || !parents.has(c.id)))
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
if (emptyLabel) options.unshift({ value: "", label: emptyLabel });
const pathOf = (id: string) => categoryPath(data, id).toLowerCase();
const taken = (parentID: string, name: string) => {
const full = `${parentID ? pathOf(parentID) + " / " : ""}${name.toLowerCase()}`;
return data.categories.some((c) => pathOf(c.id) === full);
};
const create = (text: string): ComboCreate[] => {
// Text aiming at the empty option ("No parent…") is a selection, not a
// new category name.
if (!mutate || emptyLabel?.toLowerCase().includes(text.toLowerCase()))
return [];
const segments = text
.split("/")
.map((s) => s.trim())
.filter(Boolean);
if (!segments.length) return [];
const name = segments[segments.length - 1];
const row = (parent: Category): ComboCreate => ({
key: parent.id,
label: `Create "${name}" in ${categoryPath(data, parent.id)}`,
run: async () =>
onChange(
await createCategory(mutate, data, {
name,
parent_id: parent.id,
kind: parent.kind,
}),
),
});
if (segments.length > 1) {
const prefix = segments.slice(0, -1).join(" / ").toLowerCase();
const parent = data.categories.find(
(c) => eligible(c) && pathOf(c.id) === prefix,
);
return parent && !taken(parent.id, name) ? [row(parent)] : [];
}
return data.categories
.filter((c) => !c.parent_id && eligible(c) && !taken(c.id, name))
.map(row);
};
return (
<Combobox
options={options}
value={value}
onChange={onChange}
required={required}
disabled={disabled}
placeholder={placeholder}
emptyText={
mutate
? "No matching category. Type a name to create it."
: "No matching category."
}
create={create}
/>
);
}
export function Filters({
data,
value,
onChange,
}: {
data: Dataset;
value: Filter;
onChange: (filter: Filter) => void;
}) {
const update = (key: keyof Filter, text: string) =>
onChange({ ...value, [key]: text });
const currencies = Array.from(
new Set([
...data.accounts.map((a) => a.currency),
...data.transactions.map((t) => t.facts.currency),
]),
).sort();
// Presets leave `to` open so the window always reaches today; the explicit
// date fields below stay authoritative for anything narrower.
const ranges = [
...[1, 3, DEFAULT_MONTHS, 12].map((months) => ({
label: `${months}M`,
title: months === 1 ? "This month" : `Last ${months} months`,
from: monthStart(months - 1),
to: "",
})),
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
{ label: "All", title: "All time", from: "", to: "" },
];
return (
<div className="filter-bar">
<div className="range-row">
<span className="eyebrow">Period</span>
<div className="chips">
{ranges.map((range) => {
const active = value.from === range.from && value.to === range.to;
return (
<button
key={range.label}
type="button"
className={`chip ${active ? "active" : ""}`}
aria-pressed={active}
title={range.title}
aria-label={range.title}
onClick={() =>
onChange({ ...value, from: range.from, to: range.to })
}
>
{range.label}
</button>
);
})}
</div>
<button
className="button subtle filter-reset"
onClick={() => onChange(defaultFilter())}
>
Reset
</button>
</div>
<div className="filters">
<DateField
label="From"
clearable
value={value.from}
max={value.to || undefined}
onChange={(day) => update("from", day)}
/>
<DateField
label="To"
clearable
value={value.to}
min={value.from || undefined}
onChange={(day) => update("to", day)}
/>
<Field label="Currency">
<select
value={value.currency}
onChange={(e) => update("currency", e.target.value)}
>
<option value="">All currencies</option>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="Account">
<select
value={value.account_id}
onChange={(e) => update("account_id", e.target.value)}
>
<option value="">All accounts</option>
{data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name}
</option>
))}
</select>
</Field>
<Field label="Category">
<select
value={value.category_id}
onChange={(e) => update("category_id", e.target.value)}
>
<option value="">All categories</option>
<CategoryOptions data={data} />
</select>
</Field>
<Field label="Tag">
<select
value={value.tag_id}
onChange={(e) => update("tag_id", e.target.value)}
>
<option value="">All tags</option>
{data.tags.map((t) => (
<option value={t.id} key={t.id}>
{t.name}
</option>
))}
</select>
</Field>
<Field label="Merchant">
<select
value={value.merchant_id}
onChange={(e) => update("merchant_id", e.target.value)}
>
<option value="">All merchants</option>
{data.merchants.map((m) => (
<option value={m.id} key={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
</div>
);
}
export function FormActions({
busy,
close,
label = "Save changes",
}: {
busy: boolean;
close: () => void;
label?: string;
}) {
return (
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={close}
disabled={busy}
>
Cancel
</button>
<button className="button primary" type="submit" disabled={busy}>
{busy ? "Saving…" : label}
</button>
</div>
);
}
// Mutate posts a revisioned change and returns the accepted state, so a
// caller can find ids the server just minted.
export type Mutate = (
path: string,
body: Record<string, unknown>,
message?: string,
) => Promise<State>;