batchCooking/apps/web/src/features/recipes/IngredientPicker.tsx
Nicolas 8d1c763add fix(recipes): menu d'options d'affichage explicite dans le picker d'ingrédients
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>
2026-08-18 13:35:02 +02:00

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>
);
}