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
248 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|