Link guide cards to checklists and refine Aniimo ability filters
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 17s
This commit is contained in:
parent
b5901b31e3
commit
7b23a8601a
23 changed files with 193 additions and 51 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue