Refocus responsive layouts on compact desktop use
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s

This commit is contained in:
Shinuwa 2026-10-05 15:47:46 +02:00
parent 96d058293d
commit 0ac1a0974e
27 changed files with 610 additions and 190 deletions

View file

@ -8,9 +8,7 @@
"toolboxes": "Toolboxes",
"games": "Jeux",
"library": "Librairie",
"about": "C'est quoi Sokko G ?",
"mobileLibrary": "Lib.",
"mobileGames": "Infos"
"about": "C'est quoi Sokko G ?"
},
"sidebar": {
"badge": "Local only",
@ -20,7 +18,10 @@
"dashboard": "Dashboard",
"toolbox": "Toolboxes",
"library": "Librairie",
"games": "Informations jeu"
"games": "Informations jeu",
"gameViewLabel": "Vue de la page jeu",
"guideView": "Guide",
"toolboxView": "Toolbox"
},
"gamesPage": {
"eyebrow": "Pages informatives",

View file

@ -1,18 +1,48 @@
// Rôle : structure le layout global avec sidebar, topbar, navigation et contenu.
import { useEffect, useRef } from "react";
import { Icon } from "./Icon.jsx";
import { ImportButton } from "./ImportButton.jsx";
import { Tooltip } from "./Tooltip.jsx";
export function Shell({ route, content, games, drawerGameId = "", actions, children }) {
export function Shell({ route, content, games, drawerGameId = "", drawerVisible = false, compactGameLayout = false, reducedSidebar = false, actions, children }) {
const gameId = route.split("/")[1] === "games" ? route.split("/")[2] || "" : "";
const game = games.find((item) => item.id === gameId);
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 toolboxOpen = Boolean(game && drawerGameId === game.id);
const toolboxOpen = Boolean(game && drawerGameId === game.id && drawerVisible);
const toolboxLabel = toolboxOpen ? "Masquer la toolbox" : "Afficher la toolbox";
const compactSidebar = Boolean(drawerGameId);
const compactSidebar = Boolean(reducedSidebar || (!compactGameLayout && toolboxOpen));
const compactToolboxVisible = Boolean(compactGameLayout && toolboxOpen);
const guideTabRef = useRef(null);
const toolboxTabRef = useRef(null);
const wideToolboxButtonRef = useRef(null);
const focusedViewControlRef = useRef("");
const previousCompactLayoutRef = useRef(compactGameLayout);
useEffect(() => {
const trackFocus = (event) => {
if (event.target.closest?.(".compact-game-view-switch")) focusedViewControlRef.current = "compact";
else if (event.target.closest?.(".toolbox-button")) focusedViewControlRef.current = "wide";
else focusedViewControlRef.current = "";
};
document.addEventListener("focusin", trackFocus);
return () => document.removeEventListener("focusin", trackFocus);
}, []);
useEffect(() => {
const activeElement = document.activeElement;
const layoutChanged = previousCompactLayoutRef.current !== compactGameLayout;
previousCompactLayoutRef.current = compactGameLayout;
if (layoutChanged && compactGameLayout && focusedViewControlRef.current === "wide") {
(compactToolboxVisible ? toolboxTabRef.current : guideTabRef.current)?.focus();
} else if (compactGameLayout && !compactToolboxVisible && activeElement?.closest?.("#toolbox-drawer")) {
guideTabRef.current?.focus();
} else if (layoutChanged && !compactGameLayout && focusedViewControlRef.current === "compact") {
wideToolboxButtonRef.current?.focus();
}
}, [compactGameLayout, compactToolboxVisible]);
return (
<div className={`app-shell ${compactSidebar ? "sidebar-compact" : ""} ${drawerGameId ? "toolbox-drawer-open" : ""}`}>
<div className={`app-shell ${compactSidebar ? "sidebar-compact" : ""} ${!compactGameLayout && toolboxOpen ? "toolbox-drawer-open" : ""} ${compactToolboxVisible ? "compact-toolbox-visible" : ""}`}>
<aside className="sidebar" data-variant={compactSidebar ? "compact" : undefined}>
<a className="brand" href="#/" aria-label={content.brand.homeAriaLabel}>
<span className="brand-mark" aria-hidden="true" />
@ -47,8 +77,12 @@ export function Shell({ route, content, games, drawerGameId = "", actions, child
<div><strong>{topbarLabel}</strong></div>
{game && (
<div className="topbar-actions">
<button
{compactGameLayout ? <div className="compact-game-view-switch nds-radio-button-group" role="tablist" aria-label={content.topbar.gameViewLabel}>
<button ref={guideTabRef} type="button" role="tab" aria-selected={!compactToolboxVisible} className={!compactToolboxVisible ? "active" : ""} onClick={() => actions.setDrawerGameId("")}>{content.topbar.guideView}</button>
<button ref={toolboxTabRef} type="button" role="tab" aria-selected={compactToolboxVisible} className={compactToolboxVisible ? "active" : ""} onClick={() => actions.setDrawerGameId(game.id)}>{content.topbar.toolboxView}</button>
</div> : <button
className={`drawer-button toolbox-button nds-button ${toolboxOpen ? "active" : ""}`}
ref={wideToolboxButtonRef}
data-size="sm"
data-variant="primary"
onClick={() => actions.setDrawerGameId(toolboxOpen ? "" : game.id)}
@ -58,19 +92,12 @@ export function Shell({ route, content, games, drawerGameId = "", actions, child
>
<img src="/static/icons/toolbox.svg" alt="" aria-hidden="true" />
<span>{toolboxLabel}</span>
</button>
</button>}
</div>
)}
</header>
<main>{children}</main>
</div>
<nav className="mobile-nav" aria-label="Navigation mobile">
<a className={route === "/" ? "active" : ""} href="#/">{content.navigation.home}</a>
<a className={route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""} href="#/toolboxes">{content.navigation.toolboxes}</a>
<button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({})}>+</button>
<a className={route === "/library" ? "active" : ""} href="#/library">{content.navigation.mobileLibrary}</a>
<a className={route.startsWith("/games") ? "active" : ""} href="#/games">{content.navigation.mobileGames}</a>
</nav>
</div>
);
}

View file

@ -442,7 +442,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
);
}
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, visible = true, compact = false }) {
const checklist = useGuideChecklist();
const guideChecklist = checklist.available && checklist.guideId.split("/")[0] === gameId ? checklist : undefined;
const selectedId = links[gameId] || "";
@ -483,7 +483,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
}
return (
<aside className="drawer" id="toolbox-drawer" aria-hidden="false" style={{ "--drawer-panel-width": `${width}px` }}>
<aside className={`drawer ${compact ? "is-compact" : ""}`} id="toolbox-drawer" aria-hidden={!visible} inert={!visible || undefined} 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}>
<div className="drawer-content">

View file

@ -0,0 +1,16 @@
// Rôle : expose l'état réactif d'une media query pour synchroniser comportement et mise en page.
import { useEffect, useState } from "react";
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const update = () => setMatches(mediaQuery.matches);
update();
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}, [query]);
return matches;
}

View file

@ -4,7 +4,7 @@ import { getCalendarAlertOffsets, getCurrentCalendarOccurrence, getNextCalendarA
import { getCountdownTargetMs } from "../features/toolboxes/modules/timerUtils.js";
import { moduleStorageKey } from "../features/toolboxes/storage/toolboxStorage.js";
function getVisibleToolboxIds(route, drawerGameId, links) {
export function getVisibleToolboxIds(route, drawerGameId, links) {
const visibleIds = new Set();
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
if (toolboxMatch?.[1]) visibleIds.add(toolboxMatch[1]);

View file

@ -19,6 +19,7 @@ import { useHashRoute } from "./router/hashRouter.js";
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
import { useMediaQuery } from "./hooks/useMediaQuery.js";
const assetRecoveryUrl = new URL(window.location.href);
if (assetRecoveryUrl.searchParams.has("_asset-recovery")) {
@ -43,6 +44,8 @@ function getNotificationDuration(message, options = {}) {
function App() {
const route = useHashRoute();
const compactGameLayout = useMediaQuery("(max-width: 1439px)");
const reducedSidebar = useMediaQuery("(max-width: 1024px)");
const { siteContent, siteContentError, games, gamesError, mhwilds, diablo4, aniimo } = useAppData(route);
const [filters, setFilters] = useGameFilters();
const [confirmModal, setConfirmModal] = useState(null);
@ -50,7 +53,8 @@ function App() {
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
const [linkModalGameId, setLinkModalGameId] = useState("");
const [drawerGameId, setDrawerGameId] = useState("");
const [drawerRoute, setDrawerRoute] = useState(route);
const [drawerVisible, setDrawerVisible] = useState(false);
const [drawerContext, setDrawerContext] = useState({ route, compactGameLayout });
const [image, setImage] = useState(null);
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
const [notifications, setNotifications] = useState([]);
@ -61,11 +65,33 @@ function App() {
const autoBackup = useAutoBackup(store);
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
const guideChecklistStorage = useGuideChecklistStorage(store);
const guideScrollRef = useRef(0);
if (drawerRoute !== route) {
setDrawerRoute(route);
const setGameToolbox = useCallback((gameId) => {
if (gameId) {
if (compactGameLayout && !drawerVisible) guideScrollRef.current = window.scrollY;
setDrawerGameId(gameId);
setDrawerVisible(true);
if (compactGameLayout) window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: "auto" }));
return;
}
setDrawerVisible(false);
if (compactGameLayout) {
window.requestAnimationFrame(() => window.scrollTo({ top: guideScrollRef.current, behavior: "auto" }));
} else {
setDrawerGameId("");
}
}, [compactGameLayout, drawerVisible]);
if (drawerContext.route !== route || drawerContext.compactGameLayout !== compactGameLayout) {
setDrawerContext({ route, compactGameLayout });
const activeGameId = route.match(/^\/games\/([^/]+)/)?.[1] || "";
if (activeGameId !== drawerGameId) setDrawerGameId("");
if (activeGameId !== drawerGameId) {
setDrawerGameId("");
setDrawerVisible(false);
} else if (!compactGameLayout && !drawerVisible && drawerGameId) {
setDrawerGameId("");
}
}
const notify = useCallback((message, options = {}) => {
@ -126,7 +152,7 @@ function App() {
setCreateModal,
setConfirmModal,
setLinkModalGameId,
setDrawerGameId,
setDrawerGameId: setGameToolbox,
setImage
});
@ -135,7 +161,7 @@ function App() {
moduleData: store.moduleData,
links: store.links,
route,
drawerGameId,
drawerGameId: drawerVisible ? drawerGameId : "",
normalizeTimerData,
normalizeCalendarData,
textContent: siteContent?.toolboxes?.modules,
@ -173,9 +199,19 @@ function App() {
return (
<GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistStorage, actions }}>
<Shell route={route} content={siteContent} games={games} toolboxes={store.toolboxes} links={store.links} drawerGameId={drawerGameId} actions={actions}>
<AppErrorBoundary route={route}>
<RouteContent
<Shell
route={route}
content={siteContent}
games={games}
drawerGameId={drawerGameId}
drawerVisible={drawerVisible}
compactGameLayout={compactGameLayout}
reducedSidebar={reducedSidebar}
actions={actions}
>
<div className="route-content-view" aria-hidden={compactGameLayout && drawerVisible || undefined} inert={compactGameLayout && drawerVisible || undefined}>
<AppErrorBoundary route={route}>
<RouteContent
route={route}
siteContent={siteContent}
games={games}
@ -202,8 +238,9 @@ function App() {
addImageFiles={actions.addImageFiles}
pendingTutorialToolboxId={pendingTutorialToolboxId}
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
/>
</AppErrorBoundary>
/>
</AppErrorBoundary>
</div>
{drawerGameId && (
<Suspense fallback={null}>
<ToolboxDrawer
@ -219,6 +256,8 @@ function App() {
updateToolbox={actions.updateToolbox}
updateModuleData={store.updateModuleData}
addImageFiles={actions.addImageFiles}
visible={drawerVisible}
compact={compactGameLayout}
/>
</Suspense>
)}
@ -238,7 +277,7 @@ function App() {
setCreateModal={setCreateModal}
linkModalGameId={linkModalGameId}
setLinkModalGameId={setLinkModalGameId}
setDrawerGameId={setDrawerGameId}
setDrawerGameId={setGameToolbox}
storageError={storageError}
setStorageError={setStorageError}
image={image}

View file

@ -251,7 +251,3 @@
height: 14px;
object-fit: contain;
}
.mobile-nav {
display: none;
}

View file

@ -49,6 +49,69 @@
.about-reminders-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.home-onboarding-layout,
.library-doc-layout {
grid-template-columns: 1fr;
}
.library-example-layout,
.hero,
.origin-section {
grid-template-columns: 1fr;
}
.hero-stats {
align-self: start;
justify-self: start;
width: min(520px, 100%);
}
.home-visual {
display: none;
}
.origin-copy {
padding-right: 0;
border-right: 0;
}
.library-doc-nav {
position: static;
max-height: none;
}
.page-heading,
.toolbox-head {
align-items: stretch;
flex-direction: column;
}
.game-heading-actions,
.guide-checklist-page-controls {
width: 100%;
align-items: stretch;
}
.guide-checklist-page-actions,
.toolbar,
.page-heading .actions,
.toolbox-head .actions {
flex-wrap: wrap;
}
}
@media (max-width: 1439px) {
main {
width: min(100% - 40px, 1280px);
padding-top: var(--space-6);
}
.toolbar,
.page-heading .actions,
.toolbox-head .actions {
flex-wrap: wrap;
}
}
@media (max-height: 760px) and (min-width: 1025px) {
@ -58,22 +121,14 @@
}
@media (max-width: 760px) {
.drawer {
--drawer-top-offset: 110px;
.topbar {
padding-inline: var(--space-4);
}
.app-shell {
display: block;
padding-bottom: 76px;
}
.sidebar {
.topbar:has(.compact-game-view-switch) > div:first-child {
display: none;
}
.topbar,
.page-heading,
.toolbox-head,
.module .module-header {
align-items: stretch;
flex-direction: column;
@ -109,24 +164,6 @@
order: 2;
}
.topbar {
padding: var(--space-4);
}
.topbar-actions {
display: flex;
}
.topbar-actions > * {
width: 100%;
}
.game-heading-actions,
.guide-checklist-page-controls {
width: 100%;
align-items: stretch;
}
.guide-checklist-page-controls {
justify-content: space-between;
}
@ -144,11 +181,8 @@
width: 100%;
}
.hero,
.game-hero,
.page-hero,
.origin-section,
.home-onboarding-layout,
.home-onboarding-cards,
.about-story-section,
.about-story-list,
@ -231,10 +265,6 @@
-webkit-mask-image: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.14) 30%, #000 70%);
}
.home-visual {
width: min(320px, 100%);
}
.origin-section {
padding: var(--space-5);
}
@ -243,13 +273,6 @@
padding-left: 0;
}
.origin-copy {
padding-right: 0;
padding-bottom: var(--space-5);
border-right: 0;
border-bottom: 2px solid rgba(246, 196, 83, 0.66);
}
.dialogue-line {
max-width: 92%;
}
@ -322,7 +345,6 @@
max-height: none;
}
.library-example-layout,
.library-tool-feature-list dl > div {
grid-template-columns: 1fr;
}
@ -413,59 +435,12 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mobile-nav {
position: fixed;
right: 12px;
bottom: 12px;
left: 12px;
z-index: 60;
display: grid;
grid-template-columns: 1fr 1fr 52px 1fr;
gap: 8px;
align-items: center;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: rgba(11, 14, 27, 0.9);
backdrop-filter: blur(10px);
box-shadow: var(--shadow-lg);
}
.mobile-nav a,
.mobile-nav button {
min-height: 44px;
padding: 0 8px;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.mobile-nav a {
display: grid;
place-items: center;
color: var(--color-text-secondary);
}
.mobile-nav a.active {
background: var(--color-primary-soft);
color: var(--color-text-primary);
}
}
@media (max-width: 699px) {
.drawer-panel {
width: 100vw;
min-width: 0;
max-width: none;
}
@media (max-width: 767px) {
.drawer-link-existing form {
grid-template-columns: 1fr;
}
.drawer-resize-handle {
display: none;
}
}
@media (max-width: 480px) {

View file

@ -359,7 +359,7 @@
flex-direction: column;
}
@media (min-width: 700px) {
@media (min-width: 1440px) {
.app-shell.toolbox-drawer-open .content-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 700px;
@ -404,9 +404,19 @@
.topbar-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.compact-game-view-switch {
display: inline-flex;
min-width: 184px;
}
.compact-game-view-switch > button {
flex: 1 1 0;
}
.toolbox-button {
white-space: nowrap;
}
@ -426,3 +436,11 @@ main {
margin: 0 auto;
padding: var(--space-8) 0 var(--space-12);
}
.route-content-view {
display: contents;
}
.app-shell.compact-toolbox-visible .route-content-view {
display: none;
}

View file

@ -57,4 +57,3 @@
max-width: 920px;
margin: 0;
}

View file

@ -99,3 +99,22 @@ body.is-resizing-drawer * {
.drawer-panel .modules {
grid-template-columns: 1fr;
}
@media (max-width: 1439px) {
.drawer.is-compact .drawer-panel {
inset: 68px 0 0 248px;
width: auto;
min-width: 0;
max-width: none;
}
.drawer.is-compact .drawer-resize-handle {
display: none;
}
}
@media (max-width: 1024px) {
.drawer.is-compact .drawer-panel {
left: 72px;
}
}

View file

@ -90,11 +90,11 @@
.global-memos-ticker { left: 84px; }
}
@media (max-width: 760px) {
@media (max-width: 767px) {
.global-memos-ticker {
right: 12px;
bottom: 84px;
left: 12px;
bottom: 12px;
left: 84px;
}
.memos-settings label {

View file

@ -6,6 +6,11 @@
align-items: start;
}
.toolbox-page {
container-name: toolbox-page;
container-type: inline-size;
}
.modules[data-layout-cols="1"] {
grid-template-columns: 1fr;
}
@ -21,6 +26,20 @@
min-width: 0;
}
@container toolbox-page (max-width: 899px) {
.modules-toolbar {
display: none;
}
.modules[data-layout-cols="2"] {
grid-template-columns: 1fr;
}
.modules[data-layout-cols="2"] .module-column {
display: contents;
}
}
.module-column.is-empty-warning {
min-height: 132px;
border: 1px dashed rgba(246, 196, 83, 0.26);