Add responsive game filters and hybrid toolbox drawer
All checks were successful
Deploy Sokko G / deploy (push) Successful in 20s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 20s
This commit is contained in:
parent
1ee460b94f
commit
00e73f0062
32 changed files with 1202 additions and 241 deletions
|
|
@ -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-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-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-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-empty-state` : etat vide court, avec action optionnelle.
|
||||||
- `nds-inline-notice` : feedback compact inline, avec `data-variant="info|success|warning|danger"`.
|
- `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`.
|
- `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 ;
|
- icones SVG pour Accueil, Toolboxes et Jeux ;
|
||||||
- navigation active en violet soft ;
|
- navigation active en violet soft ;
|
||||||
- le logo controller garde le gradient de l'ancien badge et une rotation au hover.
|
- 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.
|
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 :
|
Filtres :
|
||||||
|
|
||||||
- chips compactes ;
|
- `vertical` conserve le panneau latéral historique avec des chips checkbox, icône puis texte ;
|
||||||
- checkbox, icone puis texte ;
|
- `horizontal-compact` aligne recherche, petit groupe d’options directes, correspondance et reset au-dessus des résultats ;
|
||||||
- spacing lisible : checkbox pas collee au bord, icone proche du texte ;
|
- `horizontal-menu` remplace les groupes nombreux, ambigus ou avancés par des `nds-select-trigger` ouvrant des `nds-popover` ;
|
||||||
- reset avec bouton icone `rubber`.
|
- `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 :
|
Tableaux :
|
||||||
|
|
||||||
|
|
@ -609,8 +619,11 @@ Modales :
|
||||||
|
|
||||||
Drawer toolbox :
|
Drawer toolbox :
|
||||||
|
|
||||||
- panneau lateral redimensionnable ;
|
- panneau lateral ancre a droite, ouvert a `700px` et redimensionnable uniquement vers une largeur superieure ;
|
||||||
- overlay floute leger ;
|
- 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.
|
- les actions internes ne doivent pas fermer le drawer sauf action explicite.
|
||||||
|
|
||||||
Notifications internes :
|
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 ;
|
- le verso occupe exactement la même surface que le recto ; garder son en-tête fixe et faire défiler les détails dans une zone NDS accessible au clavier ; masquer la face inactive aux technologies d'assistance ;
|
||||||
- réduire l'animation selon `prefers-reduced-motion` ;
|
- réduire l'animation selon `prefers-reduced-motion` ;
|
||||||
- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ; les 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 ;
|
- 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 ;
|
- 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 » ;
|
||||||
- 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 ;
|
- 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 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 ;
|
- 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 ;
|
||||||
- garder le nombre de critères sélectionnés visible dans l'en-tête, même replié.
|
- 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
|
### Liaison des guides aux checklists
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,51 @@ import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
const route = "/#/games/aniimo/aniimos";
|
const route = "/#/games/aniimo/aniimos";
|
||||||
const cards = (page) => page.locator(".aniimo-result");
|
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]) {
|
for (const width of [1280, 390]) {
|
||||||
test(`Prismana filter combines with search and survives grouping and navigation at ${width}px`, async ({ page }) => {
|
test(`Prismana filter combines with search and survives grouping and navigation at ${width}px`, async ({ page }) => {
|
||||||
test.setTimeout(45000);
|
test.setTimeout(45000);
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
await page.goto(route);
|
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).toBeVisible({ timeout: 30000 });
|
||||||
await expect(prismana).not.toBeChecked();
|
await expect(prismana).toHaveAttribute("aria-pressed", "false");
|
||||||
await prismana.focus();
|
await prismana.focus();
|
||||||
await prismana.press("Space");
|
await prismana.press("Space");
|
||||||
await expect(cards(page)).toHaveCount(27);
|
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.locator('.aniimo-result:not([data-form="prismana_form"])')).toHaveCount(0);
|
||||||
await expect(page.getByRole("tablist")).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);
|
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 expect(cards(page)).toHaveCount(3);
|
||||||
await page.locator('.game-breadcrumb a[href="#/games/aniimo"]').click();
|
await page.locator('.game-breadcrumb a[href="#/games/aniimo"]').click();
|
||||||
await page.locator('a[href="#/games/aniimo/aniimos"]').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 expect(cards(page)).toHaveCount(3);
|
||||||
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
||||||
await expect(cards(page)).toHaveCount(3);
|
await expect(cards(page)).toHaveCount(3);
|
||||||
await page.getByRole("searchbox").fill("emberpup");
|
await page.getByRole("searchbox").fill("emberpup");
|
||||||
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
await 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(cards(page)).toHaveCount(208);
|
||||||
await expect(page.getByRole("button", { name: "Regrouper les variantes", exact: true })).toHaveAttribute("aria-pressed", "false");
|
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);
|
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 indicator = await styles(page.locator(".mhwilds-flip-indicator").first(), indicatorProperties);
|
||||||
const filter = await styles(page.locator(".filter-logic"), filterProperties);
|
const filter = await styles(page.locator(".filter-logic"), filterProperties);
|
||||||
await page.goto(route);
|
await page.goto(route);
|
||||||
|
await openFilter(page, "Éléments");
|
||||||
const card = cards(page).first();
|
const card = cards(page).first();
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
expect(await styles(card.locator(".aniimo-card-inner"), motionProperties)).toEqual(motion);
|
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"), faceProperties)).toEqual(face);
|
||||||
expect(await styles(card.locator(".aniimo-front .aniimo-flip"), indicatorProperties)).toEqual(indicator);
|
expect(await styles(card.locator(".aniimo-front .aniimo-flip"), indicatorProperties)).toEqual(indicator);
|
||||||
expect(await styles(page.locator(".filter-logic").first(), filterProperties)).toEqual(filter);
|
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-face .aniimo-flip")).toHaveCount(2);
|
||||||
await expect(card.locator(".aniimo-card > .aniimo-flip")).toHaveCount(0);
|
await expect(card.locator(".aniimo-card > .aniimo-flip")).toHaveCount(0);
|
||||||
await card.getByRole("button", { name: /Afficher les capacités/ }).press("Enter");
|
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 page.keyboard.press("ArrowRight");
|
||||||
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
|
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
|
||||||
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
|
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.getByRole("tab")).toHaveCount(0);
|
||||||
await expect(card).toHaveAttribute("data-form", "highlands");
|
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 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 expect(cards(page)).toHaveCount(1);
|
||||||
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
|
||||||
await expect(cards(page)).toHaveCount(1);
|
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 }) => {
|
test("ability levels use independent native ranges and retain name constraints", async ({ page }) => {
|
||||||
await page.goto(route);
|
await page.goto(route);
|
||||||
const abilities = section(page, "Compétences de logis");
|
const abilities = await openFilter(page, "Compétences de logis");
|
||||||
const carry = page.locator("#aniimo-minimum-carry");
|
const carry = page.locator("#aniimo-minimum-carry");
|
||||||
const crafting = page.locator("#aniimo-minimum-crafting");
|
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("checkbox")).toHaveCount(0);
|
||||||
await expect(abilities.getByRole("button", { name: "OU", exact: true })).toHaveCount(0);
|
await expect(abilities.getByRole("button", { name: "OU", exact: true })).toHaveCount(0);
|
||||||
const minimumLevel = abilities.getByRole("button", { name: "Niveau minimum", exact: true });
|
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 carry.press("ArrowRight");
|
await carry.press("ArrowRight");
|
||||||
await expect(carry).toHaveValue("3");
|
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 expect(crafting).toHaveValue("0");
|
||||||
await crafting.focus();
|
await crafting.focus();
|
||||||
await crafting.press("ArrowRight");
|
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(exactLevel).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(minimumLevel).toHaveAttribute("aria-pressed", "false");
|
await expect(minimumLevel).toHaveAttribute("aria-pressed", "false");
|
||||||
await expect(carry).toHaveAccessibleName("Porter — Niveau exact");
|
await expect(carry).toHaveAccessibleName("Porter — Niveau exact");
|
||||||
await expect(section(page, "Éléments").getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
|
await page.keyboard.press("Escape");
|
||||||
await carry.press("Home");
|
const elements = await openFilter(page, "Éléments");
|
||||||
await crafting.press("Home");
|
await expect(elements.getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||||
await expect(carry).toHaveAttribute("aria-valuetext", "Sans filtre");
|
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 expect(cards(page)).toHaveCount(88);
|
||||||
await page.getByRole("searchbox").fill("does-not-exist");
|
await page.getByRole("searchbox").fill("does-not-exist");
|
||||||
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
|
||||||
await expect(minimumLevel).toHaveAttribute("aria-pressed", "true");
|
const resetAbilities = await openFilter(page, "Compétences de logis");
|
||||||
await expect(exactLevel).toHaveAttribute("aria-pressed", "false");
|
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 }) => {
|
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 expect(card).toHaveAttribute("data-form", "basic_form");
|
||||||
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
|
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
|
||||||
await expect(card).toHaveAttribute("data-form", "prismana_form");
|
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 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 page.getByRole("searchbox").fill("emberpup");
|
||||||
await expect(cards(page)).toHaveAttribute("data-form", "basic_form");
|
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 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");
|
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);
|
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.setViewportSize({ width: 1920, height: 1080 });
|
||||||
await page.goto(route);
|
await page.goto(route);
|
||||||
await expect(cards(page)).toHaveCount(88);
|
await expect(cards(page)).toHaveCount(88);
|
||||||
const columns = await page.locator(".aniimo-grid").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length);
|
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/);
|
await expect(page.locator(".ui-icon-multiple")).toHaveCSS("mask-image", /multiples\.svg/);
|
||||||
const card = cards(page).first();
|
const card = cards(page).first();
|
||||||
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
|
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") });
|
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);
|
await page.goto(route);
|
||||||
const elements = section(page, "Éléments");
|
const roles = await openFilter(page, "Rôles");
|
||||||
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");
|
|
||||||
await expect(roles.getByRole("checkbox", { name: "SOIN", exact: true })).toHaveCount(1);
|
await expect(roles.getByRole("checkbox", { name: "SOIN", exact: true })).toHaveCount(1);
|
||||||
await expect(roles.getByRole("checkbox", { name: "SOUTIEN", 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 });
|
await page.keyboard.press("Escape");
|
||||||
const scroll = page.locator(".aniimo-filter-sections");
|
await expect(filterTrigger(page, "Rôles")).toBeFocused();
|
||||||
await expect(scroll).toHaveCSS("overflow-y", "auto");
|
const elements = await openFilter(page, "Éléments");
|
||||||
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 elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
|
await elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
|
||||||
const results = await page.getByRole("status").textContent();
|
const results = await page.getByRole("status").textContent();
|
||||||
const toggle = elements.getByRole("button", { name: "Éléments", exact: true });
|
await expect(filterTrigger(page, "Éléments").locator(".nds-filter-count")).toHaveText("1");
|
||||||
await toggle.click();
|
await page.keyboard.press("Escape");
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false");
|
await expect(elements).toBeHidden();
|
||||||
await expect(elementScroll).toBeHidden();
|
|
||||||
await expect(toggle.locator(".aniimo-selected-count")).toHaveText("1");
|
|
||||||
await expect(page.getByRole("status")).toHaveText(results);
|
await expect(page.getByRole("status")).toHaveText(results);
|
||||||
await expect(roles.getByRole("region")).toBeVisible();
|
const reopenedElements = await openFilter(page, "Éléments");
|
||||||
await expect(abilities.getByRole("region")).toBeVisible();
|
await expect(reopenedElements.getByRole("checkbox", { name: "Roche", exact: true })).toBeChecked();
|
||||||
await toggle.press("Enter");
|
await page.keyboard.press("Escape");
|
||||||
await expect(elements.getByRole("checkbox", { name: "Roche", exact: true })).toBeChecked();
|
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" });
|
const slider = abilities.getByRole("slider", { name: "Porter — Niveau minimum" });
|
||||||
await slider.focus();
|
await slider.focus();
|
||||||
await slider.press("End");
|
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 expect(slider).toBeHidden();
|
||||||
await abilities.getByRole("button", { name: "Compétences de logis", exact: true }).click();
|
const reopenedAbilities = await openFilter(page, "Compétences de logis");
|
||||||
await expect(slider).toHaveValue("4");
|
await expect(reopenedAbilities.getByRole("slider", { name: "Porter — Niveau minimum" })).toHaveValue("4");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pending and failed data requests show loading and error states", async ({ page }) => {
|
test("pending and failed data requests show loading and error states", async ({ page }) => {
|
||||||
|
|
|
||||||
75
e2e/game-filters.spec.js
Normal file
75
e2e/game-filters.spec.js
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -88,7 +88,7 @@ for (const width of [1280, 390]) {
|
||||||
await page.locator("body").evaluate((element) => element.classList.add("is-modal-open"));
|
await page.locator("body").evaluate((element) => element.classList.add("is-modal-open"));
|
||||||
await expect(page.locator("body")).toHaveCSS("overflow", "hidden");
|
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>
|
<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>`);
|
<div class="drawer-resize-handle nds-drawer-resize-handle"></div></div>`);
|
||||||
const drawer = page.locator(".drawer-panel");
|
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("position", "fixed");
|
||||||
await expect(drawer).toHaveCSS("overflow-y", "auto");
|
await expect(drawer).toHaveCSS("overflow-y", "auto");
|
||||||
await expect(drawer).toHaveCSS("backdrop-filter", "blur(10px)");
|
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 page.locator(".drawer").evaluate((element) => element.setAttribute("aria-hidden", "true"));
|
||||||
await expect(drawer).toBeHidden();
|
await expect(drawer).toBeHidden();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,34 @@ for (const width of [1280, 900, 390]) {
|
||||||
await expect(page.locator(".home-onboarding-slider img")).toHaveCSS("object-fit", "cover");
|
await expect(page.locator(".home-onboarding-slider img")).toHaveCSS("object-fit", "cover");
|
||||||
const side = page.locator(".sidebar");
|
const side = page.locator(".sidebar");
|
||||||
await expect(side).toHaveCSS("display", width <= 760 ? "none" : "flex");
|
await expect(side).toHaveCSS("display", width <= 760 ? "none" : "flex");
|
||||||
await expect(page.locator(".sidebar-mascot")).toHaveCSS("display", width <= 1024 ? "none" : "block");
|
if (width > 760) {
|
||||||
for (const link of await page.locator(".sidebar-about-link").all()) {
|
const responsiveCompact = width <= 1024;
|
||||||
await expect(link).toHaveCSS("align-items", "center");
|
await expect(side).not.toHaveAttribute("data-variant");
|
||||||
if (width <= 1024) await expect(link).toHaveCSS("justify-content", "center");
|
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 page.goto("/#/about");
|
||||||
await expectFits(page.locator(".about-page"));
|
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="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>
|
<div class="toolbox-actions-row"><div>Actions</div><div>Preferences</div></div>
|
||||||
</main>`);
|
</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 expect(page.locator(".game-grid")).toHaveCSS("display", "grid");
|
||||||
await columns(page.locator(".modules"), width <= 760 ? 1 : 2);
|
await columns(page.locator(".modules"), width <= 760 ? 1 : 2);
|
||||||
await expect(page.locator(".module-column").first()).toHaveCSS("display", width <= 760 ? "contents" : "grid");
|
await expect(page.locator(".module-column").first()).toHaveCSS("display", width <= 760 ? "contents" : "grid");
|
||||||
|
|
|
||||||
81
e2e/toolbox-drawer-layout.spec.js
Normal file
81
e2e/toolbox-drawer-layout.spec.js
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -115,10 +115,12 @@ export function validateSiteContent(site) {
|
||||||
"aniimo.name",
|
"aniimo.name",
|
||||||
"aniimo.searchPlaceholder",
|
"aniimo.searchPlaceholder",
|
||||||
"aniimo.prismanaOnly",
|
"aniimo.prismanaOnly",
|
||||||
|
"aniimo.prismana",
|
||||||
"aniimo.elements",
|
"aniimo.elements",
|
||||||
"aniimo.roles",
|
"aniimo.roles",
|
||||||
"aniimo.role",
|
"aniimo.role",
|
||||||
"aniimo.abilities",
|
"aniimo.abilities",
|
||||||
|
"aniimo.abilityActions",
|
||||||
"aniimo.minimum",
|
"aniimo.minimum",
|
||||||
"aniimo.exactLevel",
|
"aniimo.exactLevel",
|
||||||
"aniimo.levelMode",
|
"aniimo.levelMode",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ test("vite entrypoint and app shell are wired", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8");
|
const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8");
|
||||||
const shell = await readFile("website/src/components/Shell.jsx", "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 overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8");
|
||||||
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
||||||
const homePage = await readFile("website/src/pages/HomePage.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, /sidebar-about-link/);
|
||||||
assert.match(shell, /dragon-sidebar\.png/);
|
assert.match(shell, /dragon-sidebar\.png/);
|
||||||
assert.match(shell, /ImportButton/);
|
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, /\.\/ImageViewer\.jsx/);
|
||||||
assert.match(overlays, /uid\("marker"\)/);
|
assert.match(overlays, /uid\("marker"\)/);
|
||||||
assert.match(imageViewer, /export function ImageViewer/);
|
assert.match(imageViewer, /export function ImageViewer/);
|
||||||
|
|
|
||||||
|
|
@ -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 gameRoute = await readFile("website/src/features/games/GameRoute.jsx", "utf8");
|
||||||
const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8");
|
const gameLoaders = await readFile("website/src/features/games/loaders.js", "utf8");
|
||||||
const gameFiltersPanel = await readFile("website/src/features/games/GameFiltersPanel.jsx", "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 checklistCopyButton = await readFile("website/src/features/games/CopyChecklistItemsButton.jsx", "utf8");
|
||||||
const gameListsPage = await readFile("website/src/features/games/GameListsPage.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(gameLoaders, /filterOptionKeys/);
|
||||||
assert.match(gameFiltersPanel, /export function GameFiltersPanel/);
|
assert.match(gameFiltersPanel, /export function GameFiltersPanel/);
|
||||||
assert.match(gameFiltersPanel, /filter-reset-button/);
|
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, /formatChecklistImportItems/);
|
||||||
assert.match(checklistCopyButton, /formatChecklistImportSections/);
|
assert.match(checklistCopyButton, /formatChecklistImportSections/);
|
||||||
assert.match(checklistCopyButton, /\$\{entry\.label\}:\$\{entry\.quantity\}/);
|
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 mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
|
||||||
const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.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 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 aniimoLists = await readFile("website/src/features/games/aniimo/AniimoLists.jsx", "utf8");
|
||||||
const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.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");
|
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(diablo4Listing, /CopyChecklistItemsButton/);
|
||||||
assert.match(diablo4Filters, /export function Diablo4Filters/);
|
assert.match(diablo4Filters, /export function Diablo4Filters/);
|
||||||
assert.match(diablo4Filters, /GameFiltersPanel/);
|
assert.match(diablo4Filters, /GameFiltersPanel/);
|
||||||
|
assert.match(diablo4Filters, /variant="horizontal-menu"/);
|
||||||
assert.match(diablo4AffixCard, /export function Diablo4AffixCard/);
|
assert.match(diablo4AffixCard, /export function Diablo4AffixCard/);
|
||||||
assert.match(diablo4AffixCard, /categoryMap/);
|
assert.match(diablo4AffixCard, /categoryMap/);
|
||||||
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
|
assert.match(diablo4Utils, /getFilteredDiablo4Affixes/);
|
||||||
|
|
@ -83,9 +98,16 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
|
||||||
assert.match(mhwildsLists, /selectedListId/);
|
assert.match(mhwildsLists, /selectedListId/);
|
||||||
assert.match(aniimoPage, /AniimoLists/);
|
assert.match(aniimoPage, /AniimoLists/);
|
||||||
assert.match(aniimoPage, /games\/aniimo\/lists/);
|
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(aniimoLists, /GameListsPage/);
|
||||||
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
assert.match(mhwildsFilters, /export function MhwildsFilters/);
|
||||||
assert.match(mhwildsFilters, /GameFiltersPanel/);
|
assert.match(mhwildsFilters, /GameFiltersPanel/);
|
||||||
|
assert.match(mhwildsFilters, /variant="horizontal-compact"/);
|
||||||
assert.match(monsterCard, /export function MonsterCard/);
|
assert.match(monsterCard, /export function MonsterCard/);
|
||||||
assert.match(monsterCard, /ui-icon-flip/);
|
assert.match(monsterCard, /ui-icon-flip/);
|
||||||
assert.match(endemicCard, /export function EndemicCard/);
|
assert.match(endemicCard, /export function EndemicCard/);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
test("large sass files stay split by stable responsibility", async () => {
|
||||||
const orchestrators = [
|
const orchestrators = [
|
||||||
"website/src/styles/home/_library.scss",
|
"website/src/styles/home/_library.scss",
|
||||||
|
|
|
||||||
|
|
@ -2778,10 +2778,12 @@
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"searchPlaceholder": "Nom français ou anglais…",
|
"searchPlaceholder": "Nom français ou anglais…",
|
||||||
"prismanaOnly": "Prismana uniquement",
|
"prismanaOnly": "Prismana uniquement",
|
||||||
|
"prismana": "Prismana",
|
||||||
"elements": "Éléments",
|
"elements": "Éléments",
|
||||||
"roles": "Rôles",
|
"roles": "Rôles",
|
||||||
"role": "Rôle",
|
"role": "Rôle",
|
||||||
"abilities": "Compétences de logis",
|
"abilities": "Compétences de logis",
|
||||||
|
"abilityActions": "Actions",
|
||||||
"minimum": "Niveau minimum",
|
"minimum": "Niveau minimum",
|
||||||
"exactLevel": "Niveau exact",
|
"exactLevel": "Niveau exact",
|
||||||
"levelMode": "Niveau",
|
"levelMode": "Niveau",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||||
export function CompactDropdown({
|
export function CompactDropdown({
|
||||||
className = "",
|
className = "",
|
||||||
menuClassName = "",
|
menuClassName = "",
|
||||||
|
menuProps = {},
|
||||||
label,
|
label,
|
||||||
openOnHover = false,
|
openOnHover = false,
|
||||||
closeOnMouseLeave = false,
|
closeOnMouseLeave = false,
|
||||||
|
|
@ -13,6 +14,7 @@ export function CompactDropdown({
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef(null);
|
const rootRef = useRef(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
const internalPointerDownRef = useRef(false);
|
const internalPointerDownRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -23,7 +25,10 @@ export function CompactDropdown({
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event) {
|
||||||
if (event.key === "Escape") setOpen(false);
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("pointerdown", handlePointerDown);
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
|
@ -60,11 +65,10 @@ export function CompactDropdown({
|
||||||
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
if (!event.currentTarget.contains(event.relatedTarget)) close();
|
||||||
}}
|
}}
|
||||||
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
|
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
|
||||||
aria-label={label}
|
|
||||||
>
|
>
|
||||||
{renderTrigger({ open, toggle, close })}
|
{renderTrigger({ open, toggle, close, triggerRef })}
|
||||||
{open && (
|
{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 })}
|
{children({ close })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
// Rôle : encapsule l'input fichier JSON derrière un bouton d'import réutilisable.
|
// Rôle : encapsule l'input fichier JSON derrière un bouton d'import réutilisable.
|
||||||
import { Icon } from "./Icon.jsx";
|
import { Icon } from "./Icon.jsx";
|
||||||
|
import { Tooltip } from "./Tooltip.jsx";
|
||||||
|
|
||||||
export function ImportButton({ className, label, title, ariaLabel, icon, onFile, ...props }) {
|
export function ImportButton({ className, label, title, ariaLabel, icon, onFile, tooltip = "", tooltipPosition = "top", ...props }) {
|
||||||
return (
|
const children = <>
|
||||||
<label className={className} aria-label={ariaLabel} title={title} {...props}>
|
|
||||||
{icon && <Icon name={icon} />}
|
{icon && <Icon name={icon} />}
|
||||||
{label}
|
{label}
|
||||||
<input type="file" accept="application/json" hidden onChange={async (event) => {
|
<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);
|
if (file) await onFile(file);
|
||||||
event.target.value = "";
|
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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Rôle : structure le layout global avec sidebar, topbar, navigation et contenu.
|
// Rôle : structure le layout global avec sidebar, topbar, navigation et contenu.
|
||||||
import { Icon } from "./Icon.jsx";
|
import { Icon } from "./Icon.jsx";
|
||||||
import { ImportButton } from "./ImportButton.jsx";
|
import { ImportButton } from "./ImportButton.jsx";
|
||||||
|
import { Tooltip } from "./Tooltip.jsx";
|
||||||
|
|
||||||
export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) {
|
export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) {
|
||||||
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
|
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 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 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 toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox";
|
||||||
|
const compactSidebar = Boolean(drawerGameId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className={`app-shell ${compactSidebar ? "sidebar-compact" : ""} ${drawerGameId ? "toolbox-drawer-open" : ""}`}>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar" data-variant={compactSidebar ? "compact" : undefined}>
|
||||||
<a className="brand" href="#/" aria-label={content.brand.homeAriaLabel}>
|
<a className="brand" href="#/" aria-label={content.brand.homeAriaLabel}>
|
||||||
<span className="brand-mark" aria-hidden="true" />
|
<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>
|
<span className="brand-name"><span className="brand-name-main">Sokko</span> <span className="brand-name-accent">G</span></span>
|
||||||
</a>
|
</a>
|
||||||
<nav className="nav" aria-label="Navigation principale">
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<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.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>
|
</nav>
|
||||||
<div className="sidebar-mascot" aria-hidden="true">
|
<div className="sidebar-mascot" aria-hidden="true">
|
||||||
<img src="/static/img/dragon-sidebar.png" alt="" />
|
<img src="/static/img/dragon-sidebar.png" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-secondary-links">
|
<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>
|
<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>
|
||||||
<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 === "/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 && (
|
{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>
|
||||||
<div className="sidebar-note">
|
<div className="sidebar-note">
|
||||||
<span className="badge">{content.sidebar.badge}</span>
|
<span className="badge">{content.sidebar.badge}</span>
|
||||||
<p>{content.sidebar.note}</p>
|
<p>{content.sidebar.note}</p>
|
||||||
<div className="sidebar-storage-actions" aria-label="Actions globales de stockage">
|
<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>
|
<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="Importer tout" ariaLabel="Importer toutes les toolboxes" onFile={actions.importAllToolboxes} icon="import" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export function Tooltip({ as: Component = "span", children, content, className =
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
if (!anchor) return;
|
if (!anchor) return;
|
||||||
const rect = anchor.getBoundingClientRect();
|
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") {
|
if (position === "top-start") {
|
||||||
setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.max(8, rect.left)}px` });
|
setStyle({ top: `${Math.max(8, rect.top - 8)}px`, left: `${Math.max(8, rect.left)}px` });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
17
website/src/features/games/ActiveFilterBadges.jsx
Normal file
17
website/src/features/games/ActiveFilterBadges.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
website/src/features/games/GameFilterDropdown.jsx
Normal file
49
website/src/features/games/GameFilterDropdown.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,30 @@
|
||||||
// Rôle : fournit le panneau de filtres générique des pages de données de jeu.
|
// Rôle : fournit le panneau de filtres générique des pages de données de jeu.
|
||||||
import { Icon } from "../../components/Icon.jsx";
|
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({
|
export function GameFiltersPanel({
|
||||||
title,
|
title,
|
||||||
|
|
@ -14,6 +39,7 @@ export function GameFiltersPanel({
|
||||||
getOptionLabel = (option) => option,
|
getOptionLabel = (option) => option,
|
||||||
getOptionClassName = () => "",
|
getOptionClassName = () => "",
|
||||||
renderOptionMedia,
|
renderOptionMedia,
|
||||||
|
variant = "vertical",
|
||||||
onChange
|
onChange
|
||||||
}) {
|
}) {
|
||||||
const selected = new Set(filters[selectedKey] || []);
|
const selected = new Set(filters[selectedKey] || []);
|
||||||
|
|
@ -21,8 +47,114 @@ export function GameFiltersPanel({
|
||||||
onChange(updater(filters));
|
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" 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 (
|
return (
|
||||||
<div className="filter-panel nds-filter-panel">
|
<div className="filter-panel nds-filter-panel" data-variant="vertical">
|
||||||
<div className="filter-panel-head">
|
<div className="filter-panel-head">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Filtres</p>
|
<p className="eyebrow">Filtres</p>
|
||||||
|
|
@ -31,7 +163,8 @@ export function GameFiltersPanel({
|
||||||
<button
|
<button
|
||||||
className="filter-reset-button nds-icon-button"
|
className="filter-reset-button nds-icon-button"
|
||||||
data-size="xs"
|
data-size="xs"
|
||||||
onClick={() => onChange({ name: "", [selectedKey]: [], logic: "and" })}
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
aria-label={resetLabel}
|
aria-label={resetLabel}
|
||||||
title={resetLabel}
|
title={resetLabel}
|
||||||
>
|
>
|
||||||
|
|
@ -42,23 +175,7 @@ export function GameFiltersPanel({
|
||||||
<span>{nameLabel}</span>
|
<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 }))} />
|
<input className="nds-input" data-size="sm" type="search" value={filters.name} placeholder="Rechercher..." onChange={(event) => update((state) => ({ ...state, name: event.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<div className="filter-logic">
|
<FilterLogic matchLabel={matchLabel} logicLabels={logicLabels} value={filters.logic} onChange={(logic) => update((state) => ({ ...state, 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>
|
|
||||||
<div className="filter-options legacy-scrollbar">
|
<div className="filter-options legacy-scrollbar">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const optionId = getOptionId(option);
|
const optionId = getOptionId(option);
|
||||||
|
|
@ -69,12 +186,7 @@ export function GameFiltersPanel({
|
||||||
data-size="sm"
|
data-size="sm"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(optionId)}
|
checked={selected.has(optionId)}
|
||||||
onChange={(event) => update((state) => {
|
onChange={() => toggleOption(optionId)}
|
||||||
const values = new Set(state[selectedKey] || []);
|
|
||||||
if (event.target.checked) values.add(optionId);
|
|
||||||
else values.delete(optionId);
|
|
||||||
return { ...state, [selectedKey]: [...values] };
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
{renderOptionMedia?.(option)}
|
{renderOptionMedia?.(option)}
|
||||||
<span>{getOptionLabel(option)}</span>
|
<span>{getOptionLabel(option)}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// Rôle : expose les catégories repliables et les seuils Aniimo dans un défilement commun.
|
// Rôle : expose les filtres Aniimo dans une barre horizontale à menus avancés.
|
||||||
import { useState } from "react";
|
|
||||||
import { Icon } from "../../../components/Icon.jsx";
|
import { Icon } from "../../../components/Icon.jsx";
|
||||||
|
import { ActiveFilterBadges } from "../ActiveFilterBadges.jsx";
|
||||||
|
import { GameFilterDropdown } from "../GameFilterDropdown.jsx";
|
||||||
import { aniimoAsset, createAniimoFilters } from "./utils.js";
|
import { aniimoAsset, createAniimoFilters } from "./utils.js";
|
||||||
|
|
||||||
function LogicChoice({ label, value, onChange, content }) {
|
function LogicChoice({ label, value, onChange, content }) {
|
||||||
return (
|
return (
|
||||||
<div className="filter-logic">
|
<div className="filter-logic nds-filter-logic">
|
||||||
<span>{content.match}</span>
|
<span>{content.match}</span>
|
||||||
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={label}>
|
<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>)}
|
{["and", "or"].map((logic) => <button key={logic} type="button" className={value === logic ? "active" : ""} aria-pressed={value === logic} onClick={() => onChange(logic)}>{content[logic]}</button>)}
|
||||||
|
|
@ -25,77 +26,100 @@ function LevelOperatorIcon({ exact = false }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AniimoFilters({ filters, onChange, options, t, content }) {
|
export function AniimoFilters({ filters, onChange, options, t, content }) {
|
||||||
const [collapsed, setCollapsed] = useState({});
|
const elementAbilities = options.abilities.filter((option) => options.elements.includes(option));
|
||||||
const abilityOptions = [...options.abilities].sort((a, b) => Number(!options.elements.includes(a)) - Number(!options.elements.includes(b)));
|
const actionAbilities = options.abilities.filter((option) => !options.elements.includes(option));
|
||||||
function toggle(key, option, checked) {
|
function toggle(key, option, checked) {
|
||||||
onChange({ ...filters, [key]: checked ? [...filters[key], option] : filters[key].filter((value) => value !== option) });
|
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 (
|
return (
|
||||||
<div className="filter-panel nds-filter-panel aniimo-filters">
|
<div className="filter-panel nds-filter-panel aniimo-filters" data-variant="horizontal-menu">
|
||||||
<div className="filter-panel-head">
|
<label className="nds-filter-search">
|
||||||
<div><p className="eyebrow">{content.filters}</p><h2>{content.title}</h2></div>
|
<span className="sr-only">{content.name}</span>
|
||||||
<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>
|
|
||||||
<input className="nds-input" data-size="sm" type="search" placeholder={content.searchPlaceholder} value={filters.name} onChange={(event) => onChange({ ...filters, name: event.target.value })} />
|
<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>
|
||||||
<label className={`filter-chip nds-chip ${filters.prismanaOnly ? "active" : ""}`} data-size="sm">
|
<div className="nds-filter-group nds-filter-advanced-options">
|
||||||
<input className="nds-checkbox" data-size="sm" type="checkbox" checked={filters.prismanaOnly} onChange={(event) => onChange({ ...filters, prismanaOnly: event.target.checked })} />
|
<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" />
|
<img src="/static/icons/prismana.svg" alt="" width="16" height="16" />
|
||||||
<span>{content.prismanaOnly}</span>
|
<span className="nds-filter-option-label">{content.prismana}</span>
|
||||||
</label>
|
</button>
|
||||||
<div className="aniimo-filter-sections filter-options legacy-scrollbar" role="region" aria-label={content.filters} tabIndex="0">
|
<GameFilterDropdown label={content.roles} count={filters.roles.length} icon="shield" columns={2}>
|
||||||
{["roles", "elements", "abilities"].map((key) => (
|
{() => <FilterCheckboxOptions filterKey="roles" label={content.roles} values={options.roles} selected={filters.roles} t={t} onToggle={toggle} />}
|
||||||
<div key={key} className="aniimo-filter-section-surface">
|
</GameFilterDropdown>
|
||||||
<fieldset className="aniimo-filter-section">
|
<GameFilterDropdown label={content.elements} count={filters.elements.length} icon="fire" columns={2}>
|
||||||
<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>
|
<fieldset className="nds-filter-menu-fieldset">
|
||||||
<span className="aniimo-selected-count" aria-hidden="true">{(key === "abilities" ? Object.keys(filters.abilities).length : filters[key].length) || ""}</span>
|
<legend className="sr-only">{content.elements}</legend>
|
||||||
<Icon name={collapsed[key] ? "chevron-down" : "chevron-up"} />
|
<LogicChoice label={content.elements} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />
|
||||||
</button></legend>
|
<FilterCheckboxOptions filterKey="elements" label={content.elements} values={options.elements} selected={filters.elements} t={t} onToggle={toggle} />
|
||||||
<div id={`aniimo-filter-${key}`} hidden={collapsed[key]}>
|
</fieldset>
|
||||||
{key === "elements" && <LogicChoice label={content[key]} content={content} value={filters.elementsLogic} onChange={(logic) => onChange({ ...filters, elementsLogic: logic })} />}
|
)}
|
||||||
{key === "abilities" && <div className="filter-logic">
|
</GameFilterDropdown>
|
||||||
<span>{content.levelMode}</span>
|
<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">
|
||||||
<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>
|
<fieldset className="nds-filter-menu-fieldset">
|
||||||
<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>
|
<legend className="sr-only">{content.abilities}</legend>
|
||||||
</div>
|
<div className="filter-logic nds-filter-logic">
|
||||||
</div>}
|
<span>{content.levelMode}</span>
|
||||||
<div className={`aniimo-filter-options ${key === "abilities" ? "aniimo-ability-options" : "aniimo-chip-options"}`} role="region" aria-label={content[key]}>
|
<div className="filter-logic-group nds-radio-button-group" data-size="xs" role="group" aria-label={content.levelMode}>
|
||||||
{(key === "abilities" ? abilityOptions : options[key]).map((option) => {
|
<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>
|
||||||
const isAbility = key === "abilities";
|
<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>
|
||||||
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);
|
|
||||||
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)} />
|
|
||||||
<img src={aniimoAsset(option)} alt="" width="16" height="16" />
|
|
||||||
<span>{t(option)}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</fieldset>
|
||||||
</div>
|
)}
|
||||||
))}
|
</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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export function Diablo4Filters({ options, filters, setFilters }) {
|
||||||
getOptionLabel={getCategoryLabel}
|
getOptionLabel={getCategoryLabel}
|
||||||
getOptionClassName={(option) => `diablo4-filter-chip tone-${option.tone || "default"}`}
|
getOptionClassName={(option) => `diablo4-filter-chip tone-${option.tone || "default"}`}
|
||||||
renderOptionMedia={(option) => <span className="diablo4-category-icon" aria-hidden="true" style={getCategoryIconStyle(option)} />}
|
renderOptionMedia={(option) => <span className="diablo4-category-icon" aria-hidden="true" style={getCategoryIconStyle(option)} />}
|
||||||
|
variant="horizontal-menu"
|
||||||
onChange={(nextFilters) => setFilters((state) => ({ ...state, diablo4: nextFilters }))}
|
onChange={(nextFilters) => setFilters((state) => ({ ...state, diablo4: nextFilters }))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export function MhwildsFilters({ category, filterKey, options, filters, setFilte
|
||||||
options={options}
|
options={options}
|
||||||
getOptionLabel={(option) => t(option, { capitalize: true })}
|
getOptionLabel={(option) => t(option, { capitalize: true })}
|
||||||
renderOptionMedia={(option) => <img src={assetPath(option)} alt="" />}
|
renderOptionMedia={(option) => <img src={assetPath(option)} alt="" />}
|
||||||
|
variant="horizontal-compact"
|
||||||
onChange={(nextFilters) => setFilters((state) => ({ ...state, [category]: nextFilters }))}
|
onChange={(nextFilters) => setFilters((state) => ({ ...state, [category]: nextFilters }))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
|
||||||
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
|
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
|
||||||
import { useInlineEdit } from "../../hooks/useInlineEdit.js";
|
import { useInlineEdit } from "../../hooks/useInlineEdit.js";
|
||||||
import { compressImage } from "../../utils/imageCompression.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 { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
|
||||||
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
|
import { AddToolControls, ToolboxModules } from "./modules/index.jsx";
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,7 +36,11 @@ import {
|
||||||
uid
|
uid
|
||||||
} from "./storage/toolboxStorage.js";
|
} 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) {
|
function addModuleToOrder(moduleOrder, modules, moduleId) {
|
||||||
const order = normalizeModuleOrder(moduleOrder, modules);
|
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 guideChecklist = checklist.available && checklist.guideId.split("/")[0] === gameId ? checklist : undefined;
|
||||||
const selectedId = links[gameId] || "";
|
const selectedId = links[gameId] || "";
|
||||||
const toolbox = toolboxes.find((item) => item.id === selectedId);
|
const toolbox = toolboxes.find((item) => item.id === selectedId);
|
||||||
const [width, setWidth] = useState("");
|
const [width, setWidth] = useState(DRAWER_INITIAL_WIDTH);
|
||||||
const panelRef = useRef(null);
|
const panelRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const clampToViewport = () => {
|
||||||
dbGetSetting(DRAWER_WIDTH_SETTING, "")
|
if (window.innerWidth < DRAWER_INITIAL_WIDTH) return;
|
||||||
.then((storedWidth) => {
|
setWidth((currentWidth) => Math.min(currentWidth, getDrawerMaxWidth()));
|
||||||
if (cancelled) return;
|
};
|
||||||
const value = Number(storedWidth);
|
window.addEventListener("resize", clampToViewport);
|
||||||
if (Number.isFinite(value) && value > 0) setWidth(Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94)));
|
return () => window.removeEventListener("resize", clampToViewport);
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function startResize(event) {
|
function startResize(event) {
|
||||||
|
|
@ -368,13 +368,11 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
const startWidth = panel.getBoundingClientRect().width;
|
const startWidth = panel.getBoundingClientRect().width;
|
||||||
const minWidth = 360;
|
const maxWidth = getDrawerMaxWidth();
|
||||||
const maxWidth = Math.floor(window.innerWidth * 0.94);
|
|
||||||
document.body.classList.add("is-resizing-drawer");
|
document.body.classList.add("is-resizing-drawer");
|
||||||
const onMove = (moveEvent) => {
|
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);
|
setWidth(nextWidth);
|
||||||
dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {});
|
|
||||||
};
|
};
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
document.body.classList.remove("is-resizing-drawer");
|
document.body.classList.remove("is-resizing-drawer");
|
||||||
|
|
@ -388,10 +386,9 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={width ? { "--drawer-panel-width": `${width}px` } : undefined}>
|
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={{ "--drawer-panel-width": `${width}px` }}>
|
||||||
<div className="drawer-backdrop" onClick={() => actions.setDrawerGameId("")} />
|
|
||||||
<span className="drawer-resize-handle nds-drawer-resize-handle" aria-hidden="true" onPointerDown={startResize} />
|
<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">
|
<div className="drawer-content">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
// Rôle : expose en développement un catalogue vivant et interactif des contrôles NDS.
|
// Rôle : expose en développement un catalogue vivant et interactif des contrôles NDS.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { CompactDropdown } from "../components/CompactDropdown.jsx";
|
||||||
import { Icon } from "../components/Icon.jsx";
|
import { Icon } from "../components/Icon.jsx";
|
||||||
import { MixTagsInput } from "../components/MixTagsInput.jsx";
|
import { MixTagsInput } from "../components/MixTagsInput.jsx";
|
||||||
|
import { Tooltip } from "../components/Tooltip.jsx";
|
||||||
|
import { ActiveFilterBadges } from "../features/games/ActiveFilterBadges.jsx";
|
||||||
|
|
||||||
const SIZE_VALUES = {
|
const SIZE_VALUES = {
|
||||||
field: ["xs", "sm", "md"],
|
field: ["xs", "sm", "md"],
|
||||||
|
|
@ -419,14 +422,9 @@ const PATTERN_COMPONENTS = [
|
||||||
name: "Filter Panel",
|
name: "Filter Panel",
|
||||||
className: "nds-filter-panel",
|
className: "nds-filter-panel",
|
||||||
sizes: SIZE_VALUES.none,
|
sizes: SIZE_VALUES.none,
|
||||||
variants: VARIANT_VALUES.none,
|
variants: ["vertical", "horizontal-compact", "horizontal-menu"],
|
||||||
attributes: {},
|
attributes: { "data-variant": ["vertical", "horizontal-compact", "horizontal-menu"] },
|
||||||
render: () => (
|
render: ({ attributes, variant }) => <FilterPanelPreview attributes={attributes} variant={variant} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "nds-reorder-indicator",
|
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() {
|
function DatePickerPreview() {
|
||||||
const [activeDay, setActiveDay] = useState(2);
|
const [activeDay, setActiveDay] = useState(2);
|
||||||
const days = Array.from({ length: 35 }, (_, index) => {
|
const days = Array.from({ length: 35 }, (_, index) => {
|
||||||
|
|
|
||||||
|
|
@ -4,31 +4,20 @@
|
||||||
.aniimo-home-card img { padding: 0 15%; object-fit: cover; object-position: 50% 38%; }
|
.aniimo-home-card img { padding: 0 15%; object-fit: cover; object-position: 50% 38%; }
|
||||||
.aniimo-layout .game-results { min-width: 0; }
|
.aniimo-layout .game-results { min-width: 0; }
|
||||||
.aniimo-filters { min-width: 0; }
|
.aniimo-filters { min-width: 0; }
|
||||||
.aniimo-filters > .filter-chip { margin-bottom: var(--space-3); }
|
.aniimo-filters .nds-filter-advanced-options { flex: 1 1 auto; }
|
||||||
.aniimo-filter-sections { gap: 16px; }
|
.aniimo-prismana-filter span { white-space: nowrap; }
|
||||||
.aniimo-filter-section-surface + .aniimo-filter-section-surface { padding-top: 12px; border-top: 1px solid var(--color-border); }
|
.aniimo-filters .aniimo-abilities-dropdown { flex-basis: 180px; min-width: 180px; }
|
||||||
.aniimo-filter-section {
|
.nds-filter-menu.aniimo-abilities-menu { width: min(680px, calc(100vw - 32px)); }
|
||||||
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-filter-options {
|
.aniimo-filter-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
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-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 {
|
.aniimo-ability-filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(64px, 1fr) 2ch;
|
grid-template-columns: minmax(0, 1fr) minmax(64px, 1fr) 2ch;
|
||||||
|
|
@ -52,6 +41,15 @@
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
shape-rendering: geometricPrecision;
|
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-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)); }
|
||||||
.aniimo-result { min-width: 0; max-width: 280px; width: 100%; }
|
.aniimo-result { min-width: 0; max-width: 280px; width: 100%; }
|
||||||
.aniimo-tabs-slot { height: 36px; min-width: 0; padding: 2px; }
|
.aniimo-tabs-slot { height: 36px; min-width: 0; padding: 2px; }
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,16 @@
|
||||||
.ui-tooltip-top-start::after {
|
.ui-tooltip-top-start::after {
|
||||||
left: 16px;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -468,6 +468,15 @@
|
||||||
padding: var(--space-4);
|
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-card strong,
|
||||||
.design-system-pattern-wide strong,
|
.design-system-pattern-wide strong,
|
||||||
.design-system-pattern-hero strong,
|
.design-system-pattern-hero strong,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar[data-variant="compact"] .sidebar-note {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.about-story-section,
|
.about-story-section,
|
||||||
.about-contribute-section {
|
.about-contribute-section {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
@ -379,9 +383,13 @@
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 699px) {
|
||||||
.drawer-panel {
|
.drawer-panel {
|
||||||
|
width: 100vw;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: min(720px, 94vw);
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-resize-handle {
|
.drawer-resize-handle {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell.sidebar-compact {
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
|
|
@ -292,6 +296,61 @@
|
||||||
display: inline-flex;
|
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 {
|
.content-shell {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -300,6 +359,26 @@
|
||||||
flex-direction: column;
|
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 {
|
.topbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,248 @@
|
||||||
@include controls.filter-panel-pattern;
|
@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,
|
||||||
.nds-reorder-indicator[data-axis="vertical"][data-placement="before"] {
|
.nds-reorder-indicator[data-axis="vertical"][data-placement="before"] {
|
||||||
@include controls.reorder-indicator-pattern(vertical, before);
|
@include controls.reorder-indicator-pattern(vertical, before);
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,20 @@
|
||||||
backdrop-filter: blur(10px);
|
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) {
|
@mixin reorder-indicator-pattern($axis: vertical, $placement: before) {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Rôle : regroupe le layout, les filtres et les barres de contrôle des pages jeux.
|
// Rôle : regroupe le layout, les filtres et les barres de contrôle des pages jeux.
|
||||||
.game-layout {
|
.game-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|
@ -9,7 +9,42 @@
|
||||||
|
|
||||||
.game-filters {
|
.game-filters {
|
||||||
position: sticky;
|
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,
|
.filter-panel-head,
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,13 @@
|
||||||
display: none;
|
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 {
|
.drawer-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: var(--drawer-top-offset, 68px) 0 0 auto;
|
inset: var(--drawer-top-offset, 68px) 0 0 auto;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
width: min(720px, 94vw);
|
width: var(--drawer-panel-width, 700px);
|
||||||
min-width: 360px;
|
min-width: 700px;
|
||||||
max-width: 94vw;
|
max-width: max(700px, 94vw);
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +28,7 @@
|
||||||
.drawer-resize-handle {
|
.drawer-resize-handle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--drawer-top-offset, 68px) + ((100vh - var(--drawer-top-offset, 68px)) / 2));
|
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;
|
z-index: 62;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue