From a936b736ded593f31534f0364300ba0da66467b5 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 10 Jan 2026 17:34:10 +0100 Subject: [PATCH] feat: add AND/OR logic mode toggle to filter panel - Add LogicModeToggle component to switch between AND and OR filter logic - Enable multi-select for category and icon filters in OR mode - Update Search component to handle both AND and OR filtering logic - Resize Toggle component to match LogicModeToggle size (44px x 24px) - Position logic mode toggle on the left side of filter panel - Auto-convert arrays to single values when switching from OR to AND mode - In OR mode: recipes match if they satisfy ANY active filter - In AND mode: recipes must satisfy ALL active filters --- src/lib/components/CategoryFilter.svelte | 62 ++++++--- src/lib/components/FavoritesFilter.svelte | 2 +- src/lib/components/FilterPanel.svelte | 17 ++- src/lib/components/IconFilter.svelte | 59 ++++++--- src/lib/components/LogicModeToggle.svelte | 145 ++++++++++++++++++++++ src/lib/components/Search.svelte | 89 +++++++++---- src/lib/components/Toggle.svelte | 10 +- 7 files changed, 319 insertions(+), 65 deletions(-) create mode 100644 src/lib/components/LogicModeToggle.svelte diff --git a/src/lib/components/CategoryFilter.svelte b/src/lib/components/CategoryFilter.svelte index 5b3622f..183dd27 100644 --- a/src/lib/components/CategoryFilter.svelte +++ b/src/lib/components/CategoryFilter.svelte @@ -6,13 +6,21 @@ categories = [], selected = null, onChange = () => {}, - lang = 'de' + lang = 'de', + useAndLogic = true } = $props(); const isEnglish = $derived(lang === 'en'); const label = $derived(isEnglish ? 'Category' : 'Kategorie'); const selectLabel = $derived(isEnglish ? 'Select category...' : 'Kategorie auswählen...'); + // Convert selected to array for OR mode, keep as single value for AND mode + const selectedArray = $derived( + useAndLogic + ? (selected ? [selected] : []) + : (Array.isArray(selected) ? selected : (selected ? [selected] : [])) + ); + let inputValue = $state(''); let dropdownOpen = $state(false); @@ -37,9 +45,22 @@ } function handleCategorySelect(category) { - onChange(category); - inputValue = ''; - dropdownOpen = false; + if (useAndLogic) { + // AND mode: single select + onChange(category); + inputValue = ''; + dropdownOpen = false; + } else { + // OR mode: multi-select toggle + const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []); + if (currentSelected.includes(category)) { + const newSelected = currentSelected.filter(c => c !== category); + onChange(newSelected.length > 0 ? newSelected : null); + } else { + onChange([...currentSelected, category]); + } + inputValue = ''; + } } function handleKeyDown(event) { @@ -49,8 +70,7 @@ const matchedCat = categories.find(c => c.toLowerCase() === value.toLowerCase()) || filteredCategories[0]; if (matchedCat) { - onChange(matchedCat); - inputValue = ''; + handleCategorySelect(matchedCat); } } else if (event.key === 'Escape') { dropdownOpen = false; @@ -58,8 +78,14 @@ } } - function handleRemove() { - onChange(null); + function handleRemove(category) { + if (useAndLogic) { + onChange(null); + } else { + const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []); + const newSelected = currentSelected.filter(c => c !== category); + onChange(newSelected.length > 0 ? newSelected : null); + } } @@ -188,7 +214,7 @@ {#each filteredCategories as category} handleCategorySelect(category)} /> @@ -197,15 +223,17 @@ {/if} - - {#if selected} + + {#if selectedArray.length > 0}
- + {#each selectedArray as category} + handleRemove(category)} + /> + {/each}
{/if} diff --git a/src/lib/components/FavoritesFilter.svelte b/src/lib/components/FavoritesFilter.svelte index 55b9d92..6885241 100644 --- a/src/lib/components/FavoritesFilter.svelte +++ b/src/lib/components/FavoritesFilter.svelte @@ -27,7 +27,7 @@ .filter-section { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.3rem; max-width: 100%; align-items: center; } diff --git a/src/lib/components/FilterPanel.svelte b/src/lib/components/FilterPanel.svelte index 3fc1aff..f5a4394 100644 --- a/src/lib/components/FilterPanel.svelte +++ b/src/lib/components/FilterPanel.svelte @@ -5,6 +5,7 @@ import IconFilter from './IconFilter.svelte'; import SeasonFilter from './SeasonFilter.svelte'; import FavoritesFilter from './FavoritesFilter.svelte'; + import LogicModeToggle from './LogicModeToggle.svelte'; let { availableCategories = [], @@ -15,6 +16,7 @@ selectedIcon = null, selectedSeasons = [], selectedFavoritesOnly = false, + useAndLogic = true, lang = 'de', isLoggedIn = false, hideFavoritesFilter = false, @@ -22,7 +24,8 @@ onTagToggle = () => {}, onIconChange = () => {}, onSeasonChange = () => {}, - onFavoritesToggle = () => {} + onFavoritesToggle = () => {}, + onLogicModeToggle = () => {} } = $props(); const isEnglish = $derived(lang === 'en'); @@ -85,11 +88,11 @@ } .filter-panel.with-favorites { - grid-template-columns: 120px 120px 1fr 160px 90px; + grid-template-columns: 110px 120px 120px 1fr 160px 90px; } .filter-panel.without-favorites { - grid-template-columns: 120px 120px 1fr 160px; + grid-template-columns: 110px 120px 120px 1fr 160px; } @media (max-width: 968px) { @@ -135,11 +138,18 @@
+ + {}, - lang = 'de' + lang = 'de', + useAndLogic = true } = $props(); const isEnglish = $derived(lang === 'en'); const label = 'Icon'; const selectLabel = $derived(isEnglish ? 'Select icon...' : 'Icon auswählen...'); + // Convert selected to array for OR mode, keep as single value for AND mode + const selectedArray = $derived( + useAndLogic + ? (selected ? [selected] : []) + : (Array.isArray(selected) ? selected : (selected ? [selected] : [])) + ); + let inputValue = $state(''); let dropdownOpen = $state(false); @@ -37,9 +45,22 @@ } function handleIconSelect(icon) { - onChange(icon); - inputValue = ''; - dropdownOpen = false; + if (useAndLogic) { + // AND mode: single select + onChange(icon); + inputValue = ''; + dropdownOpen = false; + } else { + // OR mode: multi-select toggle + const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []); + if (currentSelected.includes(icon)) { + const newSelected = currentSelected.filter(i => i !== icon); + onChange(newSelected.length > 0 ? newSelected : null); + } else { + onChange([...currentSelected, icon]); + } + inputValue = ''; + } } function handleKeyDown(event) { @@ -49,8 +70,14 @@ } } - function handleRemove() { - onChange(null); + function handleRemove(icon) { + if (useAndLogic) { + onChange(null); + } else { + const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []); + const newSelected = currentSelected.filter(i => i !== icon); + onChange(newSelected.length > 0 ? newSelected : null); + } } @@ -180,7 +207,7 @@ {#each filteredIcons as icon} handleIconSelect(icon)} /> @@ -189,15 +216,17 @@ {/if}
- - {#if selected} + + {#if selectedArray.length > 0}
- + {#each selectedArray as icon} + handleRemove(icon)} + /> + {/each}
{/if} diff --git a/src/lib/components/LogicModeToggle.svelte b/src/lib/components/LogicModeToggle.svelte new file mode 100644 index 0000000..51dc18c --- /dev/null +++ b/src/lib/components/LogicModeToggle.svelte @@ -0,0 +1,145 @@ + + + + +
+
{label}
+
+ {andLabel} +
checked = !checked} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); checked = !checked; } }} + > +
+
+ {orLabel} +
+
diff --git a/src/lib/components/Search.svelte b/src/lib/components/Search.svelte index f6a7b97..638a2f5 100644 --- a/src/lib/components/Search.svelte +++ b/src/lib/components/Search.svelte @@ -42,6 +42,7 @@ let selectedIcon = $state(null); let selectedSeasons = $state([]); let selectedFavoritesOnly = $state(false); + let useAndLogic = $state(true); // Initialize from props (for backwards compatibility) $effect(() => { @@ -55,38 +56,61 @@ // Apply non-text filters (category, tags, icon, season) function applyNonTextFilters(recipeList) { return recipeList.filter(recipe => { - // Category filter - if (selectedCategory && recipe.category !== selectedCategory) { - return false; - } - - // Multi-tag AND logic: recipe must have ALL selected tags - if (selectedTags.length > 0) { - const recipeTags = recipe.tags || []; - if (!selectedTags.every(tag => recipeTags.includes(tag))) { + if (useAndLogic) { + // AND mode: recipe must satisfy ALL active filters + // Category filter (single value in AND mode) + if (selectedCategory && recipe.category !== selectedCategory) { return false; } - } - // Icon filter - if (selectedIcon && recipe.icon !== selectedIcon) { - return false; - } + // Multi-tag AND logic: recipe must have ALL selected tags + if (selectedTags.length > 0) { + const recipeTags = recipe.tags || []; + if (!selectedTags.every(tag => recipeTags.includes(tag))) { + return false; + } + } - // Season filter: recipe in any selected season - if (selectedSeasons.length > 0) { - const recipeSeasons = recipe.season || []; - if (!selectedSeasons.some(s => recipeSeasons.includes(s))) { + // Icon filter (single value in AND mode) + if (selectedIcon && recipe.icon !== selectedIcon) { return false; } - } - // Favorites filter - if (selectedFavoritesOnly && !recipe.isFavorite) { - return false; - } + // Season filter: recipe in any selected season + if (selectedSeasons.length > 0) { + const recipeSeasons = recipe.season || []; + if (!selectedSeasons.some(s => recipeSeasons.includes(s))) { + return false; + } + } - return true; + // Favorites filter + if (selectedFavoritesOnly && !recipe.isFavorite) { + return false; + } + + return true; + } else { + // OR mode: recipe must satisfy AT LEAST ONE active filter + const categoryArray = Array.isArray(selectedCategory) ? selectedCategory : (selectedCategory ? [selectedCategory] : []); + const iconArray = Array.isArray(selectedIcon) ? selectedIcon : (selectedIcon ? [selectedIcon] : []); + + const hasActiveFilters = categoryArray.length > 0 || selectedTags.length > 0 || iconArray.length > 0 || selectedSeasons.length > 0 || selectedFavoritesOnly; + + // If no filters active, return all + if (!hasActiveFilters) { + return true; + } + + // Check if recipe matches any filter + const matchesCategory = categoryArray.length > 0 ? categoryArray.includes(recipe.category) : false; + const matchesTags = selectedTags.length > 0 ? selectedTags.some(tag => (recipe.tags || []).includes(tag)) : false; + const matchesIcon = iconArray.length > 0 ? iconArray.includes(recipe.icon) : false; + const matchesSeasons = selectedSeasons.length > 0 ? selectedSeasons.some(s => (recipe.season || []).includes(s)) : false; + const matchesFavorites = selectedFavoritesOnly ? recipe.isFavorite : false; + + return matchesCategory || matchesTags || matchesIcon || matchesSeasons || matchesFavorites; + } }); } @@ -186,6 +210,20 @@ selectedFavoritesOnly = enabled; } + function handleLogicModeToggle(useAnd) { + useAndLogic = useAnd; + + // When switching to AND mode, convert arrays to single values + if (useAnd) { + if (Array.isArray(selectedCategory)) { + selectedCategory = selectedCategory.length > 0 ? selectedCategory[0] : null; + } + if (Array.isArray(selectedIcon)) { + selectedIcon = selectedIcon.length > 0 ? selectedIcon[0] : null; + } + } + } + function handleSubmit(event) { if (browser) { // For JS-enabled browsers, prevent default and navigate programmatically @@ -210,6 +248,7 @@ const icn = selectedIcon; const seasons = selectedSeasons; const favsOnly = selectedFavoritesOnly; + const andLogic = useAndLogic; // Set debounce timer const timer = setTimeout(() => { @@ -358,6 +397,7 @@ scale: 0.8 0.8; {selectedIcon} {selectedSeasons} {selectedFavoritesOnly} + {useAndLogic} {lang} {isLoggedIn} hideFavoritesFilter={favoritesOnly} @@ -366,5 +406,6 @@ scale: 0.8 0.8; onIconChange={handleIconChange} onSeasonChange={handleSeasonChange} onFavoritesToggle={handleFavoritesToggle} + onLogicModeToggle={handleLogicModeToggle} /> diff --git a/src/lib/components/Toggle.svelte b/src/lib/components/Toggle.svelte index aaed9c6..53522f6 100644 --- a/src/lib/components/Toggle.svelte +++ b/src/lib/components/Toggle.svelte @@ -30,10 +30,10 @@ .toggle-wrapper input[type="checkbox"] { appearance: none; -webkit-appearance: none; - width: 51px; - height: 31px; + width: 44px; + height: 24px; background: var(--nord2); - border-radius: 31px; + border-radius: 24px; position: relative; cursor: pointer; transition: background 0.3s ease; @@ -55,8 +55,8 @@ .toggle-wrapper input[type="checkbox"]::before { content: ''; position: absolute; - width: 27px; - height: 27px; + width: 20px; + height: 20px; border-radius: 50%; top: 2px; left: 2px;