feat(01-03): add slide-out panel, item form, category picker, and collection page

- CategoryPicker combobox with search, select, and inline create
- SlideOutPanel with backdrop, escape key, and slide animation
- ItemForm with all fields, validation, dollar-to-cents conversion
- Root layout with TotalsBar, panel, confirm dialog, and floating add button
- Collection page with card grid grouped by category, empty state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 22:46:24 +01:00
parent b099a47eb4
commit 12fd14ff41
5 changed files with 736 additions and 2 deletions

View File

@@ -0,0 +1,200 @@
import { useState, useRef, useEffect } from "react";
import {
useCategories,
useCreateCategory,
} from "../hooks/useCategories";
interface CategoryPickerProps {
value: number;
onChange: (categoryId: number) => void;
}
export function CategoryPicker({ value, onChange }: CategoryPickerProps) {
const { data: categories = [] } = useCategories();
const createCategory = useCreateCategory();
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// Sync display value when value prop changes
const selectedCategory = categories.find((c) => c.id === value);
const filtered = categories.filter((c) =>
c.name.toLowerCase().includes(inputValue.toLowerCase()),
);
const showCreateOption =
inputValue.trim() !== "" &&
!categories.some(
(c) => c.name.toLowerCase() === inputValue.trim().toLowerCase(),
);
const totalOptions = filtered.length + (showCreateOption ? 1 : 0);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
// Reset input to selected category name
if (selectedCategory) {
setInputValue("");
}
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [selectedCategory]);
function handleSelect(categoryId: number) {
onChange(categoryId);
setInputValue("");
setIsOpen(false);
setHighlightIndex(-1);
}
async function handleCreate() {
const name = inputValue.trim();
if (!name) return;
createCategory.mutate(
{ name, emoji: "\u{1F4E6}" },
{
onSuccess: (newCat) => {
handleSelect(newCat.id);
},
},
);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setIsOpen(true);
e.preventDefault();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightIndex((i) => Math.min(i + 1, totalOptions - 1));
break;
case "ArrowUp":
e.preventDefault();
setHighlightIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
handleSelect(filtered[highlightIndex].id);
} else if (
showCreateOption &&
highlightIndex === filtered.length
) {
handleCreate();
}
break;
case "Escape":
setIsOpen(false);
setHighlightIndex(-1);
setInputValue("");
break;
}
}
// Scroll highlighted option into view
useEffect(() => {
if (highlightIndex >= 0 && listRef.current) {
const option = listRef.current.children[highlightIndex] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
}
}, [highlightIndex]);
return (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls="category-listbox"
aria-activedescendant={
highlightIndex >= 0 ? `category-option-${highlightIndex}` : undefined
}
value={
isOpen
? inputValue
: selectedCategory
? `${selectedCategory.emoji} ${selectedCategory.name}`
: ""
}
placeholder="Search or create category..."
onChange={(e) => {
setInputValue(e.target.value);
setIsOpen(true);
setHighlightIndex(-1);
}}
onFocus={() => {
setIsOpen(true);
setInputValue("");
}}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{isOpen && (
<ul
ref={listRef}
id="category-listbox"
role="listbox"
className="absolute z-20 mt-1 w-full max-h-48 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
>
{filtered.map((cat, i) => (
<li
key={cat.id}
id={`category-option-${i}`}
role="option"
aria-selected={cat.id === value}
className={`px-3 py-2 text-sm cursor-pointer ${
i === highlightIndex
? "bg-blue-50 text-blue-900"
: "hover:bg-gray-50"
} ${cat.id === value ? "font-medium" : ""}`}
onClick={() => handleSelect(cat.id)}
onMouseEnter={() => setHighlightIndex(i)}
>
{cat.emoji} {cat.name}
</li>
))}
{showCreateOption && (
<li
id={`category-option-${filtered.length}`}
role="option"
aria-selected={false}
className={`px-3 py-2 text-sm cursor-pointer border-t border-gray-100 ${
highlightIndex === filtered.length
? "bg-blue-50 text-blue-900"
: "hover:bg-gray-50 text-gray-600"
}`}
onClick={handleCreate}
onMouseEnter={() => setHighlightIndex(filtered.length)}
>
+ Create "{inputValue.trim()}"
</li>
)}
{filtered.length === 0 && !showCreateOption && (
<li className="px-3 py-2 text-sm text-gray-400">
No categories found
</li>
)}
</ul>
)}
</div>
);
}