v0.6.2 — Pantry: searchable category/subcategory comboboxes

This commit is contained in:
Bonna Moon
2026-06-29 17:42:54 -05:00
parent 62a0089ec2
commit 608a192877
5 changed files with 226 additions and 33 deletions

38
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -1492,6 +1493,43 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz",
"integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.1",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",

View File

@@ -30,6 +30,7 @@
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",

View File

@@ -5,6 +5,8 @@ import { Plus, Pencil, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Combobox } from "@/components/ui/combobox";
import { CATEGORY_OPTIONS, getSubcategoryOptions } from "@/lib/pantry/categories";
import type { PantryItem, LocationOption, PlantOption } from "./pantry-client";
const SOURCES = [
@@ -19,15 +21,6 @@ const SOURCES = [
const COMMON_UNITS = ["lbs", "oz", "g", "kg", "bags", "jars", "quart jars", "pint jars", "gallon bags", "quart bags", "containers", "cans", "bottles", "bunches", "loaves", "portions", "each"];
const PRICE_UNITS = ["package", "lb", "oz", "kg", "each", "dozen", "bunch"];
const DEFAULT_CATEGORIES = ["Meat", "Produce", "Dairy & Eggs", "Baked Goods", "Soups & Broths", "Sauces & Condiments", "Grains & Legumes", "Herbs & Spices", "Prepared Meals", "Fruit", "Seafood", "Other"];
const DEFAULT_SUBCATEGORIES: Record<string, string[]> = {
"Meat": ["Beef", "Poultry", "Pork", "Lamb", "Game", "Sausage", "Other"],
"Produce": ["Berries", "Vegetables", "Leafy Greens", "Root Vegetables", "Corn", "Peppers", "Tomatoes", "Other"],
"Dairy & Eggs": ["Butter", "Cheese", "Milk", "Cream", "Eggs", "Other"],
"Baked Goods": ["Bread", "Muffins", "Cookies", "Cake", "Pie", "Crackers", "Other"],
"Fruit": ["Berries", "Stone Fruit", "Apples & Pears", "Citrus", "Tropical", "Other"],
"Seafood": ["Fish", "Shrimp", "Shellfish", "Other"],
};
type Suggestion = {
name: string;
@@ -107,7 +100,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
setForm(f => ({ ...f, [key]: val }));
}
// Load catalog on open (for category/subcategory autocomplete)
// Load catalog on open (for name autocomplete suggestions)
useEffect(() => {
if (!open) return;
fetch("/api/pantry/catalog")
@@ -155,13 +148,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
set("name", s.name);
}
// Subcategory options: merge defaults + previously used
const allCategories = Array.from(new Set([...DEFAULT_CATEGORIES, ...(catalog?.categories ?? [])])).sort();
const subcatDefaults = DEFAULT_SUBCATEGORIES[form.pantryCategory] ?? [];
const subcatFromCatalog = (catalog?.subcategories ?? [])
.filter(s => s.category === form.pantryCategory)
.map(s => s.subcategory);
const allSubcategories = Array.from(new Set([...subcatDefaults, ...subcatFromCatalog])).sort();
const subcategoryOptions = getSubcategoryOptions(form.pantryCategory);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -274,30 +261,23 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Category</Label>
<input
className={inputCls}
list="cat-list"
<Combobox
options={CATEGORY_OPTIONS}
value={form.pantryCategory}
onChange={e => { set("pantryCategory", e.target.value); set("pantrySubcategory", ""); }}
placeholder="e.g. Meat"
onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }}
placeholder="Select category…"
/>
<datalist id="cat-list">
{allCategories.map(c => <option key={c} value={c} />)}
</datalist>
</div>
<div className="space-y-1">
<Label>Subcategory</Label>
<input
className={inputCls}
list="subcat-list"
<Combobox
options={subcategoryOptions}
value={form.pantrySubcategory}
onChange={e => set("pantrySubcategory", e.target.value)}
placeholder="e.g. Beef"
onChange={v => set("pantrySubcategory", v)}
placeholder="Select subcategory…"
disabled={!form.pantryCategory}
emptyText="No subcategories"
/>
<datalist id="subcat-list">
{allSubcategories.map(c => <option key={c} value={c} />)}
</datalist>
</div>
</div>

View File

@@ -0,0 +1,108 @@
"use client";
import { useState, useRef, useEffect } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
export type ComboOption = { value: string; label: string };
type Props = {
options: ComboOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
emptyText?: string;
};
export function Combobox({ options, value, onChange, placeholder = "Select…", disabled, emptyText = "No results" }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const filtered = query
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
const selected = options.find(o => o.value === value);
useEffect(() => {
if (open) {
setQuery("");
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [open]);
return (
<Popover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>
<Popover.Trigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"w-full flex items-center justify-between rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring",
disabled && "opacity-50 cursor-not-allowed",
!selected && "text-muted-foreground"
)}
>
<span className="truncate">{selected?.label ?? placeholder}</span>
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0 ml-2" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 w-[var(--radix-popover-trigger-width)] rounded-md border bg-popover shadow-md p-0 overflow-hidden"
sideOffset={4}
align="start"
>
{/* Search input */}
<div className="flex items-center border-b px-3 py-2 gap-2">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
ref={inputRef}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
{/* Options list */}
<div className="max-h-56 overflow-y-auto py-1">
{/* Clear option */}
{value && (
<button
type="button"
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent flex items-center gap-2"
onClick={() => { onChange(""); setOpen(false); }}
>
clear
</button>
)}
{filtered.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">{emptyText}</p>
) : (
filtered.map(opt => (
<button
key={opt.value}
type="button"
className={cn(
"w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2",
opt.value === value && "font-medium"
)}
onClick={() => { onChange(opt.value); setOpen(false); }}
>
<span>{opt.label}</span>
{opt.value === value && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
</button>
))
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -0,0 +1,66 @@
export type PantryCategoryTree = {
label: string;
subcategories: string[];
};
export const PANTRY_CATEGORIES: PantryCategoryTree[] = [
{
label: "Meat",
subcategories: ["Beef", "Poultry", "Pork", "Lamb", "Game", "Sausage & Cured", "Mixed / Other"],
},
{
label: "Seafood",
subcategories: ["Fish", "Shrimp", "Shellfish", "Other Seafood"],
},
{
label: "Produce",
subcategories: ["Berries", "Tomatoes", "Corn", "Peppers", "Leafy Greens", "Root Vegetables", "Squash & Gourds", "Beans & Peas", "Herbs", "Other Produce"],
},
{
label: "Fruit",
subcategories: ["Berries", "Stone Fruit", "Apples & Pears", "Citrus", "Tropical", "Other Fruit"],
},
{
label: "Dairy & Eggs",
subcategories: ["Butter", "Cheese", "Milk & Cream", "Eggs", "Yogurt", "Other Dairy"],
},
{
label: "Baked Goods",
subcategories: ["Bread & Rolls", "Muffins & Quick Breads", "Cookies & Bars", "Cakes & Pies", "Pastry", "Crackers", "Other Baked Goods"],
},
{
label: "Soups & Broths",
subcategories: ["Broth & Stock", "Soup", "Stew & Chili", "Other"],
},
{
label: "Prepared Meals",
subcategories: ["Casserole", "Pasta & Rice Dishes", "Breakfast", "Leftovers", "Other Meals"],
},
{
label: "Sauces & Condiments",
subcategories: ["Tomato Sauce", "Pesto", "Salsa", "Jam & Jelly", "Pickles & Ferments", "Other Sauces"],
},
{
label: "Grains & Legumes",
subcategories: ["Flour", "Rice & Grains", "Dried Beans", "Lentils", "Pasta", "Oats", "Other"],
},
{
label: "Nuts & Seeds",
subcategories: ["Nuts", "Seeds", "Nut Butter", "Other"],
},
{
label: "Beverages",
subcategories: ["Juice", "Tea & Coffee", "Other Beverages"],
},
{
label: "Other",
subcategories: ["Other"],
},
];
export const CATEGORY_OPTIONS = PANTRY_CATEGORIES.map(c => ({ value: c.label, label: c.label }));
export function getSubcategoryOptions(category: string): { value: string; label: string }[] {
const found = PANTRY_CATEGORIES.find(c => c.label === category);
return (found?.subcategories ?? []).map(s => ({ value: s, label: s }));
}