Simpl-Resultat/src/components/shared/CategoryCombobox.tsx
le king fu 3631ef0b64 feat: render the category combobox's open list in a portal (#429)
The open list of CategoryCombobox was positioned `absolute` inside its
input's container, so a scrolling ancestor clipped it: the body of the
split window, the `overflow-x-auto` wrapper of the Transactions table.
It is now rendered in a portal on document.body, `position: fixed`, at
z-[300] (above every window, the highest at z-[210]).

The pure rule lives in placeListbox.ts: under the input, as wide as it,
4 px below; above it when the room below is short of the list's 240 px
and the room above has them; below when neither side has them. The
list is placed in a layout effect before the first paint, then follows
the input on every scroll (capture phase, any scrolling container) and
resize while it is open, skipping scrolls of the list itself; the
listeners are removed with the list (followAnchor returns the cleanup).
The outside press counts the list as inside, since it no longer sits in
the container's DOM. The closed markup is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:34:44 -04:00

378 lines
13 KiB
TypeScript

import { useState, useRef, useEffect, useLayoutEffect, useCallback, useId, useMemo } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
LISTBOX_MAX_HEIGHT,
followAnchor,
placeListbox,
samePlacement,
type ListboxPlacement,
} from "./placeListbox";
/**
* Minimal shape the combobox needs. `Category` (transactions, reports) and
* `CategoryTreeNode` (categories page) both satisfy it, so callers pass their
* own rows without converting: `parent_id` may be `null` or absent for a root,
* `i18n_key` names the seed translation when the row comes from the seed.
*/
export interface CategoryLike {
id: number;
name: string;
parent_id?: number | null;
sort_order: number;
i18n_key?: string | null;
}
interface CategoryComboboxProps<T extends CategoryLike = CategoryLike> {
categories: T[];
value: number | null;
onChange: (id: number | null) => void;
placeholder?: string;
compact?: boolean;
ariaLabel?: string;
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
extraOptions?: Array<{ value: string; label: string }>;
/** Called when an extra option is selected */
onExtraSelect?: (value: string) => void;
/** Currently active extra option value (for display) */
activeExtra?: string | null;
}
// Strip accents + lowercase for accent-insensitive matching
function normalize(s: string): string {
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
// Compute depth of each category based on parent_id chain
function computeDepths(categories: CategoryLike[]): Map<number, number> {
const byId = new Map<number, CategoryLike>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
function depthOf(id: number, seen: Set<number>): number {
if (depths.has(id)) return depths.get(id)!;
if (seen.has(id)) return 0;
seen.add(id);
const cat = byId.get(id);
if (!cat || cat.parent_id == null) {
depths.set(id, 0);
return 0;
}
const d = depthOf(cat.parent_id, seen) + 1;
depths.set(id, d);
return d;
}
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
/**
* Order a flat list of categories in hierarchical DFS order: each root is
* emitted immediately followed by its descendants (depth-first, parent before
* children). Siblings within a group are ordered by `sort_order` ascending,
* then by `resolveName(cat)` for stable tiebreaking.
*
* A plain `ORDER BY sort_order, name` in SQL mixes parents and children from
* different sub-trees that happen to share the same `sort_order`, producing
* the scrambled indentation we saw in the by-category report combobox.
* Doing the DFS client-side keeps rendering correct regardless of query shape.
*
* Orphans (category whose parent is missing or inactive / filtered out) are
* emitted at the end, each treated as a pseudo-root, so nothing disappears.
*/
export function sortHierarchical<T extends CategoryLike>(
categories: T[],
resolveName: (cat: T) => string,
): T[] {
if (categories.length === 0) return [];
const ids = new Set<number>();
for (const c of categories) ids.add(c.id);
// Group by parent bucket: root (`null`) or parent id.
const childrenByParent = new Map<number | null, T[]>();
const orphans: T[] = [];
for (const c of categories) {
if (c.parent_id == null) {
const bucket = childrenByParent.get(null) ?? [];
bucket.push(c);
childrenByParent.set(null, bucket);
} else if (ids.has(c.parent_id)) {
const bucket = childrenByParent.get(c.parent_id) ?? [];
bucket.push(c);
childrenByParent.set(c.parent_id, bucket);
} else {
orphans.push(c);
}
}
const compare = (a: T, b: T) => {
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
return resolveName(a).localeCompare(resolveName(b));
};
for (const bucket of childrenByParent.values()) bucket.sort(compare);
orphans.sort(compare);
const out: T[] = [];
const visited = new Set<number>();
const visit = (cat: T) => {
if (visited.has(cat.id)) return; // defensive against cycles
visited.add(cat.id);
out.push(cat);
const kids = childrenByParent.get(cat.id);
if (kids) for (const child of kids) visit(child);
};
const roots = childrenByParent.get(null) ?? [];
for (const root of roots) visit(root);
// Append orphans last, still treated as pseudo-roots so their own children
// (if any were pulled in) follow them.
for (const orphan of orphans) visit(orphan);
return out;
}
export default function CategoryCombobox<T extends CategoryLike = CategoryLike>({
categories,
value,
onChange,
placeholder = "",
compact = false,
ariaLabel,
extraOptions,
onExtraSelect,
activeExtra,
}: CategoryComboboxProps<T>) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0);
const [placement, setPlacement] = useState<ListboxPlacement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listboxId = `${baseId}-listbox`;
const optionId = (i: number) => `${baseId}-option-${i}`;
const depths = useMemo(() => computeDepths(categories), [categories]);
// Resolve the display name for a category: seed rows carry an i18n_key that
// we translate with name as defaultValue; user-created rows just use name.
const displayName = useCallback(
(c: T) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
[t]
);
// Re-order the (potentially sort_order-globally-sorted) input into proper
// hierarchical DFS order so parents always precede their children and
// siblings stay grouped under the same ancestor.
const orderedCategories = useMemo(
() => sortHierarchical(categories, displayName),
[categories, displayName],
);
const selectedCategory = orderedCategories.find((c) => c.id === value);
const displayLabel =
activeExtra != null
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
: selectedCategory
? displayName(selectedCategory)
: "";
const normalizedQuery = normalize(query);
const filtered = query
? orderedCategories.filter((c) => normalize(displayName(c)).includes(normalizedQuery))
: orderedCategories;
const filteredExtras = extraOptions
? query
? extraOptions.filter((o) => normalize(o.label).includes(normalizedQuery))
: extraOptions
: [];
const totalItems = filteredExtras.length + filtered.length;
useEffect(() => {
if (open && listRef.current) {
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: "nearest" });
}
}, [highlightIndex, open]);
// The open list lives in a portal on document.body (#429): no scrolling
// container clips it. It is placed on its input before the first paint,
// then follows the input on every scroll and resize, even out of the
// visible part of its container; the listeners go with the list.
const updatePlacement = useCallback(() => {
const input = inputRef.current;
if (!input) return;
const next = placeListbox({
anchor: input.getBoundingClientRect(),
viewportHeight: window.innerHeight,
});
setPlacement((prev) => (samePlacement(prev, next) ? prev : next));
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePlacement();
return followAnchor(window, updatePlacement, () => listRef.current);
}, [open, updatePlacement]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
// The list is outside the container in the DOM: a press on an option
// counts as inside, or the list would close before the click selects.
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) {
setOpen(false);
setQuery("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const selectItem = useCallback(
(index: number) => {
if (index < filteredExtras.length) {
onExtraSelect?.(filteredExtras[index].value);
} else {
const cat = filtered[index - filteredExtras.length];
if (cat) onChange(cat.id);
}
setOpen(false);
setQuery("");
inputRef.current?.blur();
},
[filteredExtras, filtered, onChange, onExtraSelect]
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "ArrowDown" || e.key === "Enter") {
e.preventDefault();
setOpen(true);
setHighlightIndex(0);
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightIndex((i) => (i + 1) % totalItems);
break;
case "ArrowUp":
e.preventDefault();
setHighlightIndex((i) => (i - 1 + totalItems) % totalItems);
break;
case "Enter":
e.preventDefault();
if (totalItems > 0) selectItem(highlightIndex);
break;
case "Escape":
e.preventDefault();
setOpen(false);
setQuery("");
inputRef.current?.blur();
break;
}
};
const py = compact ? "py-1" : "py-2";
const px = compact ? "px-2" : "px-3";
const activeId = open && totalItems > 0 ? optionId(highlightIndex) : undefined;
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={open ? query : displayLabel}
placeholder={placeholder || displayLabel}
onChange={(e) => {
setQuery(e.target.value);
setHighlightIndex(0);
if (!open) setOpen(true);
}}
onFocus={() => {
setOpen(true);
setQuery("");
setHighlightIndex(0);
}}
onKeyDown={handleKeyDown}
className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`}
/>
{open && totalItems > 0 && placement && createPortal(
<ul
ref={listRef}
id={listboxId}
role="listbox"
// z-[300]: above every window of the app, the highest at z-[210].
className="fixed z-[300] overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
style={{
top: placement.top,
bottom: placement.bottom,
left: placement.left,
width: placement.width,
maxHeight: LISTBOX_MAX_HEIGHT,
}}
>
{filteredExtras.map((opt, i) => (
<li
key={`extra-${opt.value}`}
id={optionId(i)}
role="option"
aria-selected={i === highlightIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(i)}
onMouseEnter={() => setHighlightIndex(i)}
className={`${px} ${py} text-sm cursor-pointer ${
i === highlightIndex
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{opt.label}
</li>
))}
{filtered.map((cat, i) => {
const idx = filteredExtras.length + i;
const depth = depths.get(cat.id) ?? 0;
const indent = depth > 0 ? " ".repeat(depth) : "";
return (
<li
key={cat.id}
id={optionId(idx)}
role="option"
aria-selected={idx === highlightIndex}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(idx)}
onMouseEnter={() => setHighlightIndex(idx)}
className={`${px} ${py} text-sm cursor-pointer ${
idx === highlightIndex
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
style={depth > 0 ? { paddingLeft: `calc(${compact ? "0.5rem" : "0.75rem"} + ${depth * 1}rem)` } : undefined}
>
<span className="whitespace-pre">{indent}</span>
{displayName(cat)}
</li>
);
})}
</ul>,
document.body,
)}
</div>
);
}