Remplace les deux boutons icône seule (régimes/allergènes) par un bouton réglages unique ouvrant un menu avec deux cases à cocher labellisées — les icônes seules n'étaient pas assez explicites sur ce qu'elles activaient/désactivaient. En chemin, corrige un bug réel découvert pendant l'implémentation : les cases à cocher rendaient invisibles (la règle globale "selectable card" de global.scss masque le <input type="checkbox"> natif et attend un <span class="check-mark"> + une classe is-selected sur le <label> pour dessiner l'état coché — mes cases n'avaient ni l'un ni l'autre). Corrigé en suivant exactement le même pattern que AllergySelect.tsx. Supprime AllergenIcon (nav-icons.tsx), devenu inutilisé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
import {
|
|
INGREDIENT_CATEGORIES,
|
|
INGREDIENT_CATEGORY_SUBCATEGORIES,
|
|
type IngredientCategory,
|
|
type IngredientSubcategory,
|
|
type IngredientView,
|
|
} from "@batch-cooking/shared";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { SettingsIcon } from "../../layouts/nav-icons";
|
|
import { AllergenBadges } from "./AllergenBadges";
|
|
import { DietBadges } from "./DietBadges";
|
|
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
|
import "./recipes.scss";
|
|
|
|
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
|
|
const ALL = "ALL" as const;
|
|
|
|
/**
|
|
* Browsable ingredient picker — a two-level category/subcategory drill-down
|
|
* plus search plus a card grid, replacing the earlier `IngredientAutocomplete`
|
|
* (a plain type-to-filter dropdown). With 400+ reference ingredients, search
|
|
* alone doesn't scale to actually *finding* one, and a single flat list of
|
|
* 7 aisles wouldn't either (some — "Condiments & épices" — are 100+ items
|
|
* deep) — so picking a category reveals a second row of subcategory chips
|
|
* scoped to it (reset whenever the category changes, since a subcategory
|
|
* from the previous one wouldn't apply). Search still narrows within (or
|
|
* across) whatever's selected for when the name is already known.
|
|
*
|
|
* Receives `ingredients` as a prop rather than fetching them itself — same
|
|
* rationale as `AllergySelect`/`DietSelect`. `excludeIds` (already-selected
|
|
* ingredients — a recipe's ingredient list, or a profile's disliked list)
|
|
* keeps the same one from being added twice.
|
|
*
|
|
* The settings menu trailing the search input shows/hides the allergen/diet
|
|
* badge rows on every card — a display preference local to this picker (not
|
|
* persisted), for whoever finds two rows of badges per card too noisy while
|
|
* just browsing/searching by name. A labeled checkbox menu rather than two
|
|
* bare icon-only toggle buttons — those turned out too ambiguous on their
|
|
* own (unclear what each icon meant without a label attached).
|
|
*/
|
|
export function IngredientPicker({
|
|
ingredients,
|
|
excludeIds,
|
|
onSelect,
|
|
}: {
|
|
ingredients: IngredientView[];
|
|
excludeIds: number[];
|
|
onSelect: (ingredient: IngredientView) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
|
|
const [subcategory, setSubcategory] = useState<IngredientSubcategory | typeof ALL>(ALL);
|
|
const [query, setQuery] = useState("");
|
|
// Purely a display preference for this picker's own card grid — doesn't
|
|
// touch which ingredients `visible` includes, only whether their
|
|
// allergen/diet badges render. Defaults to shown (the previous, only
|
|
// behavior); collapsing them is an opt-in for whoever finds two rows of
|
|
// badges per card too noisy while just browsing/searching by name.
|
|
const [showAllergens, setShowAllergens] = useState(true);
|
|
const [showDiets, setShowDiets] = useState(true);
|
|
const [isDisplayMenuOpen, setIsDisplayMenuOpen] = useState(false);
|
|
|
|
function selectCategory(next: IngredientCategory | typeof ALL) {
|
|
setCategory(next);
|
|
setSubcategory(ALL);
|
|
}
|
|
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const visible = ingredients.filter((ingredient) => {
|
|
if (excludeIds.includes(ingredient.id)) return false;
|
|
if (category !== ALL) {
|
|
if (ingredient.category !== category) return false;
|
|
if (subcategory !== ALL && ingredient.subcategory !== subcategory) return false;
|
|
}
|
|
if (normalizedQuery.length > 0 && !ingredient.name.toLowerCase().includes(normalizedQuery)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
function handleSelect(ingredient: IngredientView) {
|
|
onSelect(ingredient);
|
|
setQuery("");
|
|
}
|
|
|
|
return (
|
|
<div className="ingredient-picker">
|
|
<div className="ingredient-picker__search">
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
|
/>
|
|
<div className="ingredient-picker__display-options">
|
|
<button
|
|
type="button"
|
|
className={`ingredient-picker__display-toggle${isDisplayMenuOpen ? " active" : ""}`}
|
|
onClick={() => setIsDisplayMenuOpen((v) => !v)}
|
|
aria-expanded={isDisplayMenuOpen}
|
|
title={t("recipes.form.displayOptions")}
|
|
>
|
|
<SettingsIcon />
|
|
</button>
|
|
{isDisplayMenuOpen && (
|
|
<div className="ingredient-picker__display-menu">
|
|
<label className={showDiets ? "is-selected" : ""}>
|
|
<input
|
|
type="checkbox"
|
|
checked={showDiets}
|
|
onChange={(e) => setShowDiets(e.target.checked)}
|
|
/>
|
|
<span className="check-mark" aria-hidden="true" />
|
|
{t("recipes.form.showDietsLabel")}
|
|
</label>
|
|
<label className={showAllergens ? "is-selected" : ""}>
|
|
<input
|
|
type="checkbox"
|
|
checked={showAllergens}
|
|
onChange={(e) => setShowAllergens(e.target.checked)}
|
|
/>
|
|
<span className="check-mark" aria-hidden="true" />
|
|
{t("recipes.form.showAllergensLabel")}
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ingredient-picker__categories">
|
|
<button
|
|
type="button"
|
|
className={`ingredient-picker__category${category === ALL ? " active" : ""}`}
|
|
onClick={() => selectCategory(ALL)}
|
|
>
|
|
{t("recipes.form.allCategories")}
|
|
</button>
|
|
{INGREDIENT_CATEGORIES.map((c) => (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
className={`ingredient-picker__category${category === c ? " active" : ""}`}
|
|
onClick={() => selectCategory(c)}
|
|
>
|
|
<CategoryIcon category={c} />
|
|
{t(`recipes.form.category.${c}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{category !== ALL && (
|
|
<div className="ingredient-picker__subcategories">
|
|
<button
|
|
type="button"
|
|
className={`ingredient-picker__subcategory${subcategory === ALL ? " active" : ""}`}
|
|
onClick={() => setSubcategory(ALL)}
|
|
>
|
|
{t("recipes.form.allSubcategories")}
|
|
</button>
|
|
{INGREDIENT_CATEGORY_SUBCATEGORIES[category].map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
className={`ingredient-picker__subcategory${subcategory === s ? " active" : ""}`}
|
|
onClick={() => setSubcategory(s)}
|
|
>
|
|
<SubcategoryIcon subcategory={s} />
|
|
{t(`recipes.form.subcategory.${s}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{visible.length === 0 ? (
|
|
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
|
|
) : (
|
|
<div className="ingredient-picker__grid">
|
|
{visible.map((ingredient) => (
|
|
<button
|
|
key={ingredient.id}
|
|
type="button"
|
|
className="ingredient-picker__card"
|
|
onClick={() => handleSelect(ingredient)}
|
|
>
|
|
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
|
<IngredientTypeIcon icon={ingredient.icon} />
|
|
</span>
|
|
<span className="ingredient-picker__card-name">{ingredient.name}</span>
|
|
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
|
{showDiets && <DietBadges diets={ingredient.diets} />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|