fix(recipe-editor): allow decimal input for quantities and percentages; fix workspace filter alignment and mobile bottom-sheet modal
This commit is contained in:
@@ -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)=><a class:list={{active:type===tab.type}} href={tab.href ?? `/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
|
||||
<WorkspaceFilterBar
|
||||
client:load
|
||||
type={type}
|
||||
type={type ?? undefined}
|
||||
query={query}
|
||||
recipeCounts={recipeAttentionCounts}
|
||||
ingredientCounts={ingredientAttentionCounts}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "preact/hooks";
|
||||
import { createPortal } from "preact/compat";
|
||||
|
||||
export interface FilterOption {
|
||||
id: string;
|
||||
@@ -72,19 +73,26 @@ export default function WorkspaceFilterBar({
|
||||
const [openChipId, setOpenChipId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Mobile detection state
|
||||
const [activeMobileCatId, setActiveMobileCatId] = useState<string | null>(null);
|
||||
const [menuAlignRight, setMenuAlignRight] = useState(false);
|
||||
const [chipAlignRight, setChipAlignRight] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(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 (
|
||||
<div class="workspace-filter-system">
|
||||
{/* 1. Main Filter Button Trigger in Pills Row */}
|
||||
@@ -265,7 +311,7 @@ export default function WorkspaceFilterBar({
|
||||
<button
|
||||
type="button"
|
||||
class={`workspace-filter-btn ${totalActiveCount > 0 ? "active" : ""}`}
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
onClick={handleToggleMenu}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
@@ -277,9 +323,9 @@ export default function WorkspaceFilterBar({
|
||||
<span>Filter{totalActiveCount > 0 ? ` (${totalActiveCount})` : ""}</span>
|
||||
</button>
|
||||
|
||||
{/* Filter Categories Menu */}
|
||||
{/* Desktop Filter Categories Menu */}
|
||||
{menuOpen && (
|
||||
<div class="workspace-filter-menu-popover" role="menu">
|
||||
<div class={`workspace-filter-menu-popover desktop-only-filter-popover ${menuAlignRight ? "align-right" : "align-left"}`} role="menu">
|
||||
<div class="filter-menu-header">
|
||||
<h4>Filter</h4>
|
||||
</div>
|
||||
@@ -325,7 +371,153 @@ export default function WorkspaceFilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Secondary Active Filter Rule Chips Bar */}
|
||||
{/* 2. Mobile Filter & Sort Modal (Meez Parity) */}
|
||||
{menuOpen && typeof document !== "undefined" && createPortal(
|
||||
<div class="mobile-filter-backdrop" role="dialog" aria-modal="true" aria-label="Filter and Sort">
|
||||
<div class="mobile-filter-sheet">
|
||||
<header class="mobile-filter-header">
|
||||
{activeMobileCat ? (
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-filter-back-btn"
|
||||
onClick={() => {
|
||||
setActiveMobileCatId(null);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
aria-label="Back to categories"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<div class="mobile-filter-header-spacer" />
|
||||
)}
|
||||
<h3>{activeMobileCat ? activeMobileCat.label : "Filter & Sort"}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-filter-close-btn"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setActiveMobileCatId(null);
|
||||
}}
|
||||
aria-label="Close filter"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={ICONS.close} />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="mobile-filter-body">
|
||||
{activeMobileCat ? (
|
||||
/* Category Options View */
|
||||
<div class="mobile-filter-category-view">
|
||||
{activeMobileCat.options.length > 0 ? (
|
||||
<>
|
||||
<div class="filter-chip-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={ICONS.search} />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={`Search ${activeMobileCat.label.toLowerCase()}`}
|
||||
value={searchTerm}
|
||||
onInput={e => setSearchTerm((e.target as HTMLInputElement).value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ul class="filter-chip-options-list mobile-options-list">
|
||||
{activeMobileFilteredOptions.length > 0 ? (
|
||||
activeMobileFilteredOptions.map(opt => (
|
||||
<li key={opt.id}>
|
||||
<label class="filter-chip-option-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={opt.checked}
|
||||
onChange={() => handleToggleOption(activeMobileCat.id, opt.id, opt.checked)}
|
||||
/>
|
||||
<span class="option-name">{opt.name}</span>
|
||||
{typeof opt.count === "number" && (
|
||||
<span class="option-count">({opt.count})</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li class="filter-chip-empty">No matching options.</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<div class="filter-chip-empty-message">
|
||||
{activeMobileCat.emptyMessage || "No additional filter options available."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Main Filter & Sort Menu */
|
||||
<div class="mobile-filter-main-view">
|
||||
<div class="mobile-filter-section-heading">
|
||||
<svg viewBox="0 0 18 21" aria-hidden="true" class="mobile-section-icon">
|
||||
{ICONS.filter.map((d, i) => (
|
||||
<path key={i} fill="currentColor" d={d} />
|
||||
))}
|
||||
</svg>
|
||||
<span>Filter</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-filter-card">
|
||||
{categories.map(cat => {
|
||||
const isActive = hasActiveOptions(cat);
|
||||
const activeOptsCount = cat.options.filter(o => o.checked).length;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
class={`mobile-filter-card-item ${isActive ? "active" : ""}`}
|
||||
onClick={() => handleAddCategory(cat.id)}
|
||||
>
|
||||
<div class="filter-item-left">
|
||||
<span class="filter-item-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" d={cat.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="filter-item-label">{cat.label}</span>
|
||||
{activeOptsCount > 0 && (
|
||||
<span class="mobile-active-badge">({activeOptsCount})</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="mobile-item-chevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8.59 16.59 13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="mobile-clear-card">
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-clear-btn"
|
||||
disabled={totalActiveCount === 0}
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 3. Secondary Active Filter Rule Chips Bar (Horizontal Scroll on Mobile) */}
|
||||
{visibleCategoryIds.length > 0 && (
|
||||
<div class="filter-rules-row" role="region" aria-label="Active filters">
|
||||
{visibleCategoryIds.map((catId, index) => {
|
||||
@@ -340,14 +532,11 @@ export default function WorkspaceFilterBar({
|
||||
<div key={catId} class="filter-rule-item-group">
|
||||
{index > 0 && <span class="filter-connector-and">And</span>}
|
||||
|
||||
<div class={`filter-rule-chip ${isOpen ? "open" : ""}`}>
|
||||
<div class={`filter-rule-chip ${isOpen ? "open" : ""}`} data-cat-id={catId}>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-rule-chip-main"
|
||||
onClick={() => {
|
||||
setOpenChipId(isOpen ? null : catId);
|
||||
setSearchTerm("");
|
||||
}}
|
||||
onClick={(e) => handleOpenChip(catId, e as any)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span class="filter-chip-icon">
|
||||
@@ -375,9 +564,9 @@ export default function WorkspaceFilterBar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Popover for Active Chip */}
|
||||
{/* Dropdown Popover for Active Chip (Desktop) */}
|
||||
{isOpen && (
|
||||
<div class="filter-chip-popover" ref={popoverRef}>
|
||||
<div class={`filter-chip-popover desktop-only-filter-popover ${chipAlignRight ? "align-right" : "align-left"}`} ref={popoverRef}>
|
||||
{cat.options.length > 0 ? (
|
||||
<>
|
||||
<div class="filter-chip-search">
|
||||
@@ -398,7 +587,7 @@ export default function WorkspaceFilterBar({
|
||||
<li key={opt.id}>
|
||||
<label class="filter-chip-option-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
type="checkbox"
|
||||
checked={opt.checked}
|
||||
onChange={() => handleToggleOption(catId, opt.id, opt.checked)}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { RecipeStructure } from "../../lib/database";
|
||||
import type { DragState, Option, PercentMode } from "./types";
|
||||
|
||||
@@ -38,6 +39,12 @@ type Props = {
|
||||
const normal = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
|
||||
/** Commit a number input's value, returning the parsed number or undefined. */
|
||||
const commitNumericInput = (input: HTMLInputElement): number | undefined => {
|
||||
const num = Number(input.value);
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
};
|
||||
|
||||
export default function RecipeItemRow({
|
||||
item,
|
||||
componentIndex,
|
||||
@@ -67,6 +74,31 @@ export default function RecipeItemRow({
|
||||
onCreatePendingIngredient,
|
||||
onSetPercent,
|
||||
}: Props) {
|
||||
// Uncontrolled refs for numeric inputs — avoids Preact fighting
|
||||
// intermediate values like "2." during decimal entry.
|
||||
const qtyInputRef = useRef<HTMLInputElement>(null);
|
||||
const qtyTracker = useRef(item.quantity);
|
||||
useEffect(() => {
|
||||
// Sync DOM value when parent changes externally (e.g. percent-driven)
|
||||
if (item.quantity !== qtyTracker.current && qtyInputRef.current) {
|
||||
qtyTracker.current = item.quantity;
|
||||
if (document.activeElement !== qtyInputRef.current) {
|
||||
qtyInputRef.current.value = String(item.quantity);
|
||||
}
|
||||
}
|
||||
}, [item.quantity]);
|
||||
|
||||
const pctInputRef = useRef<HTMLInputElement>(null);
|
||||
const percentDisplay = percentValue != null ? Math.round(percentValue * 1000) / 1000 : undefined;
|
||||
const pctTracker = useRef(percentDisplay);
|
||||
useEffect(() => {
|
||||
if (percentDisplay !== pctTracker.current && pctInputRef.current) {
|
||||
pctTracker.current = percentDisplay;
|
||||
if (document.activeElement !== pctInputRef.current) {
|
||||
pctInputRef.current.value = percentDisplay != null ? String(percentDisplay) : "";
|
||||
}
|
||||
}
|
||||
}, [percentDisplay]);
|
||||
const isPending = Boolean(
|
||||
item.ingredient_id &&
|
||||
pendingIngredients.some((entry) => entry.id === item.ingredient_id)
|
||||
@@ -121,14 +153,21 @@ export default function RecipeItemRow({
|
||||
>
|
||||
<td class="quantity-column">
|
||||
<input
|
||||
ref={qtyInputRef}
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="Quantity"
|
||||
value={item.quantity}
|
||||
onInput={(event) =>
|
||||
onUpdateQuantity(Number(event.currentTarget.value))
|
||||
}
|
||||
defaultValue={item.quantity}
|
||||
onBlur={(event) => {
|
||||
const num = commitNumericInput(event.currentTarget);
|
||||
if (num != null) {
|
||||
qtyTracker.current = num;
|
||||
onUpdateQuantity(num);
|
||||
} else {
|
||||
event.currentTarget.value = String(item.quantity);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td class="unit-column">
|
||||
@@ -246,6 +285,7 @@ export default function RecipeItemRow({
|
||||
<span title="A weight equivalency is required">—</span>
|
||||
) : (
|
||||
<input
|
||||
ref={pctInputRef}
|
||||
aria-label={`${label} percentage`}
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -255,8 +295,16 @@ export default function RecipeItemRow({
|
||||
: undefined
|
||||
}
|
||||
step="any"
|
||||
value={Math.round(percentValue * 1000) / 1000}
|
||||
onInput={(event) => onSetPercent(Number(event.currentTarget.value))}
|
||||
defaultValue={percentDisplay}
|
||||
onBlur={(event) => {
|
||||
const num = commitNumericInput(event.currentTarget);
|
||||
if (num != null) {
|
||||
pctTracker.current = num;
|
||||
onSetPercent(num);
|
||||
} else {
|
||||
event.currentTarget.value = percentDisplay != null ? String(percentDisplay) : "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -219,15 +219,17 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
}
|
||||
|
||||
.application-body .workspace-pills > a {
|
||||
min-height: 40px;
|
||||
padding: 8px 16px 8px 12px;
|
||||
min-height: 38px;
|
||||
padding: 6px 14px 6px 10px;
|
||||
color: rgba(0,0,0,0.87);
|
||||
background: #fff;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 999px;
|
||||
box-shadow: none;
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
transition: background-color .15s cubic-bezier(0.4,0,0.2,1), border-color .15s cubic-bezier(0.4,0,0.2,1), color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
@@ -316,7 +318,8 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
|
||||
.application-body .workspace-filter-trigger-wrapper {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -361,7 +364,8 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
width: 280px;
|
||||
max-width: min(280px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 120px);
|
||||
@@ -375,6 +379,16 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
animation: meez-menu-in 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.application-body .workspace-filter-menu-popover.align-right {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.application-body .workspace-filter-menu-popover.align-left {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.application-body .filter-menu-header {
|
||||
padding: 4px 18px 8px;
|
||||
}
|
||||
@@ -492,6 +506,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
@@ -620,6 +635,7 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
z-index: 70;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: auto;
|
||||
min-width: 260px;
|
||||
max-width: min(340px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 120px);
|
||||
@@ -633,6 +649,16 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
animation: meez-menu-in 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.application-body .filter-chip-popover.align-right {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.application-body .filter-chip-popover.align-left {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.application-body .filter-chip-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -710,10 +736,219 @@ input[type="search"]::-webkit-search-results-decoration,
|
||||
.application-body .filter-chip-empty-message {
|
||||
padding: 16px 12px;
|
||||
color: #050841;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.application-body .filter-chip-empty {
|
||||
padding: 12px 8px;
|
||||
color: #8b93a7;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Mobile Filter & Sort Modal (Meez Parity) */
|
||||
.mobile-filter-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.desktop-only-filter-popover {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-filter-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
background: rgba(5, 8, 65, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: meez-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes meez-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.mobile-filter-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background: #ffffff !important;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -8px 32px rgba(5, 8, 65, 0.18);
|
||||
animation: meez-slide-up 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
@keyframes meez-slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mobile-filter-header {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
padding: 0 8px;
|
||||
background: #ffffff !important;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.mobile-filter-header h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #050841;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-filter-back-btn,
|
||||
.mobile-filter-close-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #050841;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mobile-filter-back-btn svg,
|
||||
.mobile-filter-close-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.mobile-filter-body {
|
||||
padding: 16px;
|
||||
background: #ffffff !important;
|
||||
overflow-y: auto;
|
||||
max-height: calc(85vh - 56px);
|
||||
}
|
||||
|
||||
.mobile-filter-main-view,
|
||||
.mobile-filter-category-view {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.mobile-filter-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: #050841;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-section-icon {
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
color: #3d5df6;
|
||||
}
|
||||
|
||||
.mobile-filter-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mobile-filter-card-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
background: #ffffff;
|
||||
color: #050841;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-filter-card-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.mobile-filter-card-item.active {
|
||||
color: #3d5df6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-active-badge {
|
||||
margin-left: 6px;
|
||||
color: #3d5df6;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-item-chevron {
|
||||
color: #a5a9c1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-item-chevron svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-clear-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-clear-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #3d5df6;
|
||||
font-family: var(--meez-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-clear-btn:disabled {
|
||||
color: #a5a9c1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mobile-options-list {
|
||||
max-height: calc(85vh - 160px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.application-body .entity-directory-table { overflow:visible; border:0; background:#fff; }
|
||||
.application-body .entity-directory-toolbar { grid-template-columns:36px 64px minmax(0,1fr) auto; gap:8px; min-height:57px; padding:0; background:#ffffff; border-bottom:1px solid #edf0f5; }
|
||||
.application-body .entity-directory-toolbar.has-selection { grid-template-columns:36px minmax(0,1fr); }
|
||||
|
||||
+37
-12
@@ -519,7 +519,7 @@
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* Workspace Pills on Mobile */
|
||||
/* Workspace Pills on Mobile: Smooth Horizontal Scrolling */
|
||||
.application-body .directory-workspace {
|
||||
width: calc(100% - 32px) !important;
|
||||
margin-inline: auto !important;
|
||||
@@ -528,11 +528,17 @@
|
||||
.application-body .workspace-pills {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
flex-wrap: wrap !important;
|
||||
overflow: visible !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
scrollbar-width: none !important;
|
||||
gap: 8px !important;
|
||||
padding: 0 0 16px !important;
|
||||
padding: 0 0 4px !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.application-body .workspace-pills::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
.application-body .workspace-pills > a {
|
||||
min-height: 36px !important;
|
||||
@@ -541,20 +547,39 @@
|
||||
flex: 0 0 auto !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.application-body .workspace-filter-system {
|
||||
display: contents !important;
|
||||
}
|
||||
.application-body .workspace-filter-trigger-wrapper {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
flex: 0 0 auto !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.application-body .workspace-filter-btn {
|
||||
min-height: 36px !important;
|
||||
padding: 6px 12px !important;
|
||||
font-size: 14px !important;
|
||||
white-space: nowrap !important;
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
.application-body .workspace-filter-menu-popover {
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
max-height: calc(100vh - 120px) !important;
|
||||
overflow-y: auto !important;
|
||||
.application-body .filter-rules-row {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
scrollbar-width: none !important;
|
||||
gap: 8px !important;
|
||||
padding: 2px 0 8px !important;
|
||||
width: 100% !important;
|
||||
flex-basis: 100% !important;
|
||||
}
|
||||
.application-body .filter-chip-popover {
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
max-height: calc(100vh - 120px) !important;
|
||||
overflow-y: auto !important;
|
||||
.application-body .filter-rules-row::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
.application-body .filter-rule-item-group {
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
/* Directory Table Mobile Enhancements */
|
||||
|
||||
Reference in New Issue
Block a user