Add responsive game filters and hybrid toolbox drawer
All checks were successful
Deploy Sokko G / deploy (push) Successful in 20s

This commit is contained in:
Shinuwa 2026-09-27 18:00:49 +02:00
parent 1ee460b94f
commit 00e73f0062
32 changed files with 1202 additions and 241 deletions

View file

@ -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

View file

@ -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 }) => {

75
e2e/game-filters.spec.js Normal file
View file

@ -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();
});

View file

@ -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, `<div class="drawer" aria-hidden="false"><div class="drawer-backdrop"></div>
await mountStyles(page, `<div class="drawer" aria-hidden="false">
<section class="drawer-panel nds-drawer-panel"><div style="height:1400px">Drawer</div></section>
<div class="drawer-resize-handle nds-drawer-resize-handle"></div></div>`);
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();

View file

@ -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]) {
<div class="equipment-planner-summary"><div class="equipment-planner-summary-source-grid"><div>Equipment</div><div>Materials</div></div></div>
<div class="toolbox-actions-row"><div>Actions</div><div>Preferences</div></div>
</main>`);
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");

View file

@ -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);
});

View file

@ -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",

View file

@ -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/);

View file

@ -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/);

View file

@ -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",

View file

@ -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",

View file

@ -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 && (
<div className={`${menuClassName} is-open`.trim()} aria-label={label}>
<div {...menuProps} className={`${menuClassName} ${menuProps.className || ""} is-open`.trim()} aria-label={menuProps["aria-label"] || (menuProps["aria-labelledby"] ? undefined : label)}>
{children({ close })}
</div>
)}

View file

@ -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 (
<label className={className} aria-label={ariaLabel} title={title} {...props}>
export function ImportButton({ className, label, title, ariaLabel, icon, onFile, tooltip = "", tooltipPosition = "top", ...props }) {
const children = <>
{icon && <Icon name={icon} />}
{label}
<input type="file" accept="application/json" hidden onChange={async (event) => {
@ -11,6 +11,10 @@ export function ImportButton({ className, label, title, ariaLabel, icon, onFile,
if (file) await onFile(file);
event.target.value = "";
}} />
</label>
);
</>;
if (tooltip) {
return <Tooltip as="label" className={className} content={tooltip} position={tooltipPosition} aria-label={ariaLabel} {...props}>{children}</Tooltip>;
}
return <label className={className} aria-label={ariaLabel} title={title} {...props}>{children}</label>;
}

View file

@ -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 (
<div className="app-shell">
<aside className="sidebar">
<div className={`app-shell ${compactSidebar ? "sidebar-compact" : ""} ${drawerGameId ? "toolbox-drawer-open" : ""}`}>
<aside className="sidebar" data-variant={compactSidebar ? "compact" : undefined}>
<a className="brand" href="#/" aria-label={content.brand.homeAriaLabel}>
<span className="brand-mark" aria-hidden="true" />
<span className="brand-name"><span className="brand-name-main">Sokko</span> <span className="brand-name-accent">G</span></span>
</a>
<nav className="nav" aria-label="Navigation principale">
<a className={`nav-item ${route === "/" ? "active" : ""}`} href="#/"><span className="nav-icon nav-icon-home" aria-hidden="true" /><strong>{content.navigation.home}</strong></a>
<a className={`nav-item ${route.startsWith("/games") ? "active" : ""}`} href="#/games"><span className="nav-icon nav-icon-controller" aria-hidden="true" /><strong>{content.navigation.games}</strong></a>
<a className={`nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}`} href="#/toolboxes"><span className="nav-icon nav-icon-toolbox" aria-hidden="true" /><strong>{content.navigation.toolboxes}</strong></a>
<Tooltip as="a" className={`nav-item ${route === "/" ? "active" : ""}`} content={compactSidebar ? content.navigation.home : ""} position="right" href="#/" aria-label={content.navigation.home}><span className="nav-icon nav-icon-home" aria-hidden="true" /><strong>{content.navigation.home}</strong></Tooltip>
<Tooltip as="a" className={`nav-item ${route.startsWith("/games") ? "active" : ""}`} content={compactSidebar ? content.navigation.games : ""} position="right" href="#/games" aria-label={content.navigation.games}><span className="nav-icon nav-icon-controller" aria-hidden="true" /><strong>{content.navigation.games}</strong></Tooltip>
<Tooltip as="a" className={`nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}`} content={compactSidebar ? content.navigation.toolboxes : ""} position="right" href="#/toolboxes" aria-label={content.navigation.toolboxes}><span className="nav-icon nav-icon-toolbox" aria-hidden="true" /><strong>{content.navigation.toolboxes}</strong></Tooltip>
</nav>
<div className="sidebar-mascot" aria-hidden="true">
<img src="/static/img/dragon-sidebar.png" alt="" />
</div>
<div className="sidebar-secondary-links">
<a className={`sidebar-about-link ${route === "/about" ? "active" : ""}`} href="#/about"><span className="sidebar-about-icon" aria-hidden="true">?</span><strong>{content.navigation.about}</strong></a>
<a className={`sidebar-about-link ${route === "/library" ? "active" : ""}`} href="#/library"><span className="sidebar-about-icon" aria-hidden="true"><Icon name="book" /></span><strong>{content.library.eyebrow}</strong></a>
<Tooltip as="a" className={`sidebar-about-link ${route === "/about" ? "active" : ""}`} content={compactSidebar ? content.navigation.about : ""} position="right" href="#/about" aria-label={content.navigation.about}><span className="sidebar-about-icon" aria-hidden="true">?</span><strong>{content.navigation.about}</strong></Tooltip>
<Tooltip as="a" className={`sidebar-about-link ${route === "/library" ? "active" : ""}`} content={compactSidebar ? content.library.eyebrow : ""} position="right" href="#/library" aria-label={content.library.eyebrow}><span className="sidebar-about-icon" aria-hidden="true"><Icon name="book" /></span><strong>{content.library.eyebrow}</strong></Tooltip>
{import.meta.env.DEV && (
<a className={`sidebar-about-link ${route === "/design-system" ? "active" : ""}`} href="#/design-system"><span className="sidebar-about-icon" aria-hidden="true"><Icon name="settings" /></span><strong>Design system</strong></a>
<Tooltip as="a" className={`sidebar-about-link ${route === "/design-system" ? "active" : ""}`} content={compactSidebar ? "Design system" : ""} position="right" href="#/design-system" aria-label="Design system"><span className="sidebar-about-icon" aria-hidden="true"><Icon name="settings" /></span><strong>Design system</strong></Tooltip>
)}
</div>
<div className="sidebar-note">
<span className="badge">{content.sidebar.badge}</span>
<p>{content.sidebar.note}</p>
<div className="sidebar-storage-actions" aria-label="Actions globales de stockage">
<button className="sidebar-action-button nds-icon-button" data-size="sm" data-variant="primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" data-toolbox-tutorial-target="exportAll" title="Exporter tout"><Icon name="export" /></button>
<ImportButton className="sidebar-action-button nds-icon-button import-icon-button" data-size="sm" title="Importer tout" ariaLabel="Importer toutes les toolboxes" onFile={actions.importAllToolboxes} icon="import" />
<Tooltip as="button" className="sidebar-action-button nds-icon-button" content={compactSidebar ? "Exporter toutes les toolboxes" : ""} position="right" data-size="sm" data-variant="primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" data-toolbox-tutorial-target="exportAll" title={compactSidebar ? undefined : "Exporter tout"}><Icon name="export" /></Tooltip>
<ImportButton className="sidebar-action-button nds-icon-button import-icon-button" data-size="sm" title={compactSidebar ? undefined : "Importer tout"} ariaLabel="Importer toutes les toolboxes" tooltip={compactSidebar ? "Importer toutes les toolboxes" : ""} tooltipPosition="right" onFile={actions.importAllToolboxes} icon="import" />
</div>
</div>
</aside>

View file

@ -12,6 +12,10 @@ export function Tooltip({ as: Component = "span", children, content, className =
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
if (position === "right") {
setStyle({ top: `${Math.min(window.innerHeight - 8, Math.max(8, rect.top + rect.height / 2))}px`, left: `${Math.min(window.innerWidth - 8, rect.right + 8)}px` });
return;
}
if (position === "top-start") {
setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.max(8, rect.left)}px` });
return;

View file

@ -0,0 +1,17 @@
// Rôle : affiche les critères actifs et permet de les retirer individuellement.
import { Icon } from "../../components/Icon.jsx";
export function ActiveFilterBadges({ items, label = "Filtres actifs" }) {
if (!items.length) return null;
return (
<div className="nds-active-filters" role="group" aria-label={label}>
{items.map((item) => (
<button className="nds-badge nds-active-filter" data-variant="neutral" type="button" key={item.id} aria-label={`Retirer le filtre ${item.label}`} onClick={item.onRemove}>
<span>{item.label}</span>
<Icon name="close" />
</button>
))}
</div>
);
}

View file

@ -0,0 +1,49 @@
// Rôle : fournit le menu déroulant partagé des barres de filtres avancées.
import { useId } from "react";
import { CompactDropdown } from "../../components/CompactDropdown.jsx";
import { Icon } from "../../components/Icon.jsx";
export function GameFilterDropdown({
label,
triggerLabel = label,
count = 0,
icon = "checklist",
align = "start",
columns,
className = "",
menuClassName = "",
children
}) {
const dropdownId = useId();
const triggerId = `${dropdownId}-trigger`;
const menuId = `${dropdownId}-menu`;
return (
<CompactDropdown
className={`nds-filter-dropdown ${align === "end" ? "is-aligned-end" : ""} ${className}`.trim()}
menuClassName={`nds-filter-menu nds-popover ${menuClassName}`.trim()}
menuProps={{ id: menuId, role: "dialog", "aria-labelledby": triggerId, "data-columns": columns }}
label={label}
renderTrigger={({ open, toggle, triggerRef }) => (
<button
ref={triggerRef}
id={triggerId}
className={`nds-filter-menu-trigger nds-select-trigger ${open ? "active" : ""}`}
data-size="sm"
type="button"
aria-controls={menuId}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={count > 0 ? `${label}, ${count} sélectionné${count > 1 ? "s" : ""}` : label}
onClick={toggle}
>
<Icon name={icon} />
<span>{triggerLabel}</span>
{count > 0 && <strong className="nds-filter-count" aria-hidden="true">{count}</strong>}
</button>
)}
>
{children}
</CompactDropdown>
);
}

View file

@ -1,5 +1,30 @@
// Rôle : fournit le panneau de filtres générique des pages de données de jeu.
import { Icon } from "../../components/Icon.jsx";
import { Tooltip } from "../../components/Tooltip.jsx";
import { ActiveFilterBadges } from "./ActiveFilterBadges.jsx";
import { GameFilterDropdown } from "./GameFilterDropdown.jsx";
function FilterLogic({ matchLabel, logicLabels, value, onChange }) {
return (
<div className="filter-logic nds-filter-logic">
<span>{matchLabel}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="radiogroup" aria-label={matchLabel}>
{["and", "or"].map((logic) => (
<button
key={logic}
className={value === logic ? "active" : ""}
type="button"
role="radio"
aria-checked={value === logic}
onClick={() => onChange(logic)}
>
{logicLabels[logic]}
</button>
))}
</div>
</div>
);
}
export function GameFiltersPanel({
title,
@ -14,6 +39,7 @@ export function GameFiltersPanel({
getOptionLabel = (option) => option,
getOptionClassName = () => "",
renderOptionMedia,
variant = "vertical",
onChange
}) {
const selected = new Set(filters[selectedKey] || []);
@ -21,8 +47,114 @@ export function GameFiltersPanel({
onChange(updater(filters));
}
function toggleOption(optionId) {
update((state) => {
const values = new Set(state[selectedKey] || []);
if (values.has(optionId)) values.delete(optionId);
else values.add(optionId);
return { ...state, [selectedKey]: [...values] };
});
}
function reset() {
onChange({ name: "", [selectedKey]: [], logic: "and" });
}
const activeBadges = [
...(filters.name.trim() ? [{ id: "name", label: `${nameLabel} : ${filters.name.trim()}`, onRemove: () => update((state) => ({ ...state, name: "" })) }] : []),
...options.filter((option) => selected.has(getOptionId(option))).map((option) => {
const optionId = getOptionId(option);
return {
id: `${selectedKey}-${optionId}`,
label: `${title} : ${getOptionLabel(option)}`,
onRemove: () => toggleOption(optionId)
};
})
];
if (variant === "horizontal-compact") {
return (
<div className="filter-panel nds-filter-panel">
<div className="filter-panel nds-filter-panel" data-variant={variant}>
<label className="nds-filter-search">
<span className="sr-only">{nameLabel}</span>
<input className="nds-input" data-size="sm" type="search" value={filters.name} placeholder="Rechercher..." onChange={(event) => update((state) => ({ ...state, name: event.target.value }))} />
</label>
<div className="nds-filter-group">
<span className="nds-filter-group-label">{title}</span>
<div className="nds-filter-direct-options" role="group" aria-label={title}>
{options.map((option) => {
const optionId = getOptionId(option);
const optionLabel = getOptionLabel(option);
const active = selected.has(optionId);
return (
<Tooltip
as="button"
className={`nds-filter-option ${getOptionClassName(option)} ${active ? "active" : ""}`.trim()}
content={optionLabel}
key={optionId}
type="button"
aria-label={optionLabel}
aria-pressed={active}
onClick={() => toggleOption(optionId)}
>
{renderOptionMedia?.(option)}
<span className="nds-filter-option-label">{optionLabel}</span>
</Tooltip>
);
})}
</div>
</div>
<div className="nds-filter-group">
<FilterLogic matchLabel={matchLabel} logicLabels={logicLabels} value={filters.logic} onChange={(logic) => update((state) => ({ ...state, logic }))} />
</div>
<button className="filter-reset-button nds-icon-button nds-filter-reset" data-size="xs" type="button" onClick={reset} aria-label={resetLabel} title={resetLabel}>
<Icon name="rubber" />
</button>
<ActiveFilterBadges items={activeBadges} />
</div>
);
}
if (variant === "horizontal-menu") {
return (
<div className="filter-panel nds-filter-panel" data-variant={variant}>
<label className="nds-filter-search">
<span className="sr-only">{nameLabel}</span>
<input className="nds-input" data-size="sm" type="search" value={filters.name} placeholder="Rechercher..." onChange={(event) => update((state) => ({ ...state, name: event.target.value }))} />
</label>
<div className="nds-filter-group">
<GameFilterDropdown label={title} count={selected.size}>
{() => (
<fieldset className="nds-filter-menu-fieldset">
<legend className="sr-only">{title}</legend>
<FilterLogic matchLabel={matchLabel} logicLabels={logicLabels} value={filters.logic} onChange={(logic) => update((state) => ({ ...state, logic }))} />
<div className="nds-filter-menu-options">
{options.map((option) => {
const optionId = getOptionId(option);
const optionLabel = getOptionLabel(option);
return (
<label className={`filter-chip nds-chip nds-filter-menu-option ${getOptionClassName(option)} ${selected.has(optionId) ? "active" : ""}`} key={optionId}>
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={selected.has(optionId)} onChange={() => toggleOption(optionId)} />
{renderOptionMedia?.(option)}
<span>{optionLabel}</span>
</label>
);
})}
</div>
</fieldset>
)}
</GameFilterDropdown>
</div>
<button className="filter-reset-button nds-icon-button nds-filter-reset" data-size="xs" type="button" onClick={reset} aria-label={resetLabel} title={resetLabel}>
<Icon name="rubber" />
</button>
<ActiveFilterBadges items={activeBadges} />
</div>
);
}
return (
<div className="filter-panel nds-filter-panel" data-variant="vertical">
<div className="filter-panel-head">
<div>
<p className="eyebrow">Filtres</p>
@ -31,7 +163,8 @@ export function GameFiltersPanel({
<button
className="filter-reset-button nds-icon-button"
data-size="xs"
onClick={() => onChange({ name: "", [selectedKey]: [], logic: "and" })}
type="button"
onClick={reset}
aria-label={resetLabel}
title={resetLabel}
>
@ -42,23 +175,7 @@ export function GameFiltersPanel({
<span>{nameLabel}</span>
<input className="nds-input" data-size="sm" type="search" value={filters.name} placeholder="Rechercher..." onChange={(event) => update((state) => ({ ...state, name: event.target.value }))} />
</label>
<div className="filter-logic">
<span>{matchLabel}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="radiogroup" aria-label={matchLabel}>
{["and", "or"].map((logic) => (
<button
key={logic}
className={filters.logic === logic ? "active" : ""}
type="button"
role="radio"
aria-checked={filters.logic === logic}
onClick={() => update((state) => ({ ...state, logic }))}
>
{logicLabels[logic]}
</button>
))}
</div>
</div>
<FilterLogic matchLabel={matchLabel} logicLabels={logicLabels} value={filters.logic} onChange={(logic) => update((state) => ({ ...state, logic }))} />
<div className="filter-options legacy-scrollbar">
{options.map((option) => {
const optionId = getOptionId(option);
@ -69,12 +186,7 @@ export function GameFiltersPanel({
data-size="sm"
type="checkbox"
checked={selected.has(optionId)}
onChange={(event) => update((state) => {
const values = new Set(state[selectedKey] || []);
if (event.target.checked) values.add(optionId);
else values.delete(optionId);
return { ...state, [selectedKey]: [...values] };
})}
onChange={() => toggleOption(optionId)}
/>
{renderOptionMedia?.(option)}
<span>{getOptionLabel(option)}</span>

View file

@ -1,11 +1,12 @@
// Rôle : expose les catégories repliables et les seuils Aniimo dans un défilement commun.
import { useState } from "react";
// Rôle : expose les filtres Aniimo dans une barre horizontale à menus avancés.
import { Icon } from "../../../components/Icon.jsx";
import { ActiveFilterBadges } from "../ActiveFilterBadges.jsx";
import { GameFilterDropdown } from "../GameFilterDropdown.jsx";
import { aniimoAsset, createAniimoFilters } from "./utils.js";
function LogicChoice({ label, value, onChange, content }) {
return (
<div className="filter-logic">
<div className="filter-logic nds-filter-logic">
<span>{content.match}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={label}>
{["and", "or"].map((logic) => <button key={logic} type="button" className={value === logic ? "active" : ""} aria-pressed={value === logic} onClick={() => onChange(logic)}>{content[logic]}</button>)}
@ -25,78 +26,101 @@ function LevelOperatorIcon({ exact = false }) {
}
export function AniimoFilters({ filters, onChange, options, t, content }) {
const [collapsed, setCollapsed] = useState({});
const abilityOptions = [...options.abilities].sort((a, b) => Number(!options.elements.includes(a)) - Number(!options.elements.includes(b)));
const elementAbilities = options.abilities.filter((option) => options.elements.includes(option));
const actionAbilities = options.abilities.filter((option) => !options.elements.includes(option));
function toggle(key, option, checked) {
onChange({ ...filters, [key]: checked ? [...filters[key], option] : filters[key].filter((value) => value !== option) });
}
function updateAbility(option, minimum) {
const abilities = { ...filters.abilities };
if (minimum > 0) abilities[option] = minimum;
else delete abilities[option];
onChange({ ...filters, abilities });
}
const activeBadges = [
...(filters.name.trim() ? [{ id: "name", label: `${content.name} : ${filters.name.trim()}`, onRemove: () => onChange({ ...filters, name: "" }) }] : []),
...(filters.prismanaOnly ? [{ id: "prismana", label: content.prismana, onRemove: () => onChange({ ...filters, prismanaOnly: false }) }] : []),
...filters.roles.map((option) => ({ id: `role-${option}`, label: `${content.roles} : ${t(option)}`, onRemove: () => toggle("roles", option, false) })),
...filters.elements.map((option) => ({ id: `element-${option}`, label: `${content.elements} : ${t(option)}`, onRemove: () => toggle("elements", option, false) })),
...Object.entries(filters.abilities).map(([option, level]) => ({ id: `ability-${option}`, label: `${t(option)} ${filters.abilityExact ? "=" : "≥"} ${level}`, onRemove: () => updateAbility(option, 0) }))
];
return (
<div className="filter-panel nds-filter-panel aniimo-filters">
<div className="filter-panel-head">
<div><p className="eyebrow">{content.filters}</p><h2>{content.title}</h2></div>
<button className="filter-reset-button nds-icon-button" data-size="xs" type="button" title={content.reset} aria-label={content.reset} onClick={() => onChange({ ...createAniimoFilters(), grouped: filters.grouped })}><Icon name="rubber" /></button>
</div>
<label className="field compact">
<span>{content.name}</span>
<div className="filter-panel nds-filter-panel aniimo-filters" data-variant="horizontal-menu">
<label className="nds-filter-search">
<span className="sr-only">{content.name}</span>
<input className="nds-input" data-size="sm" type="search" placeholder={content.searchPlaceholder} value={filters.name} onChange={(event) => onChange({ ...filters, name: event.target.value })} />
</label>
<label className={`filter-chip nds-chip ${filters.prismanaOnly ? "active" : ""}`} data-size="sm">
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={filters.prismanaOnly} onChange={(event) => onChange({ ...filters, prismanaOnly: event.target.checked })} />
<div className="nds-filter-group nds-filter-advanced-options">
<button className={`aniimo-prismana-filter nds-filter-option ${filters.prismanaOnly ? "active" : ""}`} type="button" aria-label={content.prismanaOnly} aria-pressed={filters.prismanaOnly} onClick={() => onChange({ ...filters, prismanaOnly: !filters.prismanaOnly })}>
<img src="/static/icons/prismana.svg" alt="" width="16" height="16" />
<span>{content.prismanaOnly}</span>
</label>
<div className="aniimo-filter-sections filter-options legacy-scrollbar" role="region" aria-label={content.filters} tabIndex="0">
{["roles", "elements", "abilities"].map((key) => (
<div key={key} className="aniimo-filter-section-surface">
<fieldset className="aniimo-filter-section">
<legend><button type="button" className="aniimo-filter-toggle nds-button" data-size="sm" data-variant="ghost" aria-expanded={!collapsed[key]} aria-controls={`aniimo-filter-${key}`} onClick={() => setCollapsed((current) => ({ ...current, [key]: !current[key] }))}>
<span>{content[key]}</span>
<span className="aniimo-selected-count" aria-hidden="true">{(key === "abilities" ? Object.keys(filters.abilities).length : filters[key].length) || ""}</span>
<Icon name={collapsed[key] ? "chevron-down" : "chevron-up"} />
</button></legend>
<div id={`aniimo-filter-${key}`} hidden={collapsed[key]}>
{key === "elements" && <LogicChoice label={content[key]} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />}
{key === "abilities" && <div className="filter-logic">
<span className="nds-filter-option-label">{content.prismana}</span>
</button>
<GameFilterDropdown label={content.roles} count={filters.roles.length} icon="shield" columns={2}>
{() => <FilterCheckboxOptions filterKey="roles" label={content.roles} values={options.roles} selected={filters.roles} t={t} onToggle={toggle} />}
</GameFilterDropdown>
<GameFilterDropdown label={content.elements} count={filters.elements.length} icon="fire" columns={2}>
{() => (
<fieldset className="nds-filter-menu-fieldset">
<legend className="sr-only">{content.elements}</legend>
<LogicChoice label={content.elements} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />
<FilterCheckboxOptions filterKey="elements" label={content.elements} values={options.elements} selected={filters.elements} t={t} onToggle={toggle} />
</fieldset>
)}
</GameFilterDropdown>
<GameFilterDropdown className="aniimo-abilities-dropdown" label={content.abilities} triggerLabel="Compétences" count={Object.keys(filters.abilities).length} icon="home" align="end" menuClassName="aniimo-abilities-menu">
{() => (
<fieldset className="nds-filter-menu-fieldset">
<legend className="sr-only">{content.abilities}</legend>
<div className="filter-logic nds-filter-logic">
<span>{content.levelMode}</span>
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={content.levelMode}>
<button type="button" className={!filters.abilityExact ? "active" : ""} aria-label={content.minimum} title={content.minimum} aria-pressed={!filters.abilityExact} onClick={() => onChange({ ...filters, abilityExact: false })}><LevelOperatorIcon /></button>
<button type="button" className={filters.abilityExact ? "active" : ""} aria-label={content.exactLevel} title={content.exactLevel} aria-pressed={filters.abilityExact} onClick={() => onChange({ ...filters, abilityExact: true })}><LevelOperatorIcon exact /></button>
</div>
</div>}
<div className={`aniimo-filter-options ${key === "abilities" ? "aniimo-ability-options" : "aniimo-chip-options"}`} role="region" aria-label={content[key]}>
{(key === "abilities" ? abilityOptions : options[key]).map((option) => {
const isAbility = key === "abilities";
const rangeId = `aniimo-minimum-${option}`;
if (isAbility) {
const level = filters.abilities[option] || 0;
return <div key={option} className="aniimo-ability-filter" data-ability-group={options.elements.includes(option) ? "elements" : "other"} data-active={level > 0 || undefined}>
<label htmlFor={rangeId}><img src={aniimoAsset(option)} alt="" width="18" height="18" /><span>{t(option)}</span></label>
<input id={rangeId} className="nds-range" data-size="sm" type="range" min="0" max="4" step="1" value={level} aria-label={`${t(option)} — ${filters.abilityExact ? content.exactLevel : content.minimum}`} aria-valuetext={level ? `${filters.abilityExact ? content.exactLevel : content.minimum} ${level}` : content.abilityDisabled} onChange={(event) => {
const abilities = { ...filters.abilities };
const minimum = Number(event.target.value);
if (minimum > 0) abilities[option] = minimum;
else delete abilities[option];
onChange({ ...filters, abilities });
}} />
<span className="aniimo-minimum-value" aria-hidden="true">{level}</span>
</div>;
}
const selected = filters[key].includes(option);
</div>
<div className="aniimo-filter-options aniimo-ability-options" role="region" aria-label={content.abilities}>
<AbilityFilterColumn label={content.elements} group="elements" options={elementAbilities} filters={filters} t={t} content={content} onChange={updateAbility} />
<AbilityFilterColumn label={content.abilityActions} group="actions" options={actionAbilities} filters={filters} t={t} content={content} onChange={updateAbility} />
</div>
</fieldset>
)}
</GameFilterDropdown>
</div>
<button className="filter-reset-button nds-icon-button nds-filter-reset" data-size="xs" type="button" title={content.reset} aria-label={content.reset} onClick={() => onChange({ ...createAniimoFilters(), grouped: filters.grouped })}><Icon name="rubber" /></button>
<ActiveFilterBadges items={activeBadges} />
</div>
);
}
function FilterCheckboxOptions({ filterKey, label, values, selected, t, onToggle }) {
return (
<label key={option} className={`filter-chip nds-chip ${selected ? "active" : ""}`} data-size="sm">
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={selected} onChange={(event) => toggle(key, option, event.target.checked)} />
<div className="nds-filter-menu-options aniimo-filter-options aniimo-chip-options" role="region" aria-label={label}>
{values.map((option) => (
<label key={option} className={`filter-chip nds-chip nds-filter-menu-option ${selected.includes(option) ? "active" : ""}`} data-size="sm">
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={selected.includes(option)} onChange={(event) => onToggle(filterKey, option, event.target.checked)} />
<img src={aniimoAsset(option)} alt="" width="16" height="16" />
<span>{t(option)}</span>
</label>
);
})}
</div>
</div>
</fieldset>
</div>
))}
</div>
);
}
function AbilityFilterColumn({ label, group, options, filters, t, content, onChange }) {
return (
<div className="aniimo-ability-column" role="group" aria-label={label}>
<div className="aniimo-ability-list">
{options.map((option) => {
const rangeId = `aniimo-minimum-${option}`;
const level = filters.abilities[option] || 0;
return <div key={option} className="aniimo-ability-filter" data-ability-group={group} data-active={level > 0 || undefined}>
<label htmlFor={rangeId}><img src={aniimoAsset(option)} alt="" width="18" height="18" /><span>{t(option)}</span></label>
<input id={rangeId} className="nds-range" data-size="sm" type="range" min="0" max="4" step="1" value={level} aria-label={`${t(option)} — ${filters.abilityExact ? content.exactLevel : content.minimum}`} aria-valuetext={level ? `${filters.abilityExact ? content.exactLevel : content.minimum} ${level}` : content.abilityDisabled} onChange={(event) => onChange(option, Number(event.target.value))} />
<span className="aniimo-minimum-value" aria-hidden="true">{level}</span>
</div>;
})}
</div>
</div>
);
}

View file

@ -13,6 +13,7 @@ export function Diablo4Filters({ options, filters, setFilters }) {
getOptionLabel={getCategoryLabel}
getOptionClassName={(option) => `diablo4-filter-chip tone-${option.tone || "default"}`}
renderOptionMedia={(option) => <span className="diablo4-category-icon" aria-hidden="true" style={getCategoryIconStyle(option)} />}
variant="horizontal-menu"
onChange={(nextFilters) => setFilters((state) => ({ ...state, diablo4: nextFilters }))}
/>
);

View file

@ -16,6 +16,7 @@ export function MhwildsFilters({ category, filterKey, options, filters, setFilte
options={options}
getOptionLabel={(option) => t(option, { capitalize: true })}
renderOptionMedia={(option) => <img src={assetPath(option)} alt="" />}
variant="horizontal-compact"
onChange={(nextFilters) => setFilters((state) => ({ ...state, [category]: nextFilters }))}
/>
);

View file

@ -9,7 +9,6 @@ import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../hooks/useInlineEdit.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js";
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
import {
@ -37,7 +36,11 @@ import {
uid
} from "./storage/toolboxStorage.js";
const DRAWER_WIDTH_SETTING = "drawerWidth";
const DRAWER_INITIAL_WIDTH = 700;
function getDrawerMaxWidth() {
return Math.max(DRAWER_INITIAL_WIDTH, Math.floor(window.innerWidth * 0.94));
}
function addModuleToOrder(moduleOrder, modules, moduleId) {
const order = normalizeModuleOrder(moduleOrder, modules);
@ -347,19 +350,16 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
const guideChecklist = checklist.available && checklist.guideId.split("/")[0] === gameId ? checklist : undefined;
const selectedId = links[gameId] || "";
const toolbox = toolboxes.find((item) => item.id === selectedId);
const [width, setWidth] = useState("");
const [width, setWidth] = useState(DRAWER_INITIAL_WIDTH);
const panelRef = useRef(null);
useEffect(() => {
let cancelled = false;
dbGetSetting(DRAWER_WIDTH_SETTING, "")
.then((storedWidth) => {
if (cancelled) return;
const value = Number(storedWidth);
if (Number.isFinite(value) && value > 0) setWidth(Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94)));
})
.catch(() => {});
return () => { cancelled = true; };
const clampToViewport = () => {
if (window.innerWidth < DRAWER_INITIAL_WIDTH) return;
setWidth((currentWidth) => Math.min(currentWidth, getDrawerMaxWidth()));
};
window.addEventListener("resize", clampToViewport);
return () => window.removeEventListener("resize", clampToViewport);
}, []);
function startResize(event) {
@ -368,13 +368,11 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
event.preventDefault();
const startX = event.clientX;
const startWidth = panel.getBoundingClientRect().width;
const minWidth = 360;
const maxWidth = Math.floor(window.innerWidth * 0.94);
const maxWidth = getDrawerMaxWidth();
document.body.classList.add("is-resizing-drawer");
const onMove = (moveEvent) => {
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, DRAWER_INITIAL_WIDTH), maxWidth);
setWidth(nextWidth);
dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {});
};
const stop = () => {
document.body.classList.remove("is-resizing-drawer");
@ -388,10 +386,9 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
}
return (
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={width ? { "--drawer-panel-width": `${width}px` } : undefined}>
<div className="drawer-backdrop" onClick={() => actions.setDrawerGameId("")} />
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={{ "--drawer-panel-width": `${width}px` }}>
<span className="drawer-resize-handle nds-drawer-resize-handle" aria-hidden="true" onPointerDown={startResize} />
<section className="drawer-panel nds-drawer-panel legacy-scrollbar" ref={panelRef} style={width ? { width } : undefined}>
<section className="drawer-panel nds-drawer-panel legacy-scrollbar" ref={panelRef}>
<div className="drawer-content">
<header>
<div>

View file

@ -1,7 +1,10 @@
// Rôle : expose en développement un catalogue vivant et interactif des contrôles NDS.
import { useState } from "react";
import { CompactDropdown } from "../components/CompactDropdown.jsx";
import { Icon } from "../components/Icon.jsx";
import { MixTagsInput } from "../components/MixTagsInput.jsx";
import { Tooltip } from "../components/Tooltip.jsx";
import { ActiveFilterBadges } from "../features/games/ActiveFilterBadges.jsx";
const SIZE_VALUES = {
field: ["xs", "sm", "md"],
@ -419,14 +422,9 @@ const PATTERN_COMPONENTS = [
name: "Filter Panel",
className: "nds-filter-panel",
sizes: SIZE_VALUES.none,
variants: VARIANT_VALUES.none,
attributes: {},
render: () => (
<aside className="nds-filter-panel design-system-pattern-card">
<header className="nds-section-heading"><strong>Filtres</strong><button className="nds-icon-button" data-size="xs" type="button" aria-label="Reset"><Icon name="rubber" /></button></header>
<button className="nds-chip" data-size="sm" type="button" aria-pressed="true">Actif</button>
</aside>
)
variants: ["vertical", "horizontal-compact", "horizontal-menu"],
attributes: { "data-variant": ["vertical", "horizontal-compact", "horizontal-menu"] },
render: ({ attributes, variant }) => <FilterPanelPreview attributes={attributes} variant={variant} />
},
{
id: "nds-reorder-indicator",
@ -714,6 +712,70 @@ function NativeSwitchPreview({ disabled = false, initialChecked = false }) {
);
}
function FilterPanelPreview({ attributes, variant }) {
const [search, setSearch] = useState("");
const [logic, setLogic] = useState("and");
const [selected, setSelected] = useState(["fire"]);
const options = [
{ id: "fire", label: "Feu", icon: "fire" },
{ id: "water", label: "Eau", icon: "tear" },
{ id: "nature", label: "Nature", icon: "leaf" }
];
const toggle = (id) => setSelected((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const activeBadges = [
...(search.trim() ? [{ id: "search", label: `Nom : ${search.trim()}`, onRemove: () => setSearch("") }] : []),
...selected.map((id) => ({ id, label: `Éléments : ${options.find((option) => option.id === id)?.label}`, onRemove: () => toggle(id) }))
];
const reset = () => {
setSearch("");
setLogic("and");
setSelected([]);
};
if (variant === "vertical") {
return (
<aside className="nds-filter-panel design-system-filter-preview" {...attributes}>
<header className="nds-section-heading"><strong>Filtres</strong><button className="nds-icon-button" data-size="xs" type="button" aria-label="Réinitialiser" onClick={reset}><Icon name="rubber" /></button></header>
<label className="field compact"><span>Nom</span><input className="nds-input" data-size="sm" type="search" value={search} placeholder="Rechercher..." onChange={(event) => setSearch(event.target.value)} /></label>
<div className="filter-options">
{options.map((option) => <label className={`filter-chip nds-chip ${selected.includes(option.id) ? "active" : ""}`} data-size="sm" key={option.id}><input className="nds-checkbox" data-size="sm" type="checkbox" checked={selected.includes(option.id)} onChange={() => toggle(option.id)} /><Icon name={option.icon} /><span>{option.label}</span></label>)}
</div>
</aside>
);
}
return (
<aside className="nds-filter-panel design-system-filter-preview" {...attributes}>
<label className="nds-filter-search"><span className="sr-only">Nom</span><input className="nds-input" data-size="sm" type="search" value={search} placeholder="Rechercher..." onChange={(event) => setSearch(event.target.value)} /></label>
<div className="nds-filter-group">
{variant === "horizontal-compact" ? (
<>
<span className="nds-filter-group-label">Éléments</span>
<div className="nds-filter-direct-options" role="group" aria-label="Éléments">
{options.map((option) => <Tooltip as="button" className={`nds-filter-option ${selected.includes(option.id) ? "active" : ""}`} content={option.label} key={option.id} type="button" aria-label={option.label} aria-pressed={selected.includes(option.id)} onClick={() => toggle(option.id)}><Icon name={option.icon} /><span className="nds-filter-option-label">{option.label}</span></Tooltip>)}
</div>
</>
) : (
<CompactDropdown
className="nds-filter-dropdown"
menuClassName="nds-filter-menu nds-popover"
menuProps={{ role: "dialog", "aria-label": "Catégories", "data-columns": "2" }}
label="Catégories"
renderTrigger={({ open, toggle: toggleMenu, triggerRef }) => <button ref={triggerRef} className={`nds-filter-menu-trigger nds-select-trigger ${open ? "active" : ""}`} data-size="sm" type="button" aria-expanded={open} aria-haspopup="dialog" onClick={toggleMenu}><Icon name="checklist" /><span>Catégories</span>{selected.length > 0 && <strong className="nds-filter-count">{selected.length}</strong>}</button>}
>
{() => <><div className="filter-logic nds-filter-logic"><span>Correspondance</span><div className="filter-logic-group nds-radio-button-group" data-size="xs" role="radiogroup" aria-label="Correspondance">{["and", "or"].map((value) => <button className={logic === value ? "active" : ""} key={value} type="button" role="radio" aria-checked={logic === value} onClick={() => setLogic(value)}>{value === "and" ? "Et" : "Ou"}</button>)}</div></div><div className="nds-filter-menu-options">{options.map((option) => <label className={`filter-chip nds-chip nds-filter-menu-option ${selected.includes(option.id) ? "active" : ""}`} data-size="sm" key={option.id}><input className="nds-checkbox" data-size="sm" type="checkbox" checked={selected.includes(option.id)} onChange={() => toggle(option.id)} /><Icon name={option.icon} /><span>{option.label}</span></label>)}</div></>}
</CompactDropdown>
)}
</div>
{variant === "horizontal-compact" && <div className="nds-filter-group">
<div className="filter-logic nds-filter-logic"><span>Correspondance</span><div className="filter-logic-group nds-radio-button-group" data-size="xs" role="radiogroup" aria-label="Correspondance">{["and", "or"].map((value) => <button className={logic === value ? "active" : ""} key={value} type="button" role="radio" aria-checked={logic === value} onClick={() => setLogic(value)}>{value === "and" ? "Et" : "Ou"}</button>)}</div></div>
</div>}
<button className="nds-icon-button nds-filter-reset" data-size="xs" type="button" aria-label="Réinitialiser" onClick={reset}><Icon name="rubber" /></button>
<ActiveFilterBadges items={activeBadges} />
</aside>
);
}
function DatePickerPreview() {
const [activeDay, setActiveDay] = useState(2);
const days = Array.from({ length: 35 }, (_, index) => {

View file

@ -4,31 +4,20 @@
.aniimo-home-card img { padding: 0 15%; object-fit: cover; object-position: 50% 38%; }
.aniimo-layout .game-results { min-width: 0; }
.aniimo-filters { min-width: 0; }
.aniimo-filters > .filter-chip { margin-bottom: var(--space-3); }
.aniimo-filter-sections { gap: 16px; }
.aniimo-filter-section-surface + .aniimo-filter-section-surface { padding-top: 12px; border-top: 1px solid var(--color-border); }
.aniimo-filter-section {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
legend { width: 100%; padding: 0; margin-bottom: 6px; }
}
.aniimo-filter-toggle.nds-button {
display: flex;
width: 100%;
justify-content: space-between;
padding-inline: 4px;
.ui-icon { width: 14px; height: 14px; }
}
.aniimo-selected-count { margin-left: auto; color: var(--color-accent-gold); }
.aniimo-filters .nds-filter-advanced-options { flex: 1 1 auto; }
.aniimo-prismana-filter span { white-space: nowrap; }
.aniimo-filters .aniimo-abilities-dropdown { flex-basis: 180px; min-width: 180px; }
.nds-filter-menu.aniimo-abilities-menu { width: min(680px, calc(100vw - 32px)); }
.aniimo-filter-options {
display: grid;
gap: 8px;
}
.aniimo-chip-options { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.aniimo-chip-options { gap: 6px; }
.aniimo-chip-options .filter-chip { min-width: 0; }
.aniimo-ability-options { gap: 2px; }
.aniimo-ability-options { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px; overflow: visible; }
.aniimo-ability-column { display: grid; min-width: 0; align-content: start; gap: 6px; }
.aniimo-ability-column:first-child { padding-right: 12px; border-right: 1px solid var(--color-border); }
.aniimo-ability-list { display: grid; gap: 4px; }
.aniimo-ability-filter {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(64px, 1fr) 2ch;
@ -52,6 +41,15 @@
stroke-linejoin: round;
shape-rendering: geometricPrecision;
}
@media (max-width: 560px) {
.aniimo-ability-options { gap: 8px; }
.aniimo-ability-column:first-child { padding-right: 8px; }
.aniimo-ability-filter { grid-template-columns: minmax(0, 1fr) 2ch; }
.aniimo-ability-filter label { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.nds-filter-menu.aniimo-abilities-menu { width: auto; }
}
.aniimo-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); }
.aniimo-result { min-width: 0; max-width: 280px; width: 100%; }
.aniimo-tabs-slot { height: 36px; min-width: 0; padding: 2px; }

View file

@ -44,3 +44,16 @@
.ui-tooltip-top-start::after {
left: 16px;
}
.ui-tooltip-right {
transform: translate(0, -50%);
}
.ui-tooltip-right::after {
top: 50%;
left: 0;
border-right: 0;
border-bottom: 1px solid rgba(165, 180, 252, 0.18);
border-left: 1px solid rgba(165, 180, 252, 0.18);
transform: translate(-4px, -50%) rotate(45deg);
}

View file

@ -468,6 +468,15 @@
padding: var(--space-4);
}
.design-system-filter-preview {
width: 100%;
min-width: 0;
}
.design-system-filter-preview[data-variant="horizontal-compact"] {
container-type: inline-size;
}
.design-system-pattern-card strong,
.design-system-pattern-wide strong,
.design-system-pattern-hero strong,

View file

@ -33,6 +33,10 @@
justify-content: center;
}
.sidebar[data-variant="compact"] .sidebar-note {
display: block;
}
.about-story-section,
.about-contribute-section {
grid-template-columns: 1fr;
@ -379,9 +383,13 @@
color: var(--color-text-primary);
}
}
@media (max-width: 699px) {
.drawer-panel {
width: 100vw;
min-width: 0;
width: min(720px, 94vw);
max-width: none;
}
.drawer-resize-handle {

View file

@ -9,6 +9,10 @@
min-height: 100vh;
}
.app-shell.sidebar-compact {
grid-template-columns: 72px minmax(0, 1fr);
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
@ -292,6 +296,61 @@
display: inline-flex;
}
.sidebar[data-variant="compact"] {
width: 72px;
gap: var(--space-4);
padding: var(--space-4) 10px;
}
.sidebar[data-variant="compact"] .brand-name,
.sidebar[data-variant="compact"] .nav-item strong,
.sidebar[data-variant="compact"] .sidebar-about-link strong,
.sidebar[data-variant="compact"] .sidebar-mascot,
.sidebar[data-variant="compact"] .sidebar-note > .badge,
.sidebar[data-variant="compact"] .sidebar-note > p {
display: none;
}
.sidebar[data-variant="compact"] .brand,
.sidebar[data-variant="compact"] .nav-item,
.sidebar[data-variant="compact"] .sidebar-about-link {
justify-content: center;
}
.sidebar[data-variant="compact"] .nav-item {
width: 100%;
padding-inline: 10px;
}
.sidebar[data-variant="compact"] .sidebar-secondary-links {
margin-top: auto;
}
.sidebar[data-variant="compact"] .sidebar-about-link {
width: 100%;
padding-inline: 8px;
}
.sidebar[data-variant="compact"] .sidebar-note {
display: block;
margin-top: 0;
padding: 6px;
border-radius: var(--radius-md);
}
.sidebar[data-variant="compact"] .sidebar-storage-actions {
flex-direction: column;
align-items: center;
gap: 6px;
margin-top: 0;
}
.sidebar[data-variant="compact"] .sidebar-action-button {
width: 36px;
height: 36px;
flex: 0 0 36px;
}
.content-shell {
grid-column: 2;
display: flex;
@ -300,6 +359,26 @@
flex-direction: column;
}
@media (min-width: 700px) {
.app-shell.toolbox-drawer-open .content-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 700px;
grid-template-rows: auto minmax(0, 1fr);
}
.app-shell.toolbox-drawer-open .topbar {
grid-column: 1 / -1;
grid-row: 1;
}
.app-shell.toolbox-drawer-open main {
grid-column: 1;
grid-row: 2;
min-width: 0;
width: min(1600px, max(0px, calc(100% - 64px)));
}
}
.topbar {
position: sticky;
top: 0;

View file

@ -158,6 +158,248 @@
@include controls.filter-panel-pattern;
}
.nds-filter-panel[data-variant="horizontal-compact"],
.nds-filter-panel[data-variant="horizontal-menu"] {
@include controls.filter-bar-pattern;
}
.nds-filter-panel[data-variant="horizontal-compact"]:has(> .nds-active-filters),
.nds-filter-panel[data-variant="horizontal-menu"]:has(> .nds-active-filters) {
row-gap: 10px;
}
.nds-filter-panel[data-variant="horizontal-compact"] {
container-type: inline-size;
}
.nds-filter-search {
flex: 0 1 280px;
width: min(100%, 280px);
max-width: 280px;
min-width: min(100%, 190px);
}
.nds-filter-search > .nds-input {
width: 100%;
}
.nds-filter-group {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding-left: 10px;
border-left: 1px solid var(--color-border-hover);
}
.nds-filter-group-label {
flex: 0 0 auto;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 800;
}
.nds-filter-direct-options,
.nds-filter-advanced-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}
.nds-filter-option {
@include controls.text-button(sm);
width: auto;
min-width: 0;
flex: 0 0 auto;
justify-content: center;
padding-inline: 8px;
font-weight: 800;
}
.nds-filter-option:hover:not(.active, [aria-pressed="true"]) {
@include controls.text-button-hover;
}
.nds-filter-option.active,
.nds-filter-option[aria-pressed="true"] {
@include controls.text-button-active;
}
.nds-filter-option > img,
.nds-filter-option > .ui-icon,
.nds-filter-option > [class$="-icon"] {
width: 20px;
height: 20px;
flex: 0 0 20px;
object-fit: contain;
}
.nds-filter-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nds-filter-panel[data-variant="horizontal-compact"] .nds-filter-logic,
.nds-filter-panel[data-variant="horizontal-menu"] .nds-filter-logic {
min-height: 34px;
margin: 0;
padding: 4px 6px 4px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.46);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.nds-filter-reset {
flex: 0 0 auto;
margin-left: auto;
}
.nds-filter-dropdown {
position: relative;
flex: 0 1 176px;
min-width: 176px;
}
.nds-filter-dropdown.is-open {
z-index: 2;
}
.nds-filter-menu-trigger.nds-select-trigger,
.nds-filter-menu-trigger.nds-select-trigger[data-size="sm"] {
grid-template-columns: 14px minmax(0, 1fr) auto;
min-width: 176px;
width: 100%;
}
.nds-filter-count {
display: inline-grid;
min-width: 20px;
min-height: 20px;
place-items: center;
padding-inline: 5px;
border: 1px solid rgba(246, 196, 83, 0.28);
border-radius: var(--radius-pill);
background: rgba(246, 196, 83, 0.1);
color: var(--color-accent-gold);
font-size: 0.68rem;
line-height: 1;
}
.nds-filter-menu {
position: absolute;
z-index: 90;
top: calc(100% + 8px);
left: 0;
width: min(320px, calc(100vw - 32px));
padding: 10px;
overflow: visible;
}
.nds-filter-menu[data-columns="2"] .nds-filter-menu-options {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.nds-filter-panel .nds-filter-menu .nds-filter-logic {
margin-bottom: 12px;
}
.nds-filter-dropdown.is-aligned-end > .nds-filter-menu {
right: 0;
left: auto;
}
.nds-filter-menu-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.nds-filter-menu-options {
display: grid;
gap: 6px;
overflow: visible;
}
.nds-filter-menu-option {
grid-template-columns: 18px 22px minmax(0, 1fr);
min-width: 0;
width: 100%;
border-radius: var(--radius-md);
}
.nds-filter-menu-option > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container (max-width: 1040px) {
.nds-filter-panel[data-variant="horizontal-compact"] .nds-filter-option {
width: 36px;
min-width: 36px;
padding-inline: 0;
}
.nds-filter-panel[data-variant="horizontal-compact"] .nds-filter-option-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip: rect(0 0 0 0);
white-space: nowrap;
}
}
@media (max-width: 760px) {
.nds-filter-panel[data-variant="horizontal-compact"],
.nds-filter-panel[data-variant="horizontal-menu"] {
align-items: stretch;
}
.nds-filter-search {
flex-basis: 100%;
width: 100%;
max-width: none;
}
.nds-filter-group {
flex: 1 1 100%;
flex-wrap: wrap;
padding-top: 8px;
padding-left: 0;
border-top: 1px solid var(--color-border-hover);
border-left: 0;
}
.nds-filter-direct-options,
.nds-filter-advanced-options {
flex: 1 1 100%;
}
.nds-filter-dropdown {
position: static;
flex: 1 1 176px;
}
.nds-filter-menu,
.nds-filter-dropdown.is-aligned-end > .nds-filter-menu {
top: calc(100% + 8px);
right: 12px;
left: 12px;
width: auto;
}
}
.nds-reorder-indicator,
.nds-reorder-indicator[data-axis="vertical"][data-placement="before"] {
@include controls.reorder-indicator-pattern(vertical, before);

View file

@ -241,6 +241,20 @@
backdrop-filter: blur(10px);
}
@mixin filter-bar-pattern {
@include filter-panel-pattern;
position: relative;
overflow: visible;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px 12px;
border-radius: var(--radius-lg);
background: rgba(12, 16, 34, 0.82);
}
@mixin reorder-indicator-pattern($axis: vertical, $placement: before) {
position: relative;

View file

@ -1,7 +1,7 @@
// Rôle : regroupe le layout, les filtres et les barres de contrôle des pages jeux.
.game-layout {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: var(--space-5);
margin-top: var(--space-6);
align-items: start;
@ -9,7 +9,42 @@
.game-filters {
position: sticky;
top: 92px;
top: 68px;
z-index: 40;
}
.nds-active-filters {
display: flex;
width: calc(100% + 24px);
min-width: 0;
flex: 1 1 calc(100% + 24px);
flex-wrap: wrap;
align-items: center;
gap: 4px;
min-height: 28px;
margin: 0 -12px -6px;
padding: 4px 12px 0;
border-top: 1px solid rgba(246, 196, 83, 0.3);
}
.nds-active-filter.nds-badge {
min-height: 24px;
gap: 4px;
padding: 2px 6px 2px 8px;
color: var(--color-text-secondary);
cursor: pointer;
}
.nds-active-filter.nds-badge:hover {
border-color: var(--color-primary-border);
background: var(--color-primary-soft);
color: var(--color-text-primary);
}
.nds-active-filter .ui-icon {
width: 13px;
height: 13px;
flex: 0 0 13px;
}
.filter-panel-head,

View file

@ -3,21 +3,13 @@
display: none;
}
.drawer-backdrop {
position: fixed;
inset: var(--drawer-top-offset, 68px) 0 0 0;
z-index: 40;
background: rgba(2, 3, 9, 0.24);
backdrop-filter: none;
}
.drawer-panel {
position: fixed;
inset: var(--drawer-top-offset, 68px) 0 0 auto;
z-index: 50;
width: min(720px, 94vw);
min-width: 360px;
max-width: 94vw;
width: var(--drawer-panel-width, 700px);
min-width: 700px;
max-width: max(700px, 94vw);
padding: var(--space-5);
}
@ -36,7 +28,7 @@
.drawer-resize-handle {
position: fixed;
top: calc(var(--drawer-top-offset, 68px) + ((100vh - var(--drawer-top-offset, 68px)) / 2));
right: calc(var(--drawer-panel-width, min(720px, 94vw)) - 18px);
right: calc(var(--drawer-panel-width, 700px) - 18px);
z-index: 62;
width: 14px;
height: 72px;