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

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

View file

@ -2778,10 +2778,12 @@
"name": "Nom",
"searchPlaceholder": "Nom français ou anglais…",
"prismanaOnly": "Prismana uniquement",
"prismana": "Prismana",
"elements": "Éléments",
"roles": "Rôles",
"role": "Rôle",
"abilities": "Compétences de logis",
"abilityActions": "Actions",
"minimum": "Niveau minimum",
"exactLevel": "Niveau exact",
"levelMode": "Niveau",

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
export function CompactDropdown({
className = "",
menuClassName = "",
menuProps = {},
label,
openOnHover = false,
closeOnMouseLeave = false,
@ -13,6 +14,7 @@ export function CompactDropdown({
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
const triggerRef = useRef(null);
const internalPointerDownRef = useRef(false);
useEffect(() => {
@ -23,7 +25,10 @@ export function CompactDropdown({
}
function handleKeyDown(event) {
if (event.key === "Escape") setOpen(false);
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("pointerdown", handlePointerDown);
@ -60,11 +65,10 @@ export function CompactDropdown({
if (!event.currentTarget.contains(event.relatedTarget)) close();
}}
onMouseDown={preventMouseDown ? (event) => event.preventDefault() : undefined}
aria-label={label}
>
{renderTrigger({ open, toggle, close })}
{renderTrigger({ open, toggle, close, triggerRef })}
{open && (
<div className={`${menuClassName} is-open`.trim()} aria-label={label}>
<div {...menuProps} className={`${menuClassName} ${menuProps.className || ""} is-open`.trim()} aria-label={menuProps["aria-label"] || (menuProps["aria-labelledby"] ? undefined : label)}>
{children({ close })}
</div>
)}

View file

@ -1,9 +1,9 @@
// Rôle : encapsule l'input fichier JSON derrière un bouton d'import réutilisable.
import { Icon } from "./Icon.jsx";
import { Tooltip } from "./Tooltip.jsx";
export function ImportButton({ className, label, title, ariaLabel, icon, onFile, ...props }) {
return (
<label className={className} aria-label={ariaLabel} title={title} {...props}>
export function ImportButton({ className, label, title, ariaLabel, icon, onFile, tooltip = "", tooltipPosition = "top", ...props }) {
const children = <>
{icon && <Icon name={icon} />}
{label}
<input type="file" accept="application/json" hidden onChange={async (event) => {
@ -11,6 +11,10 @@ export function ImportButton({ className, label, title, ariaLabel, icon, onFile,
if (file) await onFile(file);
event.target.value = "";
}} />
</label>
);
</>;
if (tooltip) {
return <Tooltip as="label" className={className} content={tooltip} position={tooltipPosition} aria-label={ariaLabel} {...props}>{children}</Tooltip>;
}
return <label className={className} aria-label={ariaLabel} title={title} {...props}>{children}</label>;
}

View file

@ -1,6 +1,7 @@
// Rôle : structure le layout global avec sidebar, topbar, navigation et contenu.
import { Icon } from "./Icon.jsx";
import { ImportButton } from "./ImportButton.jsx";
import { Tooltip } from "./Tooltip.jsx";
export function Shell({ route, content, games, toolboxes, links, drawerGameId = "", actions, children }) {
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
@ -8,35 +9,36 @@ export function Shell({ route, content, games, toolboxes, links, drawerGameId =
const linkedToolbox = game ? toolboxes.find((item) => item.id === links[game.id]) : null;
const topbarLabel = route === "/design-system" && import.meta.env.DEV ? "Design system" : route.startsWith("/toolbox") ? content.topbar.toolbox : route.startsWith("/games") ? content.topbar.games : route === "/library" ? content.topbar.library : content.topbar.dashboard;
const toolboxLabel = linkedToolbox ? `Ouvrir la toolbox ${linkedToolbox.name}` : "Associer une toolbox";
const compactSidebar = Boolean(drawerGameId);
return (
<div className="app-shell">
<aside className="sidebar">
<div className={`app-shell ${compactSidebar ? "sidebar-compact" : ""} ${drawerGameId ? "toolbox-drawer-open" : ""}`}>
<aside className="sidebar" data-variant={compactSidebar ? "compact" : undefined}>
<a className="brand" href="#/" aria-label={content.brand.homeAriaLabel}>
<span className="brand-mark" aria-hidden="true" />
<span className="brand-name"><span className="brand-name-main">Sokko</span> <span className="brand-name-accent">G</span></span>
</a>
<nav className="nav" aria-label="Navigation principale">
<a className={`nav-item ${route === "/" ? "active" : ""}`} href="#/"><span className="nav-icon nav-icon-home" aria-hidden="true" /><strong>{content.navigation.home}</strong></a>
<a className={`nav-item ${route.startsWith("/games") ? "active" : ""}`} href="#/games"><span className="nav-icon nav-icon-controller" aria-hidden="true" /><strong>{content.navigation.games}</strong></a>
<a className={`nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}`} href="#/toolboxes"><span className="nav-icon nav-icon-toolbox" aria-hidden="true" /><strong>{content.navigation.toolboxes}</strong></a>
<Tooltip as="a" className={`nav-item ${route === "/" ? "active" : ""}`} content={compactSidebar ? content.navigation.home : ""} position="right" href="#/" aria-label={content.navigation.home}><span className="nav-icon nav-icon-home" aria-hidden="true" /><strong>{content.navigation.home}</strong></Tooltip>
<Tooltip as="a" className={`nav-item ${route.startsWith("/games") ? "active" : ""}`} content={compactSidebar ? content.navigation.games : ""} position="right" href="#/games" aria-label={content.navigation.games}><span className="nav-icon nav-icon-controller" aria-hidden="true" /><strong>{content.navigation.games}</strong></Tooltip>
<Tooltip as="a" className={`nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}`} content={compactSidebar ? content.navigation.toolboxes : ""} position="right" href="#/toolboxes" aria-label={content.navigation.toolboxes}><span className="nav-icon nav-icon-toolbox" aria-hidden="true" /><strong>{content.navigation.toolboxes}</strong></Tooltip>
</nav>
<div className="sidebar-mascot" aria-hidden="true">
<img src="/static/img/dragon-sidebar.png" alt="" />
</div>
<div className="sidebar-secondary-links">
<a className={`sidebar-about-link ${route === "/about" ? "active" : ""}`} href="#/about"><span className="sidebar-about-icon" aria-hidden="true">?</span><strong>{content.navigation.about}</strong></a>
<a className={`sidebar-about-link ${route === "/library" ? "active" : ""}`} href="#/library"><span className="sidebar-about-icon" aria-hidden="true"><Icon name="book" /></span><strong>{content.library.eyebrow}</strong></a>
<Tooltip as="a" className={`sidebar-about-link ${route === "/about" ? "active" : ""}`} content={compactSidebar ? content.navigation.about : ""} position="right" href="#/about" aria-label={content.navigation.about}><span className="sidebar-about-icon" aria-hidden="true">?</span><strong>{content.navigation.about}</strong></Tooltip>
<Tooltip as="a" className={`sidebar-about-link ${route === "/library" ? "active" : ""}`} content={compactSidebar ? content.library.eyebrow : ""} position="right" href="#/library" aria-label={content.library.eyebrow}><span className="sidebar-about-icon" aria-hidden="true"><Icon name="book" /></span><strong>{content.library.eyebrow}</strong></Tooltip>
{import.meta.env.DEV && (
<a className={`sidebar-about-link ${route === "/design-system" ? "active" : ""}`} href="#/design-system"><span className="sidebar-about-icon" aria-hidden="true"><Icon name="settings" /></span><strong>Design system</strong></a>
<Tooltip as="a" className={`sidebar-about-link ${route === "/design-system" ? "active" : ""}`} content={compactSidebar ? "Design system" : ""} position="right" href="#/design-system" aria-label="Design system"><span className="sidebar-about-icon" aria-hidden="true"><Icon name="settings" /></span><strong>Design system</strong></Tooltip>
)}
</div>
<div className="sidebar-note">
<span className="badge">{content.sidebar.badge}</span>
<p>{content.sidebar.note}</p>
<div className="sidebar-storage-actions" aria-label="Actions globales de stockage">
<button className="sidebar-action-button nds-icon-button" data-size="sm" data-variant="primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" data-toolbox-tutorial-target="exportAll" title="Exporter tout"><Icon name="export" /></button>
<ImportButton className="sidebar-action-button nds-icon-button import-icon-button" data-size="sm" title="Importer tout" ariaLabel="Importer toutes les toolboxes" onFile={actions.importAllToolboxes} icon="import" />
<Tooltip as="button" className="sidebar-action-button nds-icon-button" content={compactSidebar ? "Exporter toutes les toolboxes" : ""} position="right" data-size="sm" data-variant="primary" onClick={actions.exportAllToolboxes} aria-label="Exporter toutes les toolboxes" data-toolbox-tutorial-target="exportAll" title={compactSidebar ? undefined : "Exporter tout"}><Icon name="export" /></Tooltip>
<ImportButton className="sidebar-action-button nds-icon-button import-icon-button" data-size="sm" title={compactSidebar ? undefined : "Importer tout"} ariaLabel="Importer toutes les toolboxes" tooltip={compactSidebar ? "Importer toutes les toolboxes" : ""} tooltipPosition="right" onFile={actions.importAllToolboxes} icon="import" />
</div>
</div>
</aside>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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