v0.6.2 — Pantry: searchable category/subcategory comboboxes
This commit is contained in:
38
package-lock.json
generated
38
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
108
src/components/ui/combobox.tsx
Normal file
108
src/components/ui/combobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/lib/pantry/categories.ts
Normal file
66
src/lib/pantry/categories.ts
Normal 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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user