diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md
index c99a1a1..dcb5e09 100644
--- a/DESIGN_SYSTEM.md
+++ b/DESIGN_SYSTEM.md
@@ -105,7 +105,7 @@ Les surfaces NDS couvrent les patterns transverses, pas les composants metier co
- `nds-panel` : panneau de lecture non repete, sans hover par defaut.
- `nds-card` : element repete ou ouvrable ; utiliser `nds-card-cover`, `nds-card-body` et `nds-card-actions` quand la structure cover/contenu/actions est necessaire.
- `nds-tool-panel` : surface de travail d'outil, plus discrete qu'une card, avec accent dore bas/droite.
-- `nds-filter-panel` : panneau sticky ou lateral de filtres ; les options utilisent `nds-chip`.
+- `nds-filter-panel` : filtres verticaux ou horizontaux via `data-variant="vertical|horizontal-compact|horizontal-menu"` ; sans attribut, le rendu vertical historique est conservé.
- `nds-empty-state` : etat vide court, avec action optionnelle.
- `nds-inline-notice` : feedback compact inline, avec `data-variant="info|success|warning|danger"`.
- `nds-progress` : rail de progression (taille `md`/défaut : 8 px, `sm` : 4 px, via `data-size`) ; le remplissage est le premier `span` enfant ou `.nds-progress-bar`.
@@ -545,6 +545,7 @@ Sidebar :
- icones SVG pour Accueil, Toolboxes et Jeux ;
- navigation active en violet soft ;
- le logo controller garde le gradient de l'ancien badge et une rotation au hover.
+- `sidebar[data-variant="compact"]` réduit la navigation à 72 px uniquement pendant le déploiement de la toolbox latérale : la manette conserve sa rotation au hover, les trois entrées principales et les trois raccourcis secondaires utilisent `Tooltip`, puis l’encart inférieur contient uniquement les actions globales exporter/importer empilées avec leurs propres infobulles ; à la fermeture du panneau, la sidebar desktop reprend sa variante normale.
Le nom de marque affiche `Sokko` en blanc et `G` en dore. Au hover, `Sokko` anime vers le dore de droite a gauche puis revient au hover out.
@@ -552,10 +553,19 @@ Le nom de marque affiche `Sokko` en blanc et `G` en dore. Au hover, `Sokko` anim
Filtres :
-- chips compactes ;
-- checkbox, icone puis texte ;
-- spacing lisible : checkbox pas collee au bord, icone proche du texte ;
-- reset avec bouton icone `rubber`.
+- `vertical` conserve le panneau latéral historique avec des chips checkbox, icône puis texte ;
+- `horizontal-compact` aligne recherche, petit groupe d’options directes, correspondance et reset au-dessus des résultats ;
+- `horizontal-menu` remplace les groupes nombreux, ambigus ou avancés par des `nds-select-trigger` ouvrant des `nds-popover` ;
+- `nds-filter-menu[data-columns="2"]` répartit les options aux libellés courts sur deux colonnes dans la largeur standard du popover, afin de compacter chaque option ;
+- les menus multi-sélection gardent les libellés visibles et affichent le nombre de critères actifs dans leur trigger ;
+- les listes des popovers de filtres prennent leur hauteur naturelle et n’ajoutent pas de défilement interne ;
+- les sélecteurs de condition comme `ET / OU` ou `≥ / =` restent dans une petite surface bordée afin de les distinguer des options du menu ;
+- lorsque la condition ne concerne qu’un groupe avancé, comme les catégories Diablo IV, son sélecteur est placé dans le popover correspondant avec 12 px d’espace avant les options ;
+- toute option réduite à son icône doit utiliser le composant commun `Tooltip` et conserver un nom accessible (`aria-label`) ; l’attribut HTML `title` ne remplace pas le tooltip ;
+- les options directes avec libellé suivent la largeur naturelle de leur contenu ; lorsqu’elles passent en icône seule, elles retrouvent une largeur compacte commune ;
+- `nds-active-filters` forme une seconde section compacte dans la barre principale, séparée sur toute la largeur par un trait doré semi-transparent sans ombre ; la section haute conserve les 10 px de padding vertical de la barre, tandis que la section basse se limite à 4 px ; elle affiche un badge fermable par recherche ou critère actif, dont le bouton retire uniquement le filtre correspondant ;
+- les barres sont sticky sous la topbar sur desktop, reviennent à la ligne et redeviennent statiques sur mobile ;
+- reset avec bouton icône `rubber`.
Tableaux :
@@ -609,8 +619,11 @@ Modales :
Drawer toolbox :
-- panneau lateral redimensionnable ;
-- overlay floute leger ;
+- panneau lateral ancre a droite, ouvert a `700px` et redimensionnable uniquement vers une largeur superieure ;
+- le shell reserve toujours `700px` au panneau : toute largeur supplementaire recouvre la partie droite de la page sans la reduire davantage ;
+- la page reste visible et interactive, sans backdrop bloquant ni fermeture au clic exterieur ;
+- sous `700px`, le panneau occupe `100vw`, ne reserve plus d'espace lateral et masque sa poignee de redimensionnement ;
+- la largeur revient a `700px` a chaque ouverture et n'est pas persistee ;
- les actions internes ne doivent pas fermer le drawer sauf action explicite.
Notifications internes :
@@ -818,10 +831,11 @@ Date picker et menus compacts :
- 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 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 au filtrage ;
-- garder le nombre de critères sélectionnés visible dans l'en-tête, même replié.
+- l’option directe « Prismana » reprend le style et les dimensions des `nds-filter-option`, utilise une étoile SVG arc-en-ciel et limite les résultats à `prismana_form` avant regroupement ; son nom accessible reste « Prismana uniquement » ;
+- la barre horizontale avancée ouvre trois menus distincts pour Rôles, Éléments et Compétences de logis ; chaque trigger conserve le nombre de critères sélectionnés ;
+- rôles et éléments utilisent des chips `sm` avec icône et texte, réparties sur deux colonnes via `data-columns="2"` ; le menu des compétences sépare sans titre visible les éléments et les actions en deux colonnes accessibles, le séparateur étant porté par la colonne des éléments, plus haute ; chaque ligne conserve icône, texte, curseur et valeur ; un seuil à 0 reste visible mais ne participe pas au filtrage ;
+- recherche, Prismana, rôles, éléments et niveaux actifs sont rappelés sous la barre par des badges supprimables individuellement ;
+- les menus restent ouverts pendant une sélection multiple et se ferment au clic extérieur, à `Escape` ou quand le focus quitte le menu.
### Liaison des guides aux checklists
diff --git a/e2e/aniimo.spec.js b/e2e/aniimo.spec.js
index 3a2a49e..0240e87 100644
--- a/e2e/aniimo.spec.js
+++ b/e2e/aniimo.spec.js
@@ -3,19 +3,51 @@ import { test, expect } from "@playwright/test";
const route = "/#/games/aniimo/aniimos";
const cards = (page) => page.locator(".aniimo-result");
-const section = (page, name) => page.getByRole("group", { name, exact: true }).filter({ has: page.locator("legend") });
+const filterTrigger = (page, name) => page.getByRole("button", { name: new RegExp(`^${name}`) });
+const filterMenu = (page, name) => page.getByRole("dialog", { name: new RegExp(`^${name}`) });
+async function openFilter(page, name) {
+ const trigger = filterTrigger(page, name);
+ if (await trigger.getAttribute("aria-expanded") !== "true") await trigger.click();
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
+ return filterMenu(page, name);
+}
+
+test("role and element menus use two columns and highlight their condition", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto(route);
+ const roles = await openFilter(page, "Rôles");
+ await expect(roles).toHaveAttribute("data-columns", "2");
+ expect(await roles.locator(".nds-filter-menu-options").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
+ await page.keyboard.press("Escape");
+
+ const elements = await openFilter(page, "Éléments");
+ await expect(elements).toHaveAttribute("data-columns", "2");
+ expect(await elements.locator(".nds-filter-menu-options").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
+ expect((await elements.boundingBox()).width).toBeLessThanOrEqual(320);
+ expect((await elements.locator(".nds-filter-menu-option").first().boundingBox()).width).toBeLessThan(160);
+ const condition = elements.locator(".nds-filter-logic");
+ await expect(condition).toHaveCSS("border-top-style", "solid");
+ await expect(condition).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+});
for (const width of [1280, 390]) {
test(`Prismana filter combines with search and survives grouping and navigation at ${width}px`, async ({ page }) => {
test.setTimeout(45000);
await page.setViewportSize({ width, height: 900 });
await page.goto(route);
- const prismana = page.getByRole("checkbox", { name: "Prismana uniquement", exact: true });
+ const prismana = page.getByRole("button", { name: "Prismana uniquement", exact: true });
await expect(prismana).toBeVisible({ timeout: 30000 });
- await expect(prismana).not.toBeChecked();
+ await expect(prismana).toHaveAttribute("aria-pressed", "false");
await prismana.focus();
await prismana.press("Space");
await expect(cards(page)).toHaveCount(27);
+ await expect(prismana).toHaveText("Prismana");
+ await expect(prismana).toHaveClass(/nds-filter-option/);
+ const prismanaBadge = page.getByRole("button", { name: "Retirer le filtre Prismana", exact: true });
+ await expect(prismanaBadge).toBeVisible();
+ await prismanaBadge.click();
+ await expect(prismana).toHaveAttribute("aria-pressed", "false");
+ await prismana.click();
await expect(page.locator('.aniimo-result:not([data-form="prismana_form"])')).toHaveCount(0);
await expect(page.getByRole("tablist")).toHaveCount(0);
expect(await page.locator('img[src="/static/icons/prismana.svg"]').evaluate((image) => image.complete && image.naturalWidth > 0)).toBe(true);
@@ -23,14 +55,14 @@ for (const width of [1280, 390]) {
await expect(cards(page)).toHaveCount(3);
await page.locator('.game-breadcrumb a[href="#/games/aniimo"]').click();
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
- await expect(prismana).toBeChecked();
+ await expect(prismana).toHaveAttribute("aria-pressed", "true");
await expect(cards(page)).toHaveCount(3);
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
await expect(cards(page)).toHaveCount(3);
await page.getByRole("searchbox").fill("emberpup");
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
- await expect(prismana).not.toBeChecked();
+ await expect(prismana).toHaveAttribute("aria-pressed", "false");
await expect(cards(page)).toHaveCount(208);
await expect(page.getByRole("button", { name: "Regrouper les variantes", exact: true })).toHaveAttribute("aria-pressed", "false");
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
@@ -64,12 +96,14 @@ test("Aniimo matches MHW card motion, surfaces and filter controls", async ({ pa
const indicator = await styles(page.locator(".mhwilds-flip-indicator").first(), indicatorProperties);
const filter = await styles(page.locator(".filter-logic"), filterProperties);
await page.goto(route);
+ await openFilter(page, "Éléments");
const card = cards(page).first();
await expect(card).toBeVisible();
expect(await styles(card.locator(".aniimo-card-inner"), motionProperties)).toEqual(motion);
expect(await styles(card.locator(".aniimo-front"), faceProperties)).toEqual(face);
expect(await styles(card.locator(".aniimo-front .aniimo-flip"), indicatorProperties)).toEqual(indicator);
expect(await styles(page.locator(".filter-logic").first(), filterProperties)).toEqual(filter);
+ await page.keyboard.press("Escape");
await expect(card.locator(".aniimo-face .aniimo-flip")).toHaveCount(2);
await expect(card.locator(".aniimo-card > .aniimo-flip")).toHaveCount(0);
await card.getByRole("button", { name: /Afficher les capacités/ }).press("Enter");
@@ -132,12 +166,13 @@ test("tabs, flip, matching variants and reset remain independent", async ({ page
await page.keyboard.press("ArrowRight");
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
- await section(page, "Éléments").getByRole("checkbox", { name: "Roche", exact: true }).check();
+ const elements = await openFilter(page, "Éléments");
+ await elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
await expect(card.getByRole("tab")).toHaveCount(0);
await expect(card).toHaveAttribute("data-form", "highlands");
- await section(page, "Éléments").getByRole("checkbox", { name: "Eau", exact: true }).check();
+ await elements.getByRole("checkbox", { name: "Eau", exact: true }).check();
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
- await section(page, "Éléments").getByRole("button", { name: "OU", exact: true }).click();
+ await elements.getByRole("button", { name: "OU", exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
await expect(cards(page)).toHaveCount(1);
@@ -148,9 +183,21 @@ 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 abilities = await openFilter(page, "Compétences de logis");
const carry = page.locator("#aniimo-minimum-carry");
const crafting = page.locator("#aniimo-minimum-crafting");
+ const abilityColumns = abilities.locator(".aniimo-ability-column");
+ await expect(abilityColumns).toHaveCount(2);
+ await expect(abilityColumns.nth(0)).toHaveAttribute("aria-label", "Éléments");
+ await expect(abilityColumns.nth(1)).toHaveAttribute("aria-label", "Actions");
+ await expect(abilities.locator(".aniimo-ability-column-title")).toHaveCount(0);
+ await expect(abilityColumns.nth(0)).toHaveCSS("border-right-style", "solid");
+ expect((await abilityColumns.nth(0).boundingBox()).height).toBeGreaterThan((await abilityColumns.nth(1).boundingBox()).height);
+ await expect(abilityColumns.nth(0).getByRole("slider", { name: /Eau/ })).toBeVisible();
+ await expect(abilityColumns.nth(1).getByRole("slider", { name: /Artisanat/ })).toBeVisible();
+ expect(await abilities.locator(".aniimo-ability-options").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
+ expect((await abilities.boundingBox()).width).toBeGreaterThanOrEqual(600);
+ await expect(abilities.locator(".aniimo-ability-options")).toHaveCSS("overflow-y", "visible");
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 });
@@ -166,6 +213,7 @@ test("ability levels use independent native ranges and retain name constraints",
await carry.press("ArrowRight");
await carry.press("ArrowRight");
await expect(carry).toHaveValue("3");
+ await expect(page.getByRole("button", { name: "Retirer le filtre Porter ≥ 3", exact: true })).toBeVisible();
await expect(crafting).toHaveValue("0");
await crafting.focus();
await crafting.press("ArrowRight");
@@ -176,16 +224,23 @@ test("ability levels use independent native ranges and retain name constraints",
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");
- await expect(carry).toHaveAttribute("aria-valuetext", "Sans filtre");
+ await page.keyboard.press("Escape");
+ const elements = await openFilter(page, "Éléments");
+ await expect(elements.getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
+ await page.keyboard.press("Escape");
+ const reopenedAbilities = await openFilter(page, "Compétences de logis");
+ const reopenedCarry = reopenedAbilities.getByRole("slider", { name: "Porter — Niveau exact" });
+ const reopenedCrafting = reopenedAbilities.getByRole("slider", { name: "Artisanat — Niveau exact" });
+ await reopenedCarry.press("Home");
+ await reopenedCrafting.press("Home");
+ await expect(reopenedCarry).toHaveAttribute("aria-valuetext", "Sans filtre");
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");
+ const resetAbilities = await openFilter(page, "Compétences de logis");
+ await expect(resetAbilities.getByRole("button", { name: "Niveau minimum", exact: true })).toHaveAttribute("aria-pressed", "true");
+ await expect(resetAbilities.getByRole("button", { name: "Niveau exact", exact: true })).toHaveAttribute("aria-pressed", "false");
});
test("seven tabs support keyboard navigation and fall back after filtering", async ({ page }) => {
@@ -211,14 +266,16 @@ test("seven tabs support keyboard navigation and fall back after filtering", asy
await expect(card).toHaveAttribute("data-form", "basic_form");
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
await expect(card).toHaveAttribute("data-form", "prismana_form");
- await section(page, "Éléments").getByRole("checkbox", { name: "Herbe", exact: true }).check();
+ const elements = await openFilter(page, "Éléments");
+ await elements.getByRole("checkbox", { name: "Herbe", exact: true }).check();
await expect(card).toHaveAttribute("data-form", "prismana_form");
- await section(page, "Éléments").getByRole("checkbox", { name: "Herbe", exact: true }).uncheck();
+ await elements.getByRole("checkbox", { name: "Herbe", exact: true }).uncheck();
await page.getByRole("searchbox").fill("emberpup");
await expect(cards(page)).toHaveAttribute("data-form", "basic_form");
- await section(page, "Éléments").getByRole("checkbox", { name: "Roche", exact: true }).check();
+ const reopenedElements = await openFilter(page, "Éléments");
+ await reopenedElements.getByRole("checkbox", { name: "Roche", exact: true }).check();
await expect(cards(page)).toHaveAttribute("data-form", "highlands");
- await section(page, "Éléments").getByRole("checkbox", { name: "Roche", exact: true }).uncheck();
+ await reopenedElements.getByRole("checkbox", { name: "Roche", exact: true }).uncheck();
await expect(cards(page)).toHaveAttribute("data-form", "highlands");
});
@@ -325,12 +382,12 @@ test("portrait geometry, stable faces and mobile layout", async ({ page }) => {
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
});
-test("desktop grid shows five columns with the multiple icon and complete stats", async ({ page }) => {
+test("desktop grid uses the width freed by horizontal filters and shows complete stats", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto(route);
await expect(cards(page)).toHaveCount(88);
const columns = await page.locator(".aniimo-grid").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length);
- expect(columns).toBe(5);
+ expect(columns).toBe(6);
await expect(page.locator(".ui-icon-multiple")).toHaveCSS("mask-image", /multiples\.svg/);
const card = cards(page).first();
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
@@ -339,48 +396,35 @@ test("desktop grid shows five columns with the multiple icon and complete stats"
await page.screenshot({ path: test.info().outputPath("aniimo-stats-desktop.png") });
});
-test("filter categories share one scroll and retain their selections when collapsed", async ({ page }) => {
+test("advanced filter menus retain selections, counts and keyboard closing", async ({ page }) => {
await page.goto(route);
- const elements = section(page, "Éléments");
- const roles = section(page, "Rôles");
- const abilities = section(page, "Compétences de logis");
- await expect(page.locator(".aniimo-filter-options")).toHaveCount(3);
- for (const grid of await page.locator(".aniimo-chip-options").all()) {
- expect(await grid.evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
- }
- const groups = await page.locator(".aniimo-ability-filter").evaluateAll((rows) => rows.map((row) => row.dataset.abilityGroup));
- expect(groups).toEqual([...groups].sort());
- await expect(page.locator(".aniimo-filter-toggle").first()).toHaveText("Rôles");
+ const roles = await openFilter(page, "Rôles");
await expect(roles.getByRole("checkbox", { name: "SOIN", exact: true })).toHaveCount(1);
await expect(roles.getByRole("checkbox", { name: "SOUTIEN", exact: true })).toHaveCount(1);
- const elementScroll = elements.getByRole("region", { name: "Éléments", exact: true });
- const scroll = page.locator(".aniimo-filter-sections");
- await expect(scroll).toHaveCSS("overflow-y", "auto");
- for (const options of await page.locator(".aniimo-filter-options").all()) {
- await expect(options).toHaveCSS("overflow-y", "visible");
- expect(await options.evaluate((element) => element.scrollHeight <= element.clientHeight)).toBe(true);
- }
- await scroll.evaluate((element) => { element.scrollTop = element.scrollHeight; });
- expect(await scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
+ await page.keyboard.press("Escape");
+ await expect(filterTrigger(page, "Rôles")).toBeFocused();
+ const elements = await openFilter(page, "Éléments");
await elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
const results = await page.getByRole("status").textContent();
- const toggle = elements.getByRole("button", { name: "Éléments", exact: true });
- await toggle.click();
- await expect(toggle).toHaveAttribute("aria-expanded", "false");
- await expect(elementScroll).toBeHidden();
- await expect(toggle.locator(".aniimo-selected-count")).toHaveText("1");
+ await expect(filterTrigger(page, "Éléments").locator(".nds-filter-count")).toHaveText("1");
+ await page.keyboard.press("Escape");
+ await expect(elements).toBeHidden();
await expect(page.getByRole("status")).toHaveText(results);
- await expect(roles.getByRole("region")).toBeVisible();
- await expect(abilities.getByRole("region")).toBeVisible();
- await toggle.press("Enter");
- await expect(elements.getByRole("checkbox", { name: "Roche", exact: true })).toBeChecked();
+ const reopenedElements = await openFilter(page, "Éléments");
+ await expect(reopenedElements.getByRole("checkbox", { name: "Roche", exact: true })).toBeChecked();
+ await page.keyboard.press("Escape");
+ const abilities = await openFilter(page, "Compétences de logis");
+ const groups = await abilities.locator(".aniimo-ability-filter").evaluateAll((rows) => rows.map((row) => row.dataset.abilityGroup));
+ expect(new Set(groups)).toEqual(new Set(["elements", "actions"]));
+ expect(groups).toEqual([...groups.filter((group) => group === "elements"), ...groups.filter((group) => group === "actions")]);
const slider = abilities.getByRole("slider", { name: "Porter — Niveau minimum" });
await slider.focus();
await slider.press("End");
- await abilities.getByRole("button", { name: "Compétences de logis", exact: true }).click();
+ await expect(filterTrigger(page, "Compétences de logis").locator(".nds-filter-count")).toHaveText("1");
+ await page.keyboard.press("Escape");
await expect(slider).toBeHidden();
- await abilities.getByRole("button", { name: "Compétences de logis", exact: true }).click();
- await expect(slider).toHaveValue("4");
+ const reopenedAbilities = await openFilter(page, "Compétences de logis");
+ await expect(reopenedAbilities.getByRole("slider", { name: "Porter — Niveau minimum" })).toHaveValue("4");
});
test("pending and failed data requests show loading and error states", async ({ page }) => {
diff --git a/e2e/game-filters.spec.js b/e2e/game-filters.spec.js
new file mode 100644
index 0000000..e159850
--- /dev/null
+++ b/e2e/game-filters.spec.js
@@ -0,0 +1,75 @@
+// Rôle : vérifie les variantes horizontales compactes et avancées des filtres de jeux.
+import { test, expect } from "@playwright/test";
+
+for (const width of [1920, 900]) {
+ test(`MHW compact filters keep accessible tooltips at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto("/#/games/mhwilds/monsters");
+ const filters = page.locator('.nds-filter-panel[data-variant="horizontal-compact"]');
+ await expect(filters).toBeVisible();
+ const options = filters.locator(".nds-filter-option");
+ await expect(options).toHaveCount(5);
+ const optionWidths = await options.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().width));
+ if (width > 1040) expect(Math.max(...optionWidths) - Math.min(...optionWidths)).toBeGreaterThan(1);
+ else expect(Math.max(...optionWidths) - Math.min(...optionWidths)).toBeLessThan(1);
+ const first = options.first();
+ await expect(first).toHaveAttribute("aria-label", /.+/);
+ await first.hover();
+ await expect(page.getByRole("tooltip")).toBeVisible();
+ const label = first.locator(".nds-filter-option-label");
+ await expect(label).toHaveCSS("position", width <= 1040 ? "absolute" : "static");
+ await first.click();
+ await expect(first).toHaveAttribute("aria-pressed", "true");
+ const activeBadge = page.locator(".nds-active-filter").first();
+ await expect(activeBadge).toBeVisible();
+ const activeBar = page.locator(".nds-active-filters");
+ await expect(activeBar).toHaveCSS("border-top-style", "solid");
+ await expect(activeBar).toHaveCSS("border-top-color", "rgba(246, 196, 83, 0.3)");
+ await expect(activeBar).toHaveCSS("padding-top", "4px");
+ await expect(activeBar).toHaveCSS("margin-bottom", "-6px");
+ await expect(activeBar.locator("xpath=..")).toHaveClass(/nds-filter-panel/);
+ await expect(activeBar.locator("xpath=..")).toHaveCSS("row-gap", "10px");
+ const panelBox = await filters.boundingBox();
+ const activeBarBox = await activeBar.boundingBox();
+ expect(activeBarBox.x).toBeCloseTo(panelBox.x + 1, 0);
+ expect(activeBarBox.x + activeBarBox.width).toBeCloseTo(panelBox.x + panelBox.width - 1, 0);
+ expect((await activeBar.boundingBox()).height).toBeLessThanOrEqual(30);
+ await activeBadge.click();
+ await expect(first).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator(".game-filters")).toHaveCSS("position", width <= 760 ? "static" : "sticky");
+ await expect(page.locator(".game-filters")).toHaveCSS("top", width <= 760 ? "auto" : "68px");
+ expect((await filters.locator(".nds-filter-search").boundingBox())?.width).toBeLessThanOrEqual(280);
+ expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
+ });
+}
+
+test("Diablo advanced categories keep text visible and retain focus on Escape", async ({ page }) => {
+ await page.goto("/#/games/diablo4/affixes");
+ const filters = page.locator('.nds-filter-panel[data-variant="horizontal-menu"]');
+ const trigger = filters.getByRole("button", { name: /^Catégories/ });
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ expect(await trigger.locator("span").first().evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
+ await trigger.click();
+ const menu = page.getByRole("dialog", { name: /^Catégories/ });
+ await expect(menu).toBeVisible();
+ await expect(menu.getByText("Correspondance", { exact: true })).toBeVisible();
+ await expect(menu.getByRole("radio", { name: "Et", exact: true })).toHaveAttribute("aria-checked", "true");
+ await expect(filters.locator(":scope > .nds-filter-group > .nds-filter-logic")).toHaveCount(0);
+ await expect(menu.locator(".nds-filter-logic")).toHaveCSS("margin-bottom", "12px");
+ await expect(menu.locator(".nds-filter-menu-options")).toHaveCSS("overflow-y", "visible");
+ await expect(filters).toHaveCSS("overflow", "visible");
+ const filtersBox = await filters.boundingBox();
+ const menuBox = await menu.boundingBox();
+ expect(filtersBox).not.toBeNull();
+ expect(menuBox).not.toBeNull();
+ expect(menuBox.y + menuBox.height).toBeGreaterThan(filtersBox.y + filtersBox.height);
+ await expect(menu.getByRole("checkbox")).toHaveCount(8);
+ await expect(menu.getByText("Offensif", { exact: true })).toBeVisible();
+ await expect(menu.getByText("Mobilité", { exact: true })).toBeVisible();
+ await menu.getByRole("checkbox", { name: "Offensif", exact: true }).check();
+ await expect(trigger.locator(".nds-filter-count")).toHaveText("1");
+ await expect(page.getByRole("button", { name: "Retirer le filtre Catégories : Offensif", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ await expect(trigger).toBeFocused();
+});
diff --git a/e2e/nds-patterns.spec.js b/e2e/nds-patterns.spec.js
index 7ea1d7a..1324b13 100644
--- a/e2e/nds-patterns.spec.js
+++ b/e2e/nds-patterns.spec.js
@@ -88,7 +88,7 @@ for (const width of [1280, 390]) {
await page.locator("body").evaluate((element) => element.classList.add("is-modal-open"));
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
- await mountStyles(page, `
+ await mountStyles(page, `
`);
const drawer = page.locator(".drawer-panel");
@@ -97,7 +97,8 @@ for (const width of [1280, 390]) {
await expect(drawer).toHaveCSS("position", "fixed");
await expect(drawer).toHaveCSS("overflow-y", "auto");
await expect(drawer).toHaveCSS("backdrop-filter", "blur(10px)");
- await expect(page.locator(".drawer-resize-handle")).toHaveCSS("display", width <= 760 ? "none" : "block");
+ await expect(drawer).toHaveCSS("width", width < 700 ? `${width}px` : "700px");
+ await expect(page.locator(".drawer-resize-handle")).toHaveCSS("display", width < 700 ? "none" : "block");
await page.locator(".drawer").evaluate((element) => element.setAttribute("aria-hidden", "true"));
await expect(drawer).toBeHidden();
diff --git a/e2e/style-layouts.spec.js b/e2e/style-layouts.spec.js
index 3129ff1..7f8fa75 100644
--- a/e2e/style-layouts.spec.js
+++ b/e2e/style-layouts.spec.js
@@ -19,10 +19,34 @@ for (const width of [1280, 900, 390]) {
await expect(page.locator(".home-onboarding-slider img")).toHaveCSS("object-fit", "cover");
const side = page.locator(".sidebar");
await expect(side).toHaveCSS("display", width <= 760 ? "none" : "flex");
- await expect(page.locator(".sidebar-mascot")).toHaveCSS("display", width <= 1024 ? "none" : "block");
- for (const link of await page.locator(".sidebar-about-link").all()) {
- await expect(link).toHaveCSS("align-items", "center");
- if (width <= 1024) await expect(link).toHaveCSS("justify-content", "center");
+ if (width > 760) {
+ const responsiveCompact = width <= 1024;
+ await expect(side).not.toHaveAttribute("data-variant");
+ await expect(side).toHaveCSS("width", responsiveCompact ? "72px" : "248px");
+ await expect(page.locator(".sidebar-mascot")).toHaveCSS("display", responsiveCompact ? "none" : "block");
+ const secondaryLinks = page.locator(".sidebar-secondary-links");
+ await expect(secondaryLinks).toHaveCSS("display", "grid");
+ await expect(page.locator(".sidebar-about-link")).toHaveCount(3);
+ await expect(page.locator(".sidebar-about-link strong").first()).toHaveCSS("display", responsiveCompact ? "none" : "block");
+ await expect(page.locator(".sidebar-note")).toHaveCSS("display", responsiveCompact ? "none" : "block");
+ await expect(page.locator(".sidebar-storage-actions")).toHaveCSS("flex-direction", "row");
+ await expect(page.locator(".sidebar-action-button")).toHaveCount(2);
+ await expect(page.locator(".nav-item")).toHaveCount(3);
+ await expect(page.locator(".nav-item strong").first()).toHaveCSS("display", responsiveCompact ? "none" : "block");
+ const brand = page.locator(".brand");
+ const brandMark = page.locator(".brand-mark");
+ await expect(brandMark).toHaveCSS("animation-name", "none");
+ await expect(brandMark).toHaveCSS("transform", "none");
+ await brand.hover();
+ await expect.poll(async () => brandMark.evaluate((element) => globalThis.getComputedStyle(element).transform)).not.toBe("none");
+ if (!responsiveCompact) {
+ const storageActions = await page.locator(".sidebar-action-button").evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect()));
+ expect(storageActions[1].x).toBeGreaterThan(storageActions[0].x);
+ expect(storageActions[1].y).toBeCloseTo(storageActions[0].y, 0);
+ const secondaryBox = await secondaryLinks.boundingBox();
+ const storageBox = await page.locator(".sidebar-note").boundingBox();
+ expect(secondaryBox.y + secondaryBox.height).toBeLessThan(storageBox.y);
+ }
}
await page.goto("/#/about");
await expectFits(page.locator(".about-page"));
@@ -47,7 +71,7 @@ for (const width of [1280, 390]) {
`);
- await columns(page.locator(".game-layout"), width <= 760 ? 1 : 2);
+ await columns(page.locator(".game-layout"), 1);
await expect(page.locator(".game-grid")).toHaveCSS("display", "grid");
await columns(page.locator(".modules"), width <= 760 ? 1 : 2);
await expect(page.locator(".module-column").first()).toHaveCSS("display", width <= 760 ? "contents" : "grid");
diff --git a/e2e/toolbox-drawer-layout.spec.js b/e2e/toolbox-drawer-layout.spec.js
new file mode 100644
index 0000000..e1d94d0
--- /dev/null
+++ b/e2e/toolbox-drawer-layout.spec.js
@@ -0,0 +1,81 @@
+// Rôle : vérifie la réservation hybride, le redimensionnement et le mode mobile du drawer toolbox.
+import { test, expect } from "@playwright/test";
+
+async function openDrawer(page) {
+ await page.locator(".drawer-button").click();
+ const drawer = page.locator("#toolbox-drawer");
+ await expect(drawer.locator(".drawer-panel")).toBeVisible();
+ return drawer;
+}
+
+async function resizeDrawer(page, deltaX) {
+ const handle = page.locator(".drawer-resize-handle");
+ const bounds = await handle.boundingBox();
+ await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(bounds.x + bounds.width / 2 + deltaX, bounds.y + bounds.height / 2);
+ await page.mouse.up();
+}
+
+test("drawer reserves 700px while additional width overlays the page", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto("/#/games/mhwilds/monsters");
+
+ const drawer = await openDrawer(page);
+ const panel = drawer.locator(".drawer-panel");
+ const shell = page.locator(".app-shell");
+ const contentShell = page.locator(".content-shell");
+ const main = page.locator(".content-shell > main");
+ const sidebar = page.locator(".sidebar");
+ await expect(shell).toHaveClass(/toolbox-drawer-open/);
+ await expect(sidebar).toHaveAttribute("data-variant", "compact");
+ await expect(sidebar).toHaveCSS("width", "72px");
+ await expect(drawer.locator(".drawer-backdrop")).toHaveCount(0);
+
+ const contentBounds = await contentShell.boundingBox();
+ const initialPanelBounds = await panel.boundingBox();
+ const initialMainBounds = await main.boundingBox();
+ const topbarBounds = await page.locator(".topbar").boundingBox();
+ expect(initialPanelBounds.width).toBeCloseTo(700, 0);
+ expect(initialPanelBounds.x).toBeCloseTo(contentBounds.x + contentBounds.width - 700, 0);
+ expect(topbarBounds.x + topbarBounds.width).toBeCloseTo(1280, 0);
+
+ const search = page.locator(".nds-filter-search input").first();
+ await search.fill("rath");
+ await expect(search).toHaveValue("rath");
+ await expect(panel).toBeVisible();
+
+ await resizeDrawer(page, -100);
+ await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0);
+ const expandedPanelBounds = await panel.boundingBox();
+ const expandedMainBounds = await main.boundingBox();
+ expect(expandedPanelBounds.x).toBeCloseTo(initialPanelBounds.x - 100, 0);
+ expect(expandedMainBounds.x).toBeCloseTo(initialMainBounds.x, 0);
+ expect(expandedMainBounds.width).toBeCloseTo(initialMainBounds.width, 0);
+ expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280);
+
+ await resizeDrawer(page, 300);
+ await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(700, 0);
+
+ await resizeDrawer(page, -100);
+ await expect.poll(async () => (await panel.boundingBox()).width).toBeCloseTo(800, 0);
+ await drawer.getByRole("button", { name: "Fermer", exact: true }).click();
+ await expect(drawer).toHaveCount(0);
+ await expect(sidebar).not.toHaveAttribute("data-variant");
+ await expect(sidebar).toHaveCSS("width", "248px");
+ const reopenedDrawer = await openDrawer(page);
+ await expect.poll(async () => (await reopenedDrawer.locator(".drawer-panel").boundingBox()).width).toBeCloseTo(700, 0);
+});
+
+test("drawer becomes a non-resizable full-width overlay below 700px", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 900 });
+ await page.goto("/#/games/mhwilds/monsters");
+
+ const drawer = await openDrawer(page);
+ const panel = drawer.locator(".drawer-panel");
+ const bounds = await panel.boundingBox();
+ expect(bounds.x).toBeCloseTo(0, 0);
+ expect(bounds.width).toBeCloseTo(390, 0);
+ await expect(drawer.locator(".drawer-resize-handle")).toHaveCSS("display", "none");
+ expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
+});
diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs
index 0a37c7a..1d4b803 100644
--- a/tests/helpers/data-validation.mjs
+++ b/tests/helpers/data-validation.mjs
@@ -115,10 +115,12 @@ export function validateSiteContent(site) {
"aniimo.name",
"aniimo.searchPlaceholder",
"aniimo.prismanaOnly",
+ "aniimo.prismana",
"aniimo.elements",
"aniimo.roles",
"aniimo.role",
"aniimo.abilities",
+ "aniimo.abilityActions",
"aniimo.minimum",
"aniimo.exactLevel",
"aniimo.levelMode",
diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs
index 3384e8e..2e55660 100644
--- a/tests/static-app.test.mjs
+++ b/tests/static-app.test.mjs
@@ -8,6 +8,8 @@ test("vite entrypoint and app shell are wired", async () => {
const source = await readFile("website/src/main.jsx", "utf8");
const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8");
const shell = await readFile("website/src/components/Shell.jsx", "utf8");
+ const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
+ const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
const overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8");
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
@@ -26,6 +28,15 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(shell, /sidebar-about-link/);
assert.match(shell, /dragon-sidebar\.png/);
assert.match(shell, /ImportButton/);
+ assert.match(shell, /compactSidebar/);
+ assert.match(shell, /compactSidebar = Boolean\(drawerGameId\)/);
+ assert.doesNotMatch(shell, /import\.meta\.env\.DEV \|\| Boolean\(drawerGameId\)/);
+ assert.match(shell, /data-variant=\{compactSidebar \? "compact"/);
+ assert.match(shell, /toolbox-drawer-open/);
+ assert.match(shell, /position="right"/);
+ assert.match(importButton, /tooltipPosition/);
+ assert.match(toolboxPages, /DRAWER_INITIAL_WIDTH = 700/);
+ assert.doesNotMatch(toolboxPages, /drawerWidth|dbGetSetting|dbSetSetting/);
assert.match(overlays, /\.\/ImageViewer\.jsx/);
assert.match(overlays, /uid\("marker"\)/);
assert.match(imageViewer, /export function ImageViewer/);
diff --git a/tests/static-games.test.mjs b/tests/static-games.test.mjs
index e42c1c3..7a5c099 100644
--- a/tests/static-games.test.mjs
+++ b/tests/static-games.test.mjs
@@ -9,6 +9,8 @@ test("generic game routes and shared game components are wired", async () => {
const gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8");
const gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.jsx", "utf8");
+ const gameFilterDropdown = await readFile("website/src/features/games/GameFilterDropdown.jsx", "utf8");
+ const activeFilterBadges = await readFile("website/src/features/games/ActiveFilterBadges.jsx", "utf8");
const checklistCopyButton = await readFile("website/src/features/games/CopyChecklistItemsButton.jsx", "utf8");
const gameListsPage = await readFile("website/src/features/games/GameListsPage.jsx", "utf8");
@@ -30,6 +32,17 @@ test("generic game routes and shared game components are wired", async () => {
assert.match(gameLoaders, /filterOptionKeys/);
assert.match(gameFiltersPanel, /export function GameFiltersPanel/);
assert.match(gameFiltersPanel, /filter-reset-button/);
+ assert.match(gameFiltersPanel, /horizontal-compact/);
+ assert.match(gameFiltersPanel, /horizontal-menu/);
+ assert.match(gameFiltersPanel, /Tooltip/);
+ assert.match(gameFiltersPanel, /aria-pressed/);
+ assert.match(gameFilterDropdown, /CompactDropdown/);
+ assert.match(gameFilterDropdown, /aria-expanded/);
+ assert.match(gameFilterDropdown, /nds-filter-count/);
+ assert.match(gameFilterDropdown, /data-columns/);
+ assert.match(activeFilterBadges, /export function ActiveFilterBadges/);
+ assert.match(activeFilterBadges, /Retirer le filtre/);
+ assert.match(gameFiltersPanel, /ActiveFilterBadges/);
assert.match(checklistCopyButton, /formatChecklistImportItems/);
assert.match(checklistCopyButton, /formatChecklistImportSections/);
assert.match(checklistCopyButton, /\$\{entry\.label\}:\$\{entry\.quantity\}/);
@@ -54,6 +67,7 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.jsx", "utf8");
const aniimoPage = await readFile("website/src/features/games/aniimo/AniimoPage.jsx", "utf8");
+ const aniimoFilters = await readFile("website/src/features/games/aniimo/AniimoFilters.jsx", "utf8");
const aniimoLists = await readFile("website/src/features/games/aniimo/AniimoLists.jsx", "utf8");
const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8");
const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
@@ -66,6 +80,7 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
assert.match(diablo4Listing, /CopyChecklistItemsButton/);
assert.match(diablo4Filters, /export function Diablo4Filters/);
assert.match(diablo4Filters, /GameFiltersPanel/);
+ assert.match(diablo4Filters, /variant="horizontal-menu"/);
assert.match(diablo4AffixCard, /export function Diablo4AffixCard/);
assert.match(diablo4AffixCard, /categoryMap/);
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
@@ -83,9 +98,16 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
assert.match(mhwildsLists, /selectedListId/);
assert.match(aniimoPage, /AniimoLists/);
assert.match(aniimoPage, /games\/aniimo\/lists/);
+ assert.match(aniimoFilters, /data-variant="horizontal-menu"/);
+ assert.match(aniimoFilters, /GameFilterDropdown/);
+ assert.match(aniimoFilters, /content\.roles/);
+ assert.match(aniimoFilters, /content\.elements/);
+ assert.match(aniimoFilters, /content\.abilities/);
+ assert.equal((aniimoFilters.match(/columns=\{2\}/g) || []).length, 2);
assert.match(aniimoLists, /GameListsPage/);
assert.match(mhwildsFilters, /export function MhwildsFilters/);
assert.match(mhwildsFilters, /GameFiltersPanel/);
+ assert.match(mhwildsFilters, /variant="horizontal-compact"/);
assert.match(monsterCard, /export function MonsterCard/);
assert.match(monsterCard, /ui-icon-flip/);
assert.match(endemicCard, /export function EndemicCard/);
diff --git a/tests/static-styles.test.mjs b/tests/static-styles.test.mjs
index eabebb6..44e0a70 100644
--- a/tests/static-styles.test.mjs
+++ b/tests/static-styles.test.mjs
@@ -51,6 +51,24 @@ test("product components keep the public NDS pattern vocabulary", async () => {
}
});
+test("filter patterns expose vertical, compact and menu layouts", async () => {
+ const attributes = await readFile("website/src/styles/design-system/attributes/_patterns.scss", "utf8");
+ const catalog = await readFile("website/src/pages/DesignSystemPage.jsx", "utf8");
+ const documentation = await readFile("DESIGN_SYSTEM.md", "utf8");
+ for (const variant of ["vertical", "horizontal-compact", "horizontal-menu"]) {
+ assert.match(catalog, new RegExp(variant));
+ assert.match(documentation, new RegExp(variant));
+ }
+ assert.match(attributes, /nds-filter-menu/);
+ assert.match(attributes, /data-columns="2"/);
+ assert.match(attributes, /grid-template-columns: repeat\(2/);
+ assert.match(attributes, /nds-filter-option-label/);
+ const gameFilters = await readFile("website/src/styles/games/_filters.scss", "utf8");
+ assert.match(gameFilters, /nds-active-filters/);
+ assert.match(gameFilters, /nds-active-filter/);
+ assert.match(documentation, /composant commun `Tooltip`/);
+});
+
test("large sass files stay split by stable responsibility", async () => {
const orchestrators = [
"website/src/styles/home/_library.scss",
diff --git a/website/public/data/site.json b/website/public/data/site.json
index a933a3c..7eba1f7 100644
--- a/website/public/data/site.json
+++ b/website/public/data/site.json
@@ -2778,10 +2778,12 @@
"name": "Nom",
"searchPlaceholder": "Nom français ou anglais…",
"prismanaOnly": "Prismana uniquement",
+ "prismana": "Prismana",
"elements": "Éléments",
"roles": "Rôles",
"role": "Rôle",
"abilities": "Compétences de logis",
+ "abilityActions": "Actions",
"minimum": "Niveau minimum",
"exactLevel": "Niveau exact",
"levelMode": "Niveau",
diff --git a/website/src/components/CompactDropdown.jsx b/website/src/components/CompactDropdown.jsx
index ab6f39a..9c9eedf 100644
--- a/website/src/components/CompactDropdown.jsx
+++ b/website/src/components/CompactDropdown.jsx
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
export function CompactDropdown({
className = "",
menuClassName = "",
+ menuProps = {},
label,
openOnHover = false,
closeOnMouseLeave = false,
@@ -13,6 +14,7 @@ export function CompactDropdown({
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
+ const triggerRef = useRef(null);
const internalPointerDownRef = useRef(false);
useEffect(() => {
@@ -23,7 +25,10 @@ export function CompactDropdown({
}
function handleKeyDown(event) {
- if (event.key === "Escape") setOpen(false);
+ if (event.key === "Escape") {
+ setOpen(false);
+ triggerRef.current?.focus();
+ }
}
document.addEventListener("pointerdown", handlePointerDown);
@@ -60,11 +65,10 @@ export function CompactDropdown({
if (!event.currentTarget.contains(event.relatedTarget)) close();
}}
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
- aria-label={label}
>
- {renderTrigger({ open, toggle, close })}
+ {renderTrigger({ open, toggle, close, triggerRef })}
{open && (
-
+
{children({ close })}
)}
diff --git a/website/src/components/ImportButton.jsx b/website/src/components/ImportButton.jsx
index 7cd979d..5ae15be 100644
--- a/website/src/components/ImportButton.jsx
+++ b/website/src/components/ImportButton.jsx
@@ -1,9 +1,9 @@
// Rôle : encapsule l'input fichier JSON derrière un bouton d'import réutilisable.
import { Icon } from "./Icon.jsx";
+import { Tooltip } from "./Tooltip.jsx";
-export function ImportButton({ className, label, title, ariaLabel, icon, onFile, ...props }) {
- return (
-
- );
+ >;
+
+ if (tooltip) {
+ return
{children};
+ }
+ return
;
}
diff --git a/website/src/components/Shell.jsx b/website/src/components/Shell.jsx
index 2d142c5..8052be1 100644
--- a/website/src/components/Shell.jsx
+++ b/website/src/components/Shell.jsx
@@ -1,6 +1,7 @@
// Rôle : structure le layout global avec sidebar, topbar, navigation et contenu.
import { Icon } from "./Icon.jsx";
import { ImportButton } from "./ImportButton.jsx";
+import { Tooltip } from "./Tooltip.jsx";
export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) {
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
@@ -8,35 +9,36 @@ export function Shell({ route, content, games, toolboxes, links, drawerGameId =
const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null;
const topbarLabel = route === "/design-system" && import.meta.env.DEV ? "Design system" : route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard;
const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox";
+ const compactSidebar = Boolean(drawerGameId);
return (
-
-