Swap the native <select> in CategoryZoomHeader for the reusable CategoryCombobox. Enhances the combobox with ARIA compliance (combobox, listbox, option roles + aria-expanded, aria-controls, aria-activedescendant) and hierarchy indentation based on parent_id depth. Adds reports.category.searchPlaceholder in FR/EN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react";
|
|
import type { Category } from "../../shared/types";
|
|
|
|
interface CategoryComboboxProps {
|
|
categories: Category[];
|
|
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: Category[]): Map<number, number> {
|
|
const byId = new Map<number, Category>();
|
|
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;
|
|
}
|
|
|
|
export default function CategoryCombobox({
|
|
categories,
|
|
value,
|
|
onChange,
|
|
placeholder = "",
|
|
compact = false,
|
|
ariaLabel,
|
|
extraOptions,
|
|
onExtraSelect,
|
|
activeExtra,
|
|
}: CategoryComboboxProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
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]);
|
|
|
|
const selectedCategory = categories.find((c) => c.id === value);
|
|
const displayLabel =
|
|
activeExtra != null
|
|
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
|
|
: selectedCategory?.name ?? "";
|
|
|
|
const normalizedQuery = normalize(query);
|
|
const filtered = query
|
|
? categories.filter((c) => normalize(c.name).includes(normalizedQuery))
|
|
: categories;
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.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 && (
|
|
<ul
|
|
ref={listRef}
|
|
id={listboxId}
|
|
role="listbox"
|
|
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
|
|
>
|
|
{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>
|
|
{cat.name}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|