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 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 ; - 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` ; - 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 ; - 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 ; - 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é. - garder le nombre de critères sélectionnés visible dans l'en-tête, même replié.
### Liaison des guides aux checklists ### 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 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é. 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 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. 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 ## 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 bouton toolbox et le panneau latéral.
- Vérifier le breadcrumb. - Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne. - 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. - 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. - 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 toutes ses lignes atteignent leur propre `qtyTarget` ; les doublons ne sont pas
additionnés. Renommer une ligne peut supprimer sa correspondance. Une liste encore 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. 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 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 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 }) => { test("ability levels use independent native ranges and retain name constraints", async ({ page }) => {
await page.goto(route); await page.goto(route);
const abilities = section(page, "Compétences de logis"); const abilities = section(page, "Compétences de logis");
const carry = page.getByRole("slider", { name: "Porter — Niveau minimum" }); const carry = page.locator("#aniimo-minimum-carry");
const crafting = page.getByRole("slider", { name: "Artisanat — Niveau minimum" }); const crafting = page.locator("#aniimo-minimum-crafting");
await expect(abilities.getByRole("checkbox")).toHaveCount(0); 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).toHaveValue("0");
await expect(carry).toHaveAttribute("min", "0"); await expect(carry).toHaveAttribute("min", "0");
await carry.focus(); await carry.focus();
@ -165,7 +172,10 @@ test("ability levels use independent native ranges and retain name constraints",
await crafting.press("ArrowRight"); await crafting.press("ArrowRight");
await expect(crafting).toHaveValue("2"); await expect(crafting).toHaveValue("2");
await expect(crafting).toHaveCSS("outline-style", "solid"); 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 expect(section(page, "Éléments").getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
await carry.press("Home"); await carry.press("Home");
await crafting.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 expect(cards(page)).toHaveCount(88);
await page.getByRole("searchbox").fill("does-not-exist"); await page.getByRole("searchbox").fill("does-not-exist");
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible(); 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 }) => { 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]) { for (const width of [1280, 390]) {
test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => { test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => {
test.setTimeout(60000);
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await seed(page); await seed(page);
await page.goto(route); 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 page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page); await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true"); 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 toggleHidden(page);
await expect(card.getByRole("tab")).toHaveCount(2); await expect(card.getByRole("tab")).toHaveCount(2);
await expect(card).toHaveAttribute("data-form", "mountain_woods_form"); 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 choose(page, "capture");
await assertLink(page, "capture", true); await assertLink(page, "capture", true);
await expect(card.getByRole("tab")).toHaveCount(3); 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(); const count = await page.locator(cardClass).count();
await choose(page, "progress"); await choose(page, "progress");
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1); 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 toggleHidden(page);
await expect(page.locator(cardClass)).toHaveCount(count - 1); await expect(page.locator(cardClass)).toHaveCount(count - 1);
await page.setViewportSize({ width: 390, height: 844 }); 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", () => { 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 } }, 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.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", () => { 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. // Rôle : vérifie les correspondances des guides et la validité des associations de session.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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 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" }); 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); 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", () => { 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 toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] };
const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true }; const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };

View file

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

View file

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

View file

@ -33,3 +33,12 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
export function GuideChecklistMark({ completed, label }) { 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; 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. // Rôle : affiche une carte Aniimo retournable et les onglets des variantes correspondantes.
import { useId, useLayoutEffect, useRef, useState } from "react"; 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 { Icon } from "../../../components/Icon.jsx";
import { Tooltip } from "../../../components/Tooltip.jsx"; import { Tooltip } from "../../../components/Tooltip.jsx";
import { ANIIMO_STATS, aniimoAsset } from "./utils.js"; 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"); 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 [flipped, setFlipped] = useState(false);
const frontButton = useRef(null); const frontButton = useRef(null);
const backButton = 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-card-inner">
<div className="aniimo-face aniimo-front nds-card" aria-hidden={flipped} inert={flipped}> <div className="aniimo-face aniimo-front nds-card" aria-hidden={flipped} inert={flipped}>
{flipButton(false, frontButton)} {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" /><GuideChecklistCheckbox checklist={checklist} itemKey={variant.key} /></div>
<div className="aniimo-portrait"><img src={variant.image} alt={t(variant.name)} width="512" height="840" loading="lazy" /></div>
<div className="aniimo-card-body"> <div className="aniimo-card-body">
<h2 title={t(variant.name)}>{t(variant.name)}</h2> <h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p> <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>
<div className="aniimo-face aniimo-back nds-card" aria-hidden={!flipped} inert={!flipped}> <div className="aniimo-face aniimo-back nds-card" aria-hidden={!flipped} inert={!flipped}>
{flipButton(true, backButton)} {flipButton(true, backButton)}
<GuideChecklistMark completed={completed} label={completedLabel} /> <GuideChecklistMark completed={completed} label={checklist.content.completed} />
<div className="aniimo-card-body aniimo-back-heading"> <div className="aniimo-card-body aniimo-back-heading">
<h2 title={t(variant.name)}>{t(variant.name)}</h2> <h2 title={t(variant.name)}>{t(variant.name)}</h2>
<p className="aniimo-form" title={t(variant.form)}>{t(variant.form)}</p> <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 id = useId();
const [selectedKey, setSelectedKey] = useState(variants[0].key); const [selectedKey, setSelectedKey] = useState(variants[0].key);
const variant = variants.find((item) => item.key === selectedKey) || variants[0]; 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(); target.parentElement.children[index].focus();
} }
return ( 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"> <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)}`}> {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) => { {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> </div>
<div id={`${id}-panel`} role={multiple ? "tabpanel" : undefined} aria-labelledby={multiple ? `${id}-${variant.form}` : undefined}> <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> </div>
</article> </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 }) { export function AniimoFilters({ filters, onChange, options, t, content }) {
const [collapsed, setCollapsed] = useState({}); const [collapsed, setCollapsed] = useState({});
const abilityOptions = [...options.abilities].sort((a, b) => Number(!options.elements.includes(a)) - Number(!options.elements.includes(b))); 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"} /> <Icon name={collapsed[key] ? "chevron-down" : "chevron-up"} />
</button></legend> </button></legend>
<div id={`aniimo-filter-${key}`} hidden={collapsed[key]}> <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 === "elements" && <LogicChoice label={content[key]} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />}
{key === "abilities" && <p className="aniimo-levels-hint" id="aniimo-levels-hint">{content.abilityLevelsHint}</p>} {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]}> <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) => { {(key === "abilities" ? abilityOptions : options[key]).map((option) => {
const isAbility = key === "abilities"; const isAbility = key === "abilities";
@ -56,7 +72,7 @@ export function AniimoFilters({ filters, onChange, options, t, content }) {
const level = filters.abilities[option] || 0; 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}> 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> <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 abilities = { ...filters.abilities };
const minimum = Number(event.target.value); const minimum = Number(event.target.value);
if (minimum > 0) abilities[option] = minimum; 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"> <section className="game-layout aniimo-layout">
<aside className="game-filters"><AniimoFilters filters={active} onChange={update} options={aniimo.filterOptions} t={t} content={content} /></aside> <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}> <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>
</section> </section>
</>; </>;

View file

@ -1,6 +1,6 @@
// Rôle : prépare les variantes Aniimo et applique les critères avant leur regroupement. // Rôle : prépare les variantes Aniimo et applique les critères avant leur regroupement.
export function createAniimoFilters() { 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"]; 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") && (!filters.prismanaOnly || variant.form === "prismana_form")
&& matchesSelection(filters.elements, filters.elementsLogic, (element) => variant.elements.includes(element)) && matchesSelection(filters.elements, filters.elementsLogic, (element) => variant.elements.includes(element))
&& (!filters.roles.length || filters.roles.includes(variant.role)) && (!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. // 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"; 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 ( return (
<article className="compact-data-card nds-card diablo4-affix-card" data-variant="interactive" data-checklist-complete={completed || undefined}> <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"> <div className="diablo4-affix-card-body">
<h2>{affix.label}</h2> <h2>{affix.label}</h2>
<div className="diablo4-category-list" aria-label="Catégories"> <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"> <section className="game-results" aria-live="polite">
<div className="game-grid diablo4-affix-grid"> <div className="game-grid diablo4-affix-grid">
{visible.length ? visible.map((affix) => ( {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"> <div className="empty nds-empty-state">
<h2>Aucun résultat</h2> <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 findRows = (labels = []) => [...new Set(labels.map(normalizeGuideLabel))].flatMap((label) => byLabel.get(label) || []);
const matched = new Set(); const matched = new Set();
const completed = new Set(); const completed = new Set();
const rowIds = new Map();
for (const entry of entries) { for (const entry of entries) {
const specific = findRows(entry.labels); const specific = findRows(entry.labels);
const matches = specific.length ? specific : findRows(entry.fallbackLabels); const matches = specific.length ? specific : findRows(entry.fallbackLabels);
if (!matches.length) continue; if (!matches.length) continue;
matched.add(entry.key); 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); 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) { 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"}`}> <div className={`game-grid ${isMonsters ? "monster-grid" : "endemic-grid"}`}>
{visible.length ? visible.map((item) => ( {visible.length ? visible.map((item) => (
isMonsters isMonsters
? <MonsterCard key={item.name} monster={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} completed={checklist.isCompleted(item.name)} completedLabel={checklist.content.completed} /> : <EndemicCard key={item.name} item={item} t={t} checklist={checklist} />
)) : ( )) : (
<div className="empty nds-empty-state"> <div className="empty nds-empty-state">
<h2>Aucun résultat</h2> <h2>Aucun résultat</h2>

View file

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

View file

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

View file

@ -1,6 +1,6 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides. // Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useId, useMemo, useState } from "react"; 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); export const GuideChecklistContext = createContext(null);
@ -12,7 +12,7 @@ export function useGuideChecklistPanel(checklist, module) {
} }
export function useGuideChecklist(requestedGuideId) { 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 guideId = requestedGuideId || route.replace(/^\/games\//, "");
const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]); const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
const gameId = guideId.split("/")[0]; const gameId = guideId.split("/")[0];
@ -26,8 +26,15 @@ export function useGuideChecklist(requestedGuideId) {
const selected = lists.find((list) => list.id === association?.moduleId); const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted); const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key)); 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 { 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), lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key), isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null), 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; } &[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-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-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); }
.aniimo-result { min-width: 0; max-width: 280px; width: 100%; } .aniimo-result { min-width: 0; max-width: 280px; width: 100%; }
.aniimo-tabs-slot { height: 36px; min-width: 0; padding: 2px; } .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); } .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); } .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 { .guide-checklist-mark {
position: absolute; position: absolute;
top: 10px; top: 10px;
@ -21,7 +40,7 @@
.diablo4-affix-card .guide-checklist-mark, .diablo4-affix-card .guide-checklist-mark,
.aniimo-back .guide-checklist-mark { top: auto; left: auto; right: 10px; bottom: 10px; } .aniimo-back .guide-checklist-mark { top: auto; left: auto; right: 10px; bottom: 10px; }
.mhwilds-card-back .guide-checklist-mark { left: auto; right: 50px; } .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"] { [data-checklist-complete="true"] {
.aniimo-back-details.nds-scrollbox[data-variant] { padding-bottom: 36px; } .aniimo-back-details.nds-scrollbox[data-variant] { padding-bottom: 36px; }
.aniimo-portrait img, .mhwilds-card-art img { filter: grayscale(1); opacity: 0.55; } .aniimo-portrait img, .mhwilds-card-art img { filter: grayscale(1); opacity: 0.55; }