Link guide cards to checklists and refine Aniimo ability filters
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s

This commit is contained in:
Shinuwa 2026-09-24 23:55:06 +02:00
parent b5901b31e3
commit 7b23a8601a
23 changed files with 193 additions and 51 deletions

View file

@ -816,10 +816,10 @@ Date picker et menus compacts :
- le compteur met en évidence les Aniimos ou les variantes selon le mode de copie ; l’infobulle du bouton précise le contenu copié ;
- le verso occupe exactement la même surface que le recto ; garder son en-tête fixe et faire défiler les détails dans une zone NDS accessible au clavier ; masquer la face inactive aux technologies d'assistance ;
- réduire l'animation selon `prefers-reduced-motion` ;
- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ;
- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ; les compétences de logis sont toujours cumulatives et leur sélecteur compact `≥ / =`, aligné comme « Correspondance », utilise le niveau minimum par défaut ; les libellés complets restent accessibles ;
- la chip « Prismana uniquement », sous le nom, garde 12 px de marge inférieure, utilise une étoile SVG arc-en-ciel et limite les résultats à `prismana_form` avant regroupement ; inactive par défaut, elle se cumule avec tous les autres critères et se désactive à la réinitialisation ;
- les catégories Rôles, Éléments et Compétences de logis partagent un défilement unique via `filter-options`, comme MHW ; des séparateurs discrets délimitent les catégories et leurs en-têtes permettent de les replier sans désactiver leurs sélections ;
- rôles et éléments sont sur deux colonnes de chips `sm` ; les compétences élémentaires précèdent les autres sans séparation interne, avec icône, texte, curseur et valeur sur une ligne ; un seuil à 0 reste visible mais ne participe pas aux opérations ET/OU ;
- rôles et éléments sont sur deux colonnes de chips `sm` ; les compétences élémentaires précèdent les autres sans séparation interne, avec icône, texte, curseur et valeur sur une ligne ; un seuil à 0 reste visible mais ne participe pas au filtrage ;
- garder le nombre de critères sélectionnés visible dans l'en-tête, même replié.
### Liaison des guides aux checklists
@ -835,9 +835,13 @@ non associées. Les commandes sont centrées et reviennent à la ligne sur mobil
le nombre de correspondances figure dessous. Une liste active devenue incompatible
garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de
catégorie Diablo), avec une coche accessible « Coché ». Les textes restent
catégorie Diablo). Le recto utilise uniquement la checkbox interactive ; la coche
accessible « Coché » reste sur le verso des cartes retournables. Les textes restent
lisibles et les indicateurs absolus ne modifient pas la taille des cartes.
Dans les cartes retournables, les indicateurs appartiennent à chaque face.
Lorsqu'une carte correspond à la checklist associée, une checkbox NDS apparaît en
bas à droite de la zone d'image du recto (ou de la carte pour un affixe sans image).
Elle met à jour toutes les lignes exactes correspondantes, sans retourner la carte ;
pour Aniimo, la forme spécifique reste prioritaire sur l'entrée générale.
## 18. Scrollbars

View file

@ -53,7 +53,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier le bouton toolbox et le panneau latéral.
- Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne.
- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. Les guides ne doivent jamais modifier la progression.
- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. La checkbox des cartes met à jour toutes les lignes exactes correspondantes, respecte les quantités cibles et ne retourne pas les cartes.
- Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message.
- Pour Aniimo, vérifier la copie des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs et copie doivent correspondre aux résultats affichés.

View file

@ -251,6 +251,10 @@ Les lignes étrangères sont ignorées. Une correspondance est terminée lorsque
toutes ses lignes atteignent leur propre `qtyTarget` ; les doublons ne sont pas
additionnés. Renommer une ligne peut supprimer sa correspondance. Une liste encore
sélectionnée mais sans correspondance reste liée, avec un message explicite.
Une checkbox sur la carte d'un guide peut modifier la progression de toutes les
lignes exactes qui lui correspondent : cocher porte `qtyCurrent` à `qtyTarget`,
décocher le remet à `0`. Les données passent par le flux React/IndexedDB existant ;
aucune copie de progression n'est conservée dans l'état de session du guide.
La copie Aniimo utilise `Nom:1` en affichage regroupé et `Nom — Forme:1` en
affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne

View file

@ -149,9 +149,16 @@ test("tabs, flip, matching variants and reset remain independent", async ({ page
test("ability levels use independent native ranges and retain name constraints", async ({ page }) => {
await page.goto(route);
const abilities = section(page, "Compétences de logis");
const carry = page.getByRole("slider", { name: "Porter — Niveau minimum" });
const crafting = page.getByRole("slider", { name: "Artisanat — Niveau minimum" });
const carry = page.locator("#aniimo-minimum-carry");
const crafting = page.locator("#aniimo-minimum-crafting");
await expect(abilities.getByRole("checkbox")).toHaveCount(0);
await expect(abilities.getByRole("button", { name: "OU", exact: true })).toHaveCount(0);
const minimumLevel = abilities.getByRole("button", { name: "Niveau minimum", exact: true });
const exactLevel = abilities.getByRole("button", { name: "Niveau exact", exact: true });
await expect(minimumLevel).toHaveAttribute("aria-pressed", "true");
await expect(exactLevel).toHaveAttribute("aria-pressed", "false");
await expect(carry).toHaveAccessibleName("Porter — Niveau minimum");
await expect(crafting).toHaveAccessibleName("Artisanat — Niveau minimum");
await expect(carry).toHaveValue("0");
await expect(carry).toHaveAttribute("min", "0");
await carry.focus();
@ -165,7 +172,10 @@ test("ability levels use independent native ranges and retain name constraints",
await crafting.press("ArrowRight");
await expect(crafting).toHaveValue("2");
await expect(crafting).toHaveCSS("outline-style", "solid");
await abilities.getByRole("button", { name: "OU", exact: true }).click();
await exactLevel.click();
await expect(exactLevel).toHaveAttribute("aria-pressed", "true");
await expect(minimumLevel).toHaveAttribute("aria-pressed", "false");
await expect(carry).toHaveAccessibleName("Porter — Niveau exact");
await expect(section(page, "Éléments").getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
await carry.press("Home");
await crafting.press("Home");
@ -173,6 +183,9 @@ test("ability levels use independent native ranges and retain name constraints",
await expect(cards(page)).toHaveCount(88);
await page.getByRole("searchbox").fill("does-not-exist");
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
await expect(minimumLevel).toHaveAttribute("aria-pressed", "true");
await expect(exactLevel).toHaveAttribute("aria-pressed", "false");
});
test("seven tabs support keyboard navigation and fall back after filtering", async ({ page }) => {

View file

@ -126,6 +126,7 @@ test("Aniimo copies visible species or forms and round-trips through checklist i
for (const width of [1280, 390]) {
test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => {
test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 });
await seed(page);
await page.goto(route);
@ -164,11 +165,22 @@ for (const width of [1280, 390]) {
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveAccessibleName("Coché");
await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveCount(0);
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
await expect(card.locator(".aniimo-back .guide-checklist-mark")).toHaveAccessibleName("Coché");
await card.getByRole("button", { name: /Afficher le portrait/ }).click();
const cardCheckbox = card.getByRole("checkbox", { name: "Décocher dans la checklist" });
await expect(cardCheckbox).toBeChecked();
await cardCheckbox.uncheck();
await expect(card).not.toHaveAttribute("data-checklist-complete", "true");
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "true");
await expect.poll(async () => (await readModule(page, "forms")).items.find((item) => item.label.includes("Forme de base")).qtyCurrent || 0).toBe(0);
await card.getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await toggleHidden(page);
await expect(card.getByRole("tab")).toHaveCount(2);
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
expect(await readModule(page, "forms")).toEqual(before);
expect(normalizeChecklistData(await readModule(page, "forms")).items).toEqual(normalizeChecklistData(before).items);
await choose(page, "capture");
await assertLink(page, "capture", true);
await expect(card.getByRole("tab")).toHaveCount(3);
@ -214,6 +226,13 @@ for (const [gameId, category, cardClass, dataPath, dataKey] of [
const count = await page.locator(cardClass).count();
await choose(page, "progress");
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
const completedCard = page.locator(`${cardClass}[data-checklist-complete="true"]`);
const cardCheckbox = completedCard.getByRole("checkbox", { name: "Décocher dans la checklist" });
await expect(cardCheckbox).toBeChecked();
await cardCheckbox.uncheck();
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(0);
await page.locator(cardClass).first().getByRole("checkbox", { name: "Cocher dans la checklist" }).check();
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
await toggleHidden(page);
await expect(page.locator(cardClass)).toHaveCount(count - 1);
await page.setViewportSize({ width: 390, height: 844 });

View file

@ -94,10 +94,11 @@ test("element AND never borrows elements or roles from the parent or sibling var
test("ability thresholds remain independent, inclusive and constrained to a single variant", () => {
assert.equal(filter({ abilities: { carry: 3, crafting: 2 } }, fixture).length, 0);
assert.equal(filter({ abilities: { carry: 3, crafting: 2 }, abilitiesLogic: "or" }, fixture).length, 3);
assert.deepEqual(filter({ abilities: { carry: 4, crafting: 2 }, abilitiesLogic: "or" }, fixture).map((v) => v.form), ["water_form", "both_form"]);
assert.deepEqual(filter({ abilities: { carry: 3, crafting: 1 } }, fixture).map((v) => v.form), ["both_form"]);
assert.equal(filter({ elements: ["rock"], roles: ["heal"], abilitiesLogic: "or", abilities: { carry: 1, crafting: 1 } }, fixture).length, 0);
assert.deepEqual(filter({ abilities: { carry: 3 }, abilityExact: true }, fixture).map((v) => v.form), ["rock_form"]);
assert.deepEqual(filter({ abilities: { carry: 4 }, abilityExact: true }, fixture).map((v) => v.form), ["both_form"]);
assert.equal(filter({ abilities: { carry: 2, crafting: 1 }, abilityExact: true }, fixture).length, 0);
assert.equal(filter({ elements: ["rock"], roles: ["heal"], abilities: { carry: 1, crafting: 1 } }, fixture).length, 0);
});
test("grouping preserves order and contains only variants that pass all filters", () => {

View file

@ -1,7 +1,7 @@
// Rôle : vérifie les correspondances des guides et la validité des associations de session.
import assert from "node:assert/strict";
import test from "node:test";
import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, toggleGuideChecklistRows, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
const variants = [{ key: "1:base", name: "emberpup", form: "base" }, { key: "1:mountain", name: "emberpup", form: "mountain" }];
const entries = aniimoChecklistEntries(variants, { emberpup: "Loufeuteau", base: "Forme de base", mountain: "Montagnes" }, { emberpup: "Emberpup", base: "Basic form", mountain: "Mountain form" });
@ -43,6 +43,21 @@ test("all duplicate lines must reach their own target, including translated alia
assert.equal(matchGuideChecklist(entries, {}).count, 0);
});
test("card toggles update every exact matching row to its target or zero", () => {
const data = { sections: [{ id: "s1", items: [
{ id: "a", label: "Loufeuteau", qtyTarget: 3, qtyCurrent: 1 },
{ id: "b", label: "Emberpup", qtyTarget: 2, qtyCurrent: 0 },
{ id: "c", label: "Unrelated", qtyCurrent: 1 }
] }] };
const match = matchGuideChecklist(entries, data);
assert.deepEqual(match.rowIds.get("1:base"), ["a", "b"]);
const completed = toggleGuideChecklistRows(data, match.rowIds.get("1:base"), true);
assert.deepEqual(completed.sections[0].items.map((item) => item.qtyCurrent), [3, 2, 1]);
const cleared = toggleGuideChecklistRows(completed, match.rowIds.get("1:base"), false);
assert.deepEqual(cleared.sections[0].items.map((item) => item.qtyCurrent), [0, 0, 1]);
assert.equal(data.sections[0].items[0].qtyCurrent, 1);
});
test("session associations require the current linked toolbox and an existing checklist", () => {
const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] };
const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };

View file

@ -96,6 +96,8 @@ export function validateSiteContent(site) {
"guideChecklist.unlink",
"guideChecklist.hideCompleted",
"guideChecklist.completed",
"guideChecklist.checkCard",
"guideChecklist.uncheckCard",
"aniimo.copyChecklist",
"aniimo.copyVariantsChecklist",
"aniimo.statsTitle",
@ -118,7 +120,8 @@ export function validateSiteContent(site) {
"aniimo.role",
"aniimo.abilities",
"aniimo.minimum",
"aniimo.abilityLevelsHint",
"aniimo.exactLevel",
"aniimo.levelMode",
"aniimo.abilityDisabled",
"aniimo.level",
"aniimo.match",

View file

@ -2750,7 +2750,9 @@
"matches": "correspondances",
"unlink": "Dissocier",
"hideCompleted": "Masquer les cochés",
"completed": "Coché"
"completed": "Coché",
"checkCard": "Cocher dans la checklist",
"uncheckCard": "Décocher dans la checklist"
},
"aniimo": {
"title": "Aniimos",
@ -2770,7 +2772,8 @@
"role": "Rôle",
"abilities": "Compétences de logis",
"minimum": "Niveau minimum",
"abilityLevelsHint": "0 : sans filtre · 1–4 : niveau minimum",
"exactLevel": "Niveau exact",
"levelMode": "Niveau",
"abilityDisabled": "Sans filtre",
"level": "Niv.",
"match": "Correspondance",

View file

@ -33,3 +33,12 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
export function GuideChecklistMark({ completed, label }) {
return completed ? <span className="guide-checklist-mark nds-badge" data-variant="neutral" role="img" aria-label={label} title={label}>✓</span> : null;
}
export function GuideChecklistCheckbox({ checklist, itemKey }) {
if (!checklist.canToggle(itemKey)) return null;
const completed = checklist.isCompleted(itemKey);
const label = completed ? checklist.content.uncheckCard : checklist.content.checkCard;
return <label className="guide-checklist-checkbox" title={label} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={completed} onChange={() => checklist.toggleCompleted(itemKey)} aria-label={label} />
</label>;
}

View file

@ -1,6 +1,6 @@
// Rôle : affiche une carte Aniimo retournable et les onglets des variantes correspondantes.
import { useId, useLayoutEffect, useRef, useState } from "react";
import { GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox, GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx";
import { ANIIMO_STATS, aniimoAsset } from "./utils.js";
@ -15,7 +15,8 @@ function shortForm(label) {
return (words.length === 1 ? words[0].slice(0, 3) : words.map((word) => word[0]).join("")).toLocaleUpperCase("fr");
}
function CardFaces({ variant, t, content, completed, completedLabel, statsMax }) {
function CardFaces({ variant, t, content, checklist, statsMax }) {
const completed = checklist.isCompleted(variant.key);
const [flipped, setFlipped] = useState(false);
const frontButton = useRef(null);
const backButton = useRef(null);
@ -35,8 +36,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })
<div className="aniimo-card-inner">
<div className="aniimo-face aniimo-front nds-card" aria-hidden={flipped} inert={flipped}>
{flipButton(false, frontButton)}
<GuideChecklistMark completed={completed} label={completedLabel} />
<div className="aniimo-portrait"><img src={variant.image} alt={t(variant.name)} width="512" height="840" loading="lazy" /></div>
<div className="aniimo-portrait"><img src={variant.image} alt={t(variant.name)} width="512" height="840" loading="lazy" /><GuideChecklistCheckbox checklist={checklist} itemKey={variant.key} /></div>
<div className="aniimo-card-body">
<h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p>
@ -45,7 +45,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })
</div>
<div className="aniimo-face aniimo-back nds-card" aria-hidden={!flipped} inert={!flipped}>
{flipButton(true, backButton)}
<GuideChecklistMark completed={completed} label={completedLabel} />
<GuideChecklistMark completed={completed} label={checklist.content.completed} />
<div className="aniimo-card-body aniimo-back-heading">
<h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p>
@ -79,7 +79,7 @@ function CardFaces({ variant, t, content, completed, completedLabel, statsMax })
);
}
export function AniimoCard({ variants, t, content, isCompleted, completedLabel, statsMax }) {
export function AniimoCard({ variants, t, content, checklist, statsMax }) {
const id = useId();
const [selectedKey, setSelectedKey] = useState(variants[0].key);
const variant = variants.find((item) => item.key === selectedKey) || variants[0];
@ -91,7 +91,7 @@ export function AniimoCard({ variants, t, content, isCompleted, completedLabel,
target.parentElement.children[index].focus();
}
return (
<article className="aniimo-result" data-checklist-complete={isCompleted(variant.key) || undefined} data-aniimo={variant.aniimoId} data-form={variant.form} aria-label={t(variant.name)}>
<article className="aniimo-result" data-checklist-complete={checklist.isCompleted(variant.key) || undefined} data-aniimo={variant.aniimoId} data-form={variant.form} aria-label={t(variant.name)}>
<div className="aniimo-tabs-slot">
{multiple && <div className="aniimo-tabs nds-radio-button-group" data-size="xs" role="tablist" aria-label={`${content.variants} — ${t(variant.name)}`}>
{variants.map((item, index) => <Tooltip as="button" content={t(item.form)} aria-label={t(item.form)} key={item.key} type="button" role="tab" data-form={item.form} id={`${id}-${item.form}`} aria-controls={`${id}-panel`} aria-selected={item.key === variant.key} tabIndex={item.key === variant.key ? 0 : -1} className={item.key === variant.key ? "active" : ""} onClick={() => setSelectedKey(item.key)} onKeyDown={(event) => {
@ -103,7 +103,7 @@ export function AniimoCard({ variants, t, content, isCompleted, completedLabel,
</div>}
</div>
<div id={`${id}-panel`} role={multiple ? "tabpanel" : undefined} aria-labelledby={multiple ? `${id}-${variant.form}` : undefined}>
<CardFaces variant={variant} t={t} content={content} completed={isCompleted(variant.key)} completedLabel={completedLabel} statsMax={statsMax} />
<CardFaces variant={variant} t={t} content={content} checklist={checklist} statsMax={statsMax} />
</div>
</article>
);

View file

@ -14,6 +14,16 @@ function LogicChoice({ label, value, onChange, content }) {
);
}
function LevelOperatorIcon({ exact = false }) {
return (
<svg className="aniimo-level-operator-icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
{exact
? <path d="M4.5 7h11M4.5 13h11" />
: <path d="m4.5 3.75 11 4.75-11 4.75M4.5 16.5h11" />}
</svg>
);
}
export function AniimoFilters({ filters, onChange, options, t, content }) {
const [collapsed, setCollapsed] = useState({});
const abilityOptions = [...options.abilities].sort((a, b) => Number(!options.elements.includes(a)) - Number(!options.elements.includes(b)));
@ -46,8 +56,14 @@ export function AniimoFilters({ filters, onChange, options, t, content }) {
<Icon name={collapsed[key] ? "chevron-down" : "chevron-up"} />
</button></legend>
<div id={`aniimo-filter-${key}`} hidden={collapsed[key]}>
{key !== "roles" && <LogicChoice label={content[key]} content={content} value={filters[`${key}Logic`]} onChange={(logic) => onChange({ ...filters, [`${key}Logic`]: logic })} />}
{key === "abilities" && <p className="aniimo-levels-hint" id="aniimo-levels-hint">{content.abilityLevelsHint}</p>}
{key === "elements" && <LogicChoice label={content[key]} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />}
{key === "abilities" && <div className="filter-logic">
<span>{content.levelMode}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={content.levelMode}>
<button type="button" className={!filters.abilityExact ? "active" : ""} aria-label={content.minimum} title={content.minimum} aria-pressed={!filters.abilityExact} onClick={() => onChange({ ...filters, abilityExact: false })}><LevelOperatorIcon /></button>
<button type="button" className={filters.abilityExact ? "active" : ""} aria-label={content.exactLevel} title={content.exactLevel} aria-pressed={filters.abilityExact} onClick={() => onChange({ ...filters, abilityExact: true })}><LevelOperatorIcon exact /></button>
</div>
</div>}
<div className={`aniimo-filter-options ${key === "abilities" ? "aniimo-ability-options" : "aniimo-chip-options"}`} role="region" aria-label={content[key]}>
{(key === "abilities" ? abilityOptions : options[key]).map((option) => {
const isAbility = key === "abilities";
@ -56,7 +72,7 @@ export function AniimoFilters({ filters, onChange, options, t, content }) {
const level = filters.abilities[option] || 0;
return <div key={option} className="aniimo-ability-filter" data-ability-group={options.elements.includes(option) ? "elements" : "other"} data-active={level > 0 || undefined}>
<label htmlFor={rangeId}><img src={aniimoAsset(option)} alt="" width="18" height="18" /><span>{t(option)}</span></label>
<input id={rangeId} className="nds-range" data-size="sm" type="range" min="0" max="4" step="1" value={level} aria-label={`${t(option)} — ${content.minimum}`} aria-describedby="aniimo-levels-hint" aria-valuetext={level ? `${content.minimum} ${level}` : content.abilityDisabled} onChange={(event) => {
<input id={rangeId} className="nds-range" data-size="sm" type="range" min="0" max="4" step="1" value={level} aria-label={`${t(option)} — ${filters.abilityExact ? content.exactLevel : content.minimum}`} aria-valuetext={level ? `${filters.abilityExact ? content.exactLevel : content.minimum} ${level}` : content.abilityDisabled} onChange={(event) => {
const abilities = { ...filters.abilities };
const minimum = Number(event.target.value);
if (minimum > 0) abilities[option] = minimum;

View file

@ -36,7 +36,7 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
<section className="game-layout aniimo-layout">
<aside className="game-filters"><AniimoFilters filters={active} onChange={update} options={aniimo.filterOptions} t={t} content={content} /></aside>
<section className="game-results" aria-label={content.results}>
{cards.length ? <div className="game-grid aniimo-grid">{cards.map((variants) => <AniimoCard key={active.grouped ? variants[0].aniimoId : variants[0].key} variants={variants} t={t} content={content} isCompleted={checklist.isCompleted} completedLabel={checklist.content.completed} statsMax={statsMax} />)}</div> : <div className="empty nds-empty-state"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>}
{cards.length ? <div className="game-grid aniimo-grid">{cards.map((variants) => <AniimoCard key={active.grouped ? variants[0].aniimoId : variants[0].key} variants={variants} t={t} content={content} checklist={checklist} statsMax={statsMax} />)}</div> : <div className="empty nds-empty-state"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>}
</section>
</section>
</>;

View file

@ -1,6 +1,6 @@
// Rôle : prépare les variantes Aniimo et applique les critères avant leur regroupement.
export function createAniimoFilters() {
return { name: "", prismanaOnly: false, elements: [], roles: [], abilities: {}, elementsLogic: "and", abilitiesLogic: "and", grouped: true };
return { name: "", prismanaOnly: false, elements: [], roles: [], abilities: {}, elementsLogic: "and", abilityExact: false, grouped: true };
}
export const ANIIMO_STATS = ["hp", "atk", "p_def", "m_def", "break", "regen"];
@ -54,7 +54,7 @@ export function filterAniimoVariants(variants, filters) {
&& (!filters.prismanaOnly || variant.form === "prismana_form")
&& matchesSelection(filters.elements, filters.elementsLogic, (element) => variant.elements.includes(element))
&& (!filters.roles.length || filters.roles.includes(variant.role))
&& matchesSelection(abilities, filters.abilitiesLogic, ([type, minimum]) => variant.homeland_abilities.some((ability) => ability.type === type && ability.level >= minimum))
&& abilities.every(([type, level]) => variant.homeland_abilities.some((ability) => ability.type === type && (filters.abilityExact ? ability.level === level : ability.level >= level)))
));
}

View file

@ -1,11 +1,12 @@
// Rôle : affiche une carte d'affixe Diablo IV et ses catégories.
import { GuideChecklistMark } from "../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox } from "../GuideChecklistControls.jsx";
import { getCategoryIconStyle, getCategoryLabel } from "./utils.js";
export function Diablo4AffixCard({ affix, categoryMap, completed, completedLabel }) {
export function Diablo4AffixCard({ affix, categoryMap, checklist }) {
const completed = checklist.isCompleted(affix.id);
return (
<article className="compact-data-card nds-card diablo4-affix-card" data-variant="interactive" data-checklist-complete={completed || undefined}>
<GuideChecklistMark completed={completed} label={completedLabel} />
<GuideChecklistCheckbox checklist={checklist} itemKey={affix.id} />
<div className="diablo4-affix-card-body">
<h2>{affix.label}</h2>
<div className="diablo4-category-list" aria-label="Catégories">

View file

@ -38,7 +38,7 @@ export function Diablo4Listing({ game, diablo4, filters, setFilters }) {
<section className="game-results" aria-live="polite">
<div className="game-grid diablo4-affix-grid">
{visible.length ? visible.map((affix) => (
<Diablo4AffixCard key={affix.id} affix={affix} categoryMap={diablo4.categoryMap} completed={checklist.isCompleted(affix.id)} completedLabel={checklist.content.completed} />
<Diablo4AffixCard key={affix.id} affix={affix} categoryMap={diablo4.categoryMap} checklist={checklist} />
)) : (
<div className="empty nds-empty-state">
<h2>Aucun résultat</h2>

View file

@ -19,14 +19,29 @@ export function matchGuideChecklist(entries, data) {
const findRows = (labels = []) => [...new Set(labels.map(normalizeGuideLabel))].flatMap((label) => byLabel.get(label) || []);
const matched = new Set();
const completed = new Set();
const rowIds = new Map();
for (const entry of entries) {
const specific = findRows(entry.labels);
const matches = specific.length ? specific : findRows(entry.fallbackLabels);
if (!matches.length) continue;
matched.add(entry.key);
rowIds.set(entry.key, [...new Set(matches.map((row) => row.id).filter(Boolean))]);
if (matches.every((row) => Number(row.qtyCurrent || 0) >= Math.max(1, Number(row.qtyTarget || 1)))) completed.add(entry.key);
}
return { count: matched.size, completed };
return { count: matched.size, completed, rowIds };
}
export function toggleGuideChecklistRows(data, ids, completed) {
const targetIds = new Set(ids || []);
if (!targetIds.size) return data;
const update = (items = []) => items.map((item) => targetIds.has(item.id)
? { ...item, qtyCurrent: completed ? Math.max(1, Number(item.qtyTarget || 1)) : 0 }
: item);
return {
...data,
...(Array.isArray(data?.items) ? { items: update(data.items) } : {}),
...(Array.isArray(data?.sections) ? { sections: data.sections.map((section) => ({ ...section, items: update(section.items) })) } : {})
};
}
export function validGuideAssociations(value, toolboxes, links) {

View file

@ -45,8 +45,8 @@ export function MhwildsListing({ category, game, mhwilds, filters, setFilters, t
<div className={`game-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
{visible.length ? visible.map((item) => (
isMonsters
? <MonsterCard key={item.name} monster={item} t={t} completed={checklist.isCompleted(item.name)} completedLabel={checklist.content.completed} />
: <EndemicCard key={item.name} item={item} t={t} completed={checklist.isCompleted(item.name)} completedLabel={checklist.content.completed} />
? <MonsterCard key={item.name} monster={item} t={t} checklist={checklist} />
: <EndemicCard key={item.name} item={item} t={t} checklist={checklist} />
)) : (
<div className="empty nds-empty-state">
<h2>Aucun résultat</h2>

View file

@ -1,9 +1,10 @@
// Rôle : affiche une carte de faune endémique Monster Hunter Wilds.
import { GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox } from "../../GuideChecklistControls.jsx";
import { IconRow } from "./IconRow.jsx";
import { assetPath, getConditionValues, normalizeText } from "../utils.js";
export function EndemicCard({ item, t, completed, completedLabel }) {
export function EndemicCard({ item, t, checklist }) {
const completed = checklist.isCompleted(item.name);
const locations = getConditionValues(item.locations);
const description = t(item.description);
const normalizedDescription = normalizeText(description);
@ -13,9 +14,9 @@ export function EndemicCard({ item, t, completed, completedLabel }) {
return (
<article className="mhwilds-card endemic-card" data-checklist-complete={completed || undefined}>
<GuideChecklistMark completed={completed} label={completedLabel} />
<div className="mhwilds-card-art">
<img src={assetPath(item.name)} alt={t(item.name, { capitalize: true })} loading="lazy" />
<GuideChecklistCheckbox checklist={checklist} itemKey={item.name} />
</div>
<div className="mhwilds-card-body">
<h2>{t(item.name, { capitalize: true })}</h2>

View file

@ -1,11 +1,12 @@
// Rôle : affiche une carte monstre retournable avec faiblesses et hitzones.
import { GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { GuideChecklistCheckbox, GuideChecklistMark } from "../../GuideChecklistControls.jsx";
import { useState } from "react";
import { DamageTable } from "./DamageTable.jsx";
import { IconRow } from "./IconRow.jsx";
import { assetPath, getConditionValues } from "../utils.js";
export function MonsterCard({ monster, t, completed, completedLabel }) {
export function MonsterCard({ monster, t, checklist }) {
const completed = checklist.isCompleted(monster.name);
const [flipped, setFlipped] = useState(false);
const weaknesses = getConditionValues(monster.weaknesses);
const ailments = getConditionValues(monster.ailments).filter((value) => value !== "none");
@ -23,16 +24,16 @@ export function MonsterCard({ monster, t, completed, completedLabel }) {
tabIndex="0"
role="button"
aria-pressed={flipped}
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}${completed ? ` — ${completedLabel}` : ""}`}
aria-label={`Afficher les dégâts de ${t(monster.name, { capitalize: true })}${completed ? ` — ${checklist.content.completed}` : ""}`}
>
<div className="mhwilds-card-inner">
<div className="mhwilds-card-face mhwilds-card-front">
<GuideChecklistMark completed={completed} label={completedLabel} />
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>
<div className="mhwilds-card-art">
<img src={assetPath(monster.name)} alt={t(monster.name, { capitalize: true })} loading="lazy" />
<GuideChecklistCheckbox checklist={checklist} itemKey={monster.name} />
</div>
<div className="mhwilds-card-body">
<p className="eyebrow">{t(monster.type, { capitalize: true })}</p>
@ -42,7 +43,7 @@ export function MonsterCard({ monster, t, completed, completedLabel }) {
</div>
</div>
<div className="mhwilds-card-face mhwilds-card-back">
<GuideChecklistMark completed={completed} label={completedLabel} />
<GuideChecklistMark completed={completed} label={checklist.content.completed} />
<span className="mhwilds-flip-indicator" aria-hidden="true">
<span className="ui-icon ui-icon-flip" />
</span>

View file

@ -1,6 +1,6 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useId, useMemo, useState } from "react";
import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js";
import { getGuideChecklistEntries, matchGuideChecklist, toggleGuideChecklistRows } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null);
@ -12,7 +12,7 @@ export function useGuideChecklistPanel(checklist, module) {
}
export function useGuideChecklist(requestedGuideId) {
const { toolboxes, links, moduleData, guideChecklistSession, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const { toolboxes, links, moduleData, guideChecklistSession, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const guideId = requestedGuideId || route.replace(/^\/games\//, "");
const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
const gameId = guideId.split("/")[0];
@ -26,8 +26,15 @@ export function useGuideChecklist(requestedGuideId) {
const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key));
const canToggle = (key) => Boolean(selected?.rowIds.get(key)?.length);
function toggleCompleted(key) {
const ids = selected?.rowIds.get(key);
if (!ids?.length) return;
const data = moduleData[`${toolbox.id}:${selected.id}`];
actions.updateModuleData(toolbox.id, selected.id, toggleGuideChecklistRows(data, ids, !isCompleted(key)), "checklist");
}
return {
content, toolbox, selected, hideCompleted, isCompleted, guideId, available: entries !== null,
content, toolbox, selected, hideCompleted, isCompleted, canToggle, toggleCompleted, guideId, available: entries !== null,
lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),

View file

@ -40,7 +40,18 @@
&[data-active] .aniimo-minimum-value { color: var(--color-accent-gold); font-weight: 700; }
}
.aniimo-minimum-value { text-align: right; font-variant-numeric: tabular-nums; }
.aniimo-levels-hint { margin: 4px 0; color: var(--color-text-muted); font-size: var(--font-size-xs); }
.aniimo-level-operator-icon {
display: block;
width: 20px;
height: 20px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
shape-rendering: geometricPrecision;
}
.aniimo-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); }
.aniimo-result { min-width: 0; max-width: 280px; width: 100%; }
.aniimo-tabs-slot { height: 36px; min-width: 0; padding: 2px; }

View file

@ -10,6 +10,25 @@
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-2); padding-top: var(--space-2); }
}
.module-guide-button[data-linked] .ui-icon { color: var(--color-accent-gold); }
.guide-checklist-checkbox {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 3;
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: rgba(7, 10, 24, 0.82);
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28);
}
.guide-checklist-checkbox:hover { border-color: var(--color-border-hover); }
.aniimo-portrait,
.mhwilds-card-art { position: relative; }
.diablo4-affix-card > .guide-checklist-checkbox { right: 12px; bottom: 12px; }
.guide-checklist-mark {
position: absolute;
top: 10px;
@ -21,7 +40,7 @@
.diablo4-affix-card .guide-checklist-mark,
.aniimo-back .guide-checklist-mark { top: auto; left: auto; right: 10px; bottom: 10px; }
.mhwilds-card-back .guide-checklist-mark { left: auto; right: 50px; }
.diablo4-affix-card .diablo4-category-list { padding-right: 24px; }
.diablo4-affix-card .diablo4-category-list { padding-right: 34px; }
[data-checklist-complete="true"] {
.aniimo-back-details.nds-scrollbox[data-variant] { padding-bottom: 36px; }
.aniimo-portrait img, .mhwilds-card-art img { filter: grayscale(1); opacity: 0.55; }