From 00e73f0062b91a77f272337eee520a9947c99420 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sun, 27 Sep 2026 18:00:49 +0200 Subject: [PATCH] Add responsive game filters and hybrid toolbox drawer --- DESIGN_SYSTEM.md | 36 ++- e2e/aniimo.spec.js | 150 +++++++---- e2e/game-filters.spec.js | 75 ++++++ e2e/nds-patterns.spec.js | 5 +- e2e/style-layouts.spec.js | 34 ++- e2e/toolbox-drawer-layout.spec.js | 81 ++++++ tests/helpers/data-validation.mjs | 2 + tests/static-app.test.mjs | 11 + tests/static-games.test.mjs | 22 ++ tests/static-styles.test.mjs | 18 ++ website/public/data/site.json | 2 + website/src/components/CompactDropdown.jsx | 12 +- website/src/components/ImportButton.jsx | 14 +- website/src/components/Shell.jsx | 22 +- website/src/components/Tooltip.jsx | 4 + .../src/features/games/ActiveFilterBadges.jsx | 17 ++ .../src/features/games/GameFilterDropdown.jsx | 49 ++++ .../src/features/games/GameFiltersPanel.jsx | 162 ++++++++++-- .../features/games/aniimo/AniimoFilters.jsx | 152 ++++++----- .../features/games/diablo4/Diablo4Filters.jsx | 1 + .../features/games/mhwilds/MhwildsFilters.jsx | 1 + .../src/features/toolboxes/ToolboxPages.jsx | 35 ++- website/src/pages/DesignSystemPage.jsx | 78 +++++- website/src/styles/_aniimo.scss | 38 ++- website/src/styles/_components.scss | 13 + website/src/styles/_design-system.scss | 9 + website/src/styles/_responsive.scss | 10 +- website/src/styles/_shell.scss | 79 ++++++ .../design-system/attributes/_patterns.scss | 242 ++++++++++++++++++ .../design-system/controls/_patterns.scss | 14 + website/src/styles/games/_filters.scss | 39 ++- website/src/styles/overlays/_drawers.scss | 16 +- 32 files changed, 1202 insertions(+), 241 deletions(-) create mode 100644 e2e/game-filters.spec.js create mode 100644 e2e/toolbox-drawer-layout.spec.js create mode 100644 website/src/features/games/ActiveFilterBadges.jsx create mode 100644 website/src/features/games/GameFilterDropdown.jsx 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, `
Drawer
`); 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]) {
Equipment
Materials
Actions
Preferences
`); - 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 ( -
-