From 608a192877ed312874b5b8fa61507ced1befef99 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 29 Jun 2026 17:42:54 -0500 Subject: [PATCH] =?UTF-8?q?v0.6.2=20=E2=80=94=20Pantry:=20searchable=20cat?= =?UTF-8?q?egory/subcategory=20comboboxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 38 +++++++ package.json | 1 + src/components/pantry/pantry-item-dialog.tsx | 46 +++----- src/components/ui/combobox.tsx | 108 +++++++++++++++++++ src/lib/pantry/categories.ts | 66 ++++++++++++ 5 files changed, 226 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/combobox.tsx create mode 100644 src/lib/pantry/categories.ts diff --git a/package-lock.json b/package-lock.json index 3f8d8a0..20c6249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7b54d08..f07f1d5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/pantry/pantry-item-dialog.tsx b/src/components/pantry/pantry-item-dialog.tsx index 3861e43..1b6cc95 100644 --- a/src/components/pantry/pantry-item-dialog.tsx +++ b/src/components/pantry/pantry-item-dialog.tsx @@ -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 = { - "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)
- { set("pantryCategory", e.target.value); set("pantrySubcategory", ""); }} - placeholder="e.g. Meat" + onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }} + placeholder="Select category…" /> - - {allCategories.map(c =>
- set("pantrySubcategory", e.target.value)} - placeholder="e.g. Beef" + onChange={v => set("pantrySubcategory", v)} + placeholder="Select subcategory…" disabled={!form.pantryCategory} + emptyText="No subcategories" /> - - {allSubcategories.map(c =>
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx new file mode 100644 index 0000000..07f824f --- /dev/null +++ b/src/components/ui/combobox.tsx @@ -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(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 ( + + + + + + + + {/* Search input */} +
+ + setQuery(e.target.value)} + /> +
+ + {/* Options list */} +
+ {/* Clear option */} + {value && ( + + )} + + {filtered.length === 0 ? ( +

{emptyText}

+ ) : ( + filtered.map(opt => ( + + )) + )} +
+
+
+
+ ); +} diff --git a/src/lib/pantry/categories.ts b/src/lib/pantry/categories.ts new file mode 100644 index 0000000..5cd69f0 --- /dev/null +++ b/src/lib/pantry/categories.ts @@ -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 })); +}