From 1be6be2be85af836bb04db4d76cadcbdb7cfca30 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Tue, 18 Aug 2026 16:58:45 -0500 Subject: [PATCH] fix(recipe-editor): allow decimal input for quantities and percentages; fix workspace filter alignment and mobile bottom-sheet modal --- src/application/pages/app/index.astro | 10 +- src/components/WorkspaceFilterBar.tsx | 235 ++++++++++++++-- .../recipe-editor/RecipeItemRow.tsx | 60 ++++- src/styles/components/entity-directory.css | 251 +++++++++++++++++- src/styles/responsive.css | 49 +++- 5 files changed, 551 insertions(+), 54 deletions(-) diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index d09612d..bd61dfe 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -214,10 +214,10 @@ const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredien {type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"}, ]; const allSearchResults=normalizedQuery ? [ - ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t))) && (selectedIngredients.length === 0 || (recipeIngredientsMap.get(item.id) ?? new Set()).some(id => selectedIngredients.includes(id)))).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), - ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t)))).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), - ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})), - ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})), + ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t))) && (selectedIngredients.length === 0 || Array.from(recipeIngredientsMap.get(item.id) ?? []).some((id: string) => selectedIngredients.includes(id)))).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), + ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && (selectedTags.length === 0 || parseItemTags(item.tags_json, item.categories_json).some(t => selectedTags.includes(t)))).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), + ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})), + ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery) && selectedTags.length === 0).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})), ].sort((a,b)=>a.name.localeCompare(b.name)):[]; const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults; const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`})); @@ -255,7 +255,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in {tabs.map((tab)=>{tab.label}{tab.count})} (null); const [searchTerm, setSearchTerm] = useState(""); + // Mobile detection state + const [activeMobileCatId, setActiveMobileCatId] = useState(null); + const [menuAlignRight, setMenuAlignRight] = useState(false); + const [chipAlignRight, setChipAlignRight] = useState(false); + const menuRef = useRef(null); const popoverRef = useRef(null); - // Close menus when clicking outside + // Close menus when clicking outside (desktop) useEffect(() => { function handleClickOutside(e: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setMenuOpen(false); - } - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - const target = e.target as HTMLElement; - if (!target.closest(".filter-rule-chip-main")) { - setOpenChipId(null); + if (window.innerWidth > 980) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + const target = e.target as HTMLElement; + if (!target.closest(".filter-rule-chip-main")) { + setOpenChipId(null); + } } } } @@ -92,6 +100,15 @@ export default function WorkspaceFilterBar({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + const handleToggleMenu = () => { + if (!menuOpen && menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + setMenuAlignRight(rect.left + 280 > window.innerWidth - 16); + } + setActiveMobileCatId(null); + setMenuOpen(!menuOpen); + }; + // Parse active filter parameters const isRecipe = type === "recipe"; const isIngredient = type === "ingredient"; @@ -175,7 +192,7 @@ export default function WorkspaceFilterBar({ const categoriesWithActiveSelections = categories.filter(c => hasActiveOptions(c)); const totalActiveCount = categoriesWithActiveSelections.length; - // The chips visible on screen: categories with active selections + the currently open chip (if user just opened one from the menu) + // The chips visible on screen const visibleCategoryIds = Array.from( new Set([ ...categoriesWithActiveSelections.map(c => c.id), @@ -188,13 +205,18 @@ export default function WorkspaceFilterBar({ }; const handleAddCategory = (catId: string) => { - setOpenChipId(catId); - setMenuOpen(false); + if (window.innerWidth <= 980) { + setActiveMobileCatId(catId); + } else { + setOpenChipId(catId); + setMenuOpen(false); + } setSearchTerm(""); }; const handleRemoveCategory = (catId: string) => { if (openChipId === catId) setOpenChipId(null); + if (activeMobileCatId === catId) setActiveMobileCatId(null); const params = new URLSearchParams(window.location.search); const cat = categories.find(c => c.id === catId); @@ -216,6 +238,7 @@ export default function WorkspaceFilterBar({ const handleClearAll = () => { setOpenChipId(null); setMenuOpen(false); + setActiveMobileCatId(null); const params = new URLSearchParams(); if (type) params.set("type", type); if (query) params.set("q", query); @@ -258,6 +281,29 @@ export default function WorkspaceFilterBar({ navigateWithParams(params); }; + const handleOpenChip = (catId: string, e?: MouseEvent) => { + if (window.innerWidth <= 980) { + setActiveMobileCatId(catId); + setMenuOpen(true); + } else { + const isOpening = openChipId !== catId; + if (isOpening) { + const target = (e?.currentTarget as HTMLElement)?.closest(".filter-rule-item-group"); + if (target) { + const rect = target.getBoundingClientRect(); + setChipAlignRight(rect.left + 270 > window.innerWidth - 16); + } + } + setOpenChipId(openChipId === catId ? null : catId); + } + setSearchTerm(""); + }; + + const activeMobileCat = activeMobileCatId ? categories.find(c => c.id === activeMobileCatId) : null; + const activeMobileFilteredOptions = activeMobileCat + ? activeMobileCat.options.filter(opt => opt.name.toLowerCase().includes(searchTerm.toLowerCase())) + : []; + return (
{/* 1. Main Filter Button Trigger in Pills Row */} @@ -265,7 +311,7 @@ export default function WorkspaceFilterBar({ - {/* Filter Categories Menu */} + {/* Desktop Filter Categories Menu */} {menuOpen && ( -