fix(recipe-editor): allow decimal input for quantities and percentages; fix workspace filter alignment and mobile bottom-sheet modal

This commit is contained in:
2026-08-18 16:58:45 -05:00
parent 7c6563deda
commit 1be6be2be8
5 changed files with 551 additions and 54 deletions
+5 -5
View File
@@ -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/"}, {type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"},
]; ];
const allSearchResults=normalizedQuery ? [ 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}/`})), ...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}/`})), ...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}/`})), ...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`})), ...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)):[]; ].sort((a,b)=>a.name.localeCompare(b.name)):[];
const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults; 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}`})); 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>)} {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 <WorkspaceFilterBar
client:load client:load
type={type} type={type ?? undefined}
query={query} query={query}
recipeCounts={recipeAttentionCounts} recipeCounts={recipeAttentionCounts}
ingredientCounts={ingredientAttentionCounts} ingredientCounts={ingredientAttentionCounts}
+212 -23
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "preact/hooks"; import { useState, useEffect, useRef } from "preact/hooks";
import { createPortal } from "preact/compat";
export interface FilterOption { export interface FilterOption {
id: string; id: string;
@@ -72,19 +73,26 @@ export default function WorkspaceFilterBar({
const [openChipId, setOpenChipId] = useState<string | null>(null); const [openChipId, setOpenChipId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState(""); 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 menuRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
// Close menus when clicking outside // Close menus when clicking outside (desktop)
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { if (window.innerWidth > 980) {
setMenuOpen(false); 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 (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
if (!target.closest(".filter-rule-chip-main")) { const target = e.target as HTMLElement;
setOpenChipId(null); if (!target.closest(".filter-rule-chip-main")) {
setOpenChipId(null);
}
} }
} }
} }
@@ -92,6 +100,15 @@ export default function WorkspaceFilterBar({
return () => document.removeEventListener("mousedown", handleClickOutside); 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 // Parse active filter parameters
const isRecipe = type === "recipe"; const isRecipe = type === "recipe";
const isIngredient = type === "ingredient"; const isIngredient = type === "ingredient";
@@ -175,7 +192,7 @@ export default function WorkspaceFilterBar({
const categoriesWithActiveSelections = categories.filter(c => hasActiveOptions(c)); const categoriesWithActiveSelections = categories.filter(c => hasActiveOptions(c));
const totalActiveCount = categoriesWithActiveSelections.length; 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( const visibleCategoryIds = Array.from(
new Set([ new Set([
...categoriesWithActiveSelections.map(c => c.id), ...categoriesWithActiveSelections.map(c => c.id),
@@ -188,13 +205,18 @@ export default function WorkspaceFilterBar({
}; };
const handleAddCategory = (catId: string) => { const handleAddCategory = (catId: string) => {
setOpenChipId(catId); if (window.innerWidth <= 980) {
setMenuOpen(false); setActiveMobileCatId(catId);
} else {
setOpenChipId(catId);
setMenuOpen(false);
}
setSearchTerm(""); setSearchTerm("");
}; };
const handleRemoveCategory = (catId: string) => { const handleRemoveCategory = (catId: string) => {
if (openChipId === catId) setOpenChipId(null); if (openChipId === catId) setOpenChipId(null);
if (activeMobileCatId === catId) setActiveMobileCatId(null);
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const cat = categories.find(c => c.id === catId); const cat = categories.find(c => c.id === catId);
@@ -216,6 +238,7 @@ export default function WorkspaceFilterBar({
const handleClearAll = () => { const handleClearAll = () => {
setOpenChipId(null); setOpenChipId(null);
setMenuOpen(false); setMenuOpen(false);
setActiveMobileCatId(null);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (type) params.set("type", type); if (type) params.set("type", type);
if (query) params.set("q", query); if (query) params.set("q", query);
@@ -258,6 +281,29 @@ export default function WorkspaceFilterBar({
navigateWithParams(params); 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 ( return (
<div class="workspace-filter-system"> <div class="workspace-filter-system">
{/* 1. Main Filter Button Trigger in Pills Row */} {/* 1. Main Filter Button Trigger in Pills Row */}
@@ -265,7 +311,7 @@ export default function WorkspaceFilterBar({
<button <button
type="button" type="button"
class={`workspace-filter-btn ${totalActiveCount > 0 ? "active" : ""}`} class={`workspace-filter-btn ${totalActiveCount > 0 ? "active" : ""}`}
onClick={() => setMenuOpen(!menuOpen)} onClick={handleToggleMenu}
aria-expanded={menuOpen} aria-expanded={menuOpen}
aria-haspopup="menu" aria-haspopup="menu"
> >
@@ -277,9 +323,9 @@ export default function WorkspaceFilterBar({
<span>Filter{totalActiveCount > 0 ? ` (${totalActiveCount})` : ""}</span> <span>Filter{totalActiveCount > 0 ? ` (${totalActiveCount})` : ""}</span>
</button> </button>
{/* Filter Categories Menu */} {/* Desktop Filter Categories Menu */}
{menuOpen && ( {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"> <div class="filter-menu-header">
<h4>Filter</h4> <h4>Filter</h4>
</div> </div>
@@ -325,7 +371,153 @@ export default function WorkspaceFilterBar({
)} )}
</div> </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 && ( {visibleCategoryIds.length > 0 && (
<div class="filter-rules-row" role="region" aria-label="Active filters"> <div class="filter-rules-row" role="region" aria-label="Active filters">
{visibleCategoryIds.map((catId, index) => { {visibleCategoryIds.map((catId, index) => {
@@ -340,14 +532,11 @@ export default function WorkspaceFilterBar({
<div key={catId} class="filter-rule-item-group"> <div key={catId} class="filter-rule-item-group">
{index > 0 && <span class="filter-connector-and">And</span>} {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 <button
type="button" type="button"
class="filter-rule-chip-main" class="filter-rule-chip-main"
onClick={() => { onClick={(e) => handleOpenChip(catId, e as any)}
setOpenChipId(isOpen ? null : catId);
setSearchTerm("");
}}
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<span class="filter-chip-icon"> <span class="filter-chip-icon">
@@ -375,9 +564,9 @@ export default function WorkspaceFilterBar({
</button> </button>
</div> </div>
{/* Dropdown Popover for Active Chip */} {/* Dropdown Popover for Active Chip (Desktop) */}
{isOpen && ( {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 ? ( {cat.options.length > 0 ? (
<> <>
<div class="filter-chip-search"> <div class="filter-chip-search">
@@ -398,7 +587,7 @@ export default function WorkspaceFilterBar({
<li key={opt.id}> <li key={opt.id}>
<label class="filter-chip-option-label"> <label class="filter-chip-option-label">
<input <input
type="checkbox" type="checkbox"
checked={opt.checked} checked={opt.checked}
onChange={() => handleToggleOption(catId, opt.id, opt.checked)} onChange={() => handleToggleOption(catId, opt.id, opt.checked)}
/> />
+54 -6
View File
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "preact/hooks";
import type { RecipeStructure } from "../../lib/database"; import type { RecipeStructure } from "../../lib/database";
import type { DragState, Option, PercentMode } from "./types"; import type { DragState, Option, PercentMode } from "./types";
@@ -38,6 +39,12 @@ type Props = {
const normal = (value: string) => const normal = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); 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({ export default function RecipeItemRow({
item, item,
componentIndex, componentIndex,
@@ -67,6 +74,31 @@ export default function RecipeItemRow({
onCreatePendingIngredient, onCreatePendingIngredient,
onSetPercent, onSetPercent,
}: Props) { }: 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( const isPending = Boolean(
item.ingredient_id && item.ingredient_id &&
pendingIngredients.some((entry) => entry.id === item.ingredient_id) pendingIngredients.some((entry) => entry.id === item.ingredient_id)
@@ -121,14 +153,21 @@ export default function RecipeItemRow({
> >
<td class="quantity-column"> <td class="quantity-column">
<input <input
ref={qtyInputRef}
type="number" type="number"
min="0" min="0"
step="any" step="any"
aria-label="Quantity" aria-label="Quantity"
value={item.quantity} defaultValue={item.quantity}
onInput={(event) => onBlur={(event) => {
onUpdateQuantity(Number(event.currentTarget.value)) const num = commitNumericInput(event.currentTarget);
} if (num != null) {
qtyTracker.current = num;
onUpdateQuantity(num);
} else {
event.currentTarget.value = String(item.quantity);
}
}}
/> />
</td> </td>
<td class="unit-column"> <td class="unit-column">
@@ -246,6 +285,7 @@ export default function RecipeItemRow({
<span title="A weight equivalency is required"></span> <span title="A weight equivalency is required"></span>
) : ( ) : (
<input <input
ref={pctInputRef}
aria-label={`${label} percentage`} aria-label={`${label} percentage`}
type="number" type="number"
min="0" min="0"
@@ -255,8 +295,16 @@ export default function RecipeItemRow({
: undefined : undefined
} }
step="any" step="any"
value={Math.round(percentValue * 1000) / 1000} defaultValue={percentDisplay}
onInput={(event) => onSetPercent(Number(event.currentTarget.value))} onBlur={(event) => {
const num = commitNumericInput(event.currentTarget);
if (num != null) {
pctTracker.current = num;
onSetPercent(num);
} else {
event.currentTarget.value = percentDisplay != null ? String(percentDisplay) : "";
}
}}
/> />
)} )}
</td> </td>
+243 -8
View File
@@ -219,15 +219,17 @@ input[type="search"]::-webkit-search-results-decoration,
} }
.application-body .workspace-pills > a { .application-body .workspace-pills > a {
min-height: 40px; min-height: 38px;
padding: 8px 16px 8px 12px; padding: 6px 14px 6px 10px;
color: rgba(0,0,0,0.87); color: rgba(0,0,0,0.87);
background: #fff; background: #fff;
border: 1px solid #ececec; border: 1px solid #ececec;
border-radius: 999px; border-radius: 999px;
box-shadow: none; box-shadow: none;
font-size: 16px; font-size: 15px;
font-weight: 400; 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; 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 { .application-body .workspace-filter-trigger-wrapper {
position: relative; position: relative;
margin-left: auto; display: inline-flex;
align-items: center;
flex: none; flex: none;
} }
@@ -361,7 +364,8 @@ input[type="search"]::-webkit-search-results-decoration,
position: absolute; position: absolute;
z-index: 60; z-index: 60;
top: calc(100% + 6px); top: calc(100% + 6px);
right: 0; left: 0;
right: auto;
width: 280px; width: 280px;
max-width: min(280px, calc(100vw - 32px)); max-width: min(280px, calc(100vw - 32px));
max-height: calc(100vh - 120px); 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); 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 { .application-body .filter-menu-header {
padding: 4px 18px 8px; padding: 4px 18px 8px;
} }
@@ -492,6 +506,7 @@ input[type="search"]::-webkit-search-results-decoration,
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
flex-basis: 100%;
padding: 4px 0 0; padding: 4px 0 0;
} }
@@ -620,6 +635,7 @@ input[type="search"]::-webkit-search-results-decoration,
z-index: 70; z-index: 70;
top: calc(100% + 6px); top: calc(100% + 6px);
left: 0; left: 0;
right: auto;
min-width: 260px; min-width: 260px;
max-width: min(340px, calc(100vw - 32px)); max-width: min(340px, calc(100vw - 32px));
max-height: calc(100vh - 120px); 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); 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 { .application-body .filter-chip-search {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -710,10 +736,219 @@ input[type="search"]::-webkit-search-results-decoration,
.application-body .filter-chip-empty-message { .application-body .filter-chip-empty-message {
padding: 16px 12px; padding: 16px 12px;
color: #050841; color: #050841;
font-size: 14px; font-size: 13px;
font-weight: 400; font-style: italic;
text-align: left; 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-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 { 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); } .application-body .entity-directory-toolbar.has-selection { grid-template-columns:36px minmax(0,1fr); }
+37 -12
View File
@@ -519,7 +519,7 @@
border-radius: 6px !important; border-radius: 6px !important;
} }
/* Workspace Pills on Mobile */ /* Workspace Pills on Mobile: Smooth Horizontal Scrolling */
.application-body .directory-workspace { .application-body .directory-workspace {
width: calc(100% - 32px) !important; width: calc(100% - 32px) !important;
margin-inline: auto !important; margin-inline: auto !important;
@@ -528,11 +528,17 @@
.application-body .workspace-pills { .application-body .workspace-pills {
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
flex-wrap: wrap !important; flex-wrap: nowrap !important;
overflow: visible !important; overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
scrollbar-width: none !important;
gap: 8px !important; gap: 8px !important;
padding: 0 0 16px !important; padding: 0 0 4px !important;
margin: 0 !important; margin: 0 !important;
width: 100% !important;
}
.application-body .workspace-pills::-webkit-scrollbar {
display: none !important;
} }
.application-body .workspace-pills > a { .application-body .workspace-pills > a {
min-height: 36px !important; min-height: 36px !important;
@@ -541,20 +547,39 @@
flex: 0 0 auto !important; flex: 0 0 auto !important;
white-space: nowrap !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 { .application-body .workspace-filter-btn {
min-height: 36px !important; min-height: 36px !important;
padding: 6px 12px !important; padding: 6px 12px !important;
font-size: 14px !important; font-size: 14px !important;
white-space: nowrap !important;
flex: 0 0 auto !important;
} }
.application-body .workspace-filter-menu-popover { .application-body .filter-rules-row {
max-width: calc(100vw - 32px) !important; display: flex !important;
max-height: calc(100vh - 120px) !important; align-items: center !important;
overflow-y: auto !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 { .application-body .filter-rules-row::-webkit-scrollbar {
max-width: calc(100vw - 32px) !important; display: none !important;
max-height: calc(100vh - 120px) !important; }
overflow-y: auto !important; .application-body .filter-rule-item-group {
flex: 0 0 auto !important;
} }
/* Directory Table Mobile Enhancements */ /* Directory Table Mobile Enhancements */