Files
silo/web/src/components/items/CategoryPicker.tsx
Forbes ba92dd363c fix(web): align all spacing values to 4px grid
Standardize all spacing to multiples of 4px (0.25rem):
- 0.15rem (2.4px) → 0.25rem (4px)
- 0.35rem (5.6px) → 0.25rem (4px)
- 0.375rem (6px) → 0.25rem (4px) for borderRadius
- 0.4rem (6.4px) → 0.5rem (8px)
- 0.6rem (9.6px) → 0.5rem (8px)

Updated theme.css density variables, silo-base.css focus ring,
and all TSX component inline styles.

Closes #71
2026-02-14 13:36:22 -06:00

248 lines
7.5 KiB
TypeScript

import { useState, useMemo, useRef, useEffect } from "react";
import type { CategoryPickerStage } from "../../api/types";
interface CategoryPickerProps {
value: string;
onChange: (code: string) => void;
categories: Record<string, string>;
stages?: CategoryPickerStage[];
}
export function CategoryPicker({
value,
onChange,
categories,
stages,
}: CategoryPickerProps) {
const [selectedDomain, setSelectedDomain] = useState<string>("");
const [search, setSearch] = useState("");
const selectedRef = useRef<HTMLDivElement>(null);
// Derive domain from current value
useEffect(() => {
if (value && value.length > 0) {
setSelectedDomain(value[0]!);
}
}, [value]);
const isMultiStage = stages && stages.length >= 2;
// Domain stage (first stage)
const domainStage = isMultiStage ? stages[0] : undefined;
const subcatStage = isMultiStage
? stages.find((s) => s.values_by_domain)
: undefined;
// Filtered categories for current domain in multi-stage mode
const filteredCategories = useMemo(() => {
if (!isMultiStage || !selectedDomain || !subcatStage?.values_by_domain) {
return categories;
}
return subcatStage.values_by_domain[selectedDomain] ?? {};
}, [isMultiStage, selectedDomain, subcatStage, categories]);
const entries = useMemo(() => {
const all = Object.entries(filteredCategories).sort(([a], [b]) =>
a.localeCompare(b),
);
if (!search) return all;
const q = search.toLowerCase();
return all.filter(
([code, desc]) =>
code.toLowerCase().includes(q) || desc.toLowerCase().includes(q),
);
}, [filteredCategories, search]);
// Scroll selected into view on mount.
useEffect(() => {
selectedRef.current?.scrollIntoView({ block: "nearest" });
}, []);
return (
<div
style={{
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
backgroundColor: "var(--ctp-base)",
overflow: "hidden",
}}
>
{/* Multi-stage domain picker */}
{isMultiStage && domainStage?.values && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
padding: "0.5rem 0.5rem",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
}}
>
{Object.entries(domainStage.values)
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, label]) => {
const isActive = code === selectedDomain;
return (
<button
key={code}
onClick={() => {
setSelectedDomain(code);
setSearch("");
// Clear selection if switching domain
if (value && value[0] !== code) {
onChange("");
}
}}
style={{
padding: "0.25rem 0.5rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor: isActive
? "rgba(203,166,247,0.2)"
: "transparent",
color: isActive
? "var(--ctp-mauve)"
: "var(--ctp-subtext0)",
transition: "all 0.15s ease",
}}
>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{code}
</span>{" "}
{label}
</button>
);
})}
</div>
)}
{/* Search */}
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={
isMultiStage && !selectedDomain
? "Select a domain above..."
: "Search categories..."
}
disabled={isMultiStage && !selectedDomain}
style={{
width: "100%",
padding: "0.5rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
color: "var(--ctp-text)",
outline: "none",
boxSizing: "border-box",
}}
/>
{/* Scrollable list */}
<div style={{ maxHeight: 200, overflowY: "auto" }}>
{isMultiStage && !selectedDomain ? (
<div
style={{
padding: "0.75rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
fontSize: "var(--font-table)",
}}
>
Select a domain to see categories
</div>
) : entries.length === 0 ? (
<div
style={{
padding: "0.75rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
fontSize: "var(--font-table)",
}}
>
No categories found
</div>
) : (
entries.map(([code, desc]) => {
const isSelected = code === value;
return (
<div
key={code}
ref={isSelected ? selectedRef : undefined}
onClick={() => onChange(code)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.25rem 0.5rem",
cursor: "pointer",
fontSize: "var(--font-table)",
backgroundColor: isSelected
? "rgba(203,166,247,0.12)"
: "transparent",
color: isSelected ? "var(--ctp-mauve)" : "var(--ctp-text)",
fontWeight: isSelected ? 600 : 400,
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor =
"var(--ctp-surface0)";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
flexShrink: 0,
width: 48,
}}
>
{code}
</span>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{desc}
</span>
</div>
);
})
)}
</div>
{/* Selected breadcrumb */}
{value && categories[value] && (
<div
style={{
padding: "0.25rem 0.5rem",
fontSize: "0.75rem",
color: "var(--ctp-subtext0)",
borderTop: "1px solid var(--ctp-surface0)",
backgroundColor: "var(--ctp-mantle)",
}}
>
Selected:{" "}
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
{value}
</span>{" "}
{categories[value]}
</div>
)}
</div>
);
}