Move guide checklist controls into a collapsible header toggle
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-24 08:26:48 +02:00
parent ef0c592c11
commit 14f1b79cbc
12 changed files with 144 additions and 89 deletions

View file

@ -822,9 +822,16 @@ Date picker et menus compacts :
### Liaison des guides aux checklists
Les guides à cartes partagent un panneau `guide-checklist-controls` dans la toolbox
latérale du jeu, au-dessus de ses outils : panneau NDS, select avec label, bouton de dissociation et switch
« Masquer les cochés ». Le label et le sélecteur occupent toute la largeur ; les actions sont centrées et reviennent à la ligne sur mobile. Le panneau ne concerne que le guide à cartes actuellement ouvert.
Dans la toolbox latérale, chaque checklist compatible avec le guide ouvert affiche
un bouton icône de lien à côté du « + » dans son en-tête. Ce toggle NDS affiche
ou masque la section `guide-checklist-controls`, masquée par défaut, sans désactiver
le suivi. Il expose `aria-expanded`, `aria-pressed` et `aria-controls` ; son icône
reste dorée lorsque la liste est associée. Le bouton NDS
« Associer à la page » / « Dissocier » expose `aria-pressed` ; activer une liste
dissocie la précédente. Le switch « Masquer les cochés » est désactivé sur les listes
non associées. Les commandes sont centrées et reviennent à la ligne sur mobile ;
le nombre de correspondances figure dessous. Une liste active devenue incompatible
garde ses commandes avec un message explicite. Aucun sélecteur global n'est affiché.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de
catégorie Diablo), avec une coche accessible « Coché ». Les textes restent
lisibles et les indicateurs absolus ne modifient pas la taille des cartes.

View file

@ -52,7 +52,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier le bouton toolbox et le panneau latéral.
- Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne.
- Pour les guides à cartes, vérifier le sélecteur de checklist dans le panneau latéral de la toolbox liée, les listes partielles et la mise à jour immédiate depuis le panneau latéral. Les guides ne doivent jamais modifier la progression.
- Pour les guides à cartes, vérifier le toggle de lien à côté du « + » dans l'en-tête de chaque checklist compatible : il affiche les commandes, masquées par défaut. Vérifier l'association exclusive, le masquage désactivé sur les autres listes et la mise à jour immédiate depuis le panneau latéral. Refermer les commandes conserve l'association. Les guides ne doivent jamais modifier la progression.
- Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message.
- Pour Aniimo, vérifier la copie des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs et copie doivent correspondre aux résultats affichés.

View file

@ -211,9 +211,14 @@ Stockage compact :
### Association temporaire aux guides
Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une
checklist de la toolbox actuellement associée au jeu. Le sélecteur, la dissociation
et le masquage se trouvent dans le panneau latéral de cette toolbox, au-dessus des outils. Cette liaison ne modifie
ni les données de checklist ni les liens persistants jeu/toolbox.
checklist de la toolbox actuellement associée au jeu. Chaque checklist compatible
affiche un toggle de lien à côté du « + » de son en-tête dans le panneau latéral.
Il ouvre une section masquée par défaut en haut du contenu :
bouton toggle d'association/dissociation et switch de masquage, actif seulement pour
la liste associée. Associer une autre liste remplace la précédente et conserve le
réglage de masquage ; dissocier réaffiche tous les éléments. Replier les commandes
ne modifie pas l'association ; cet état d'affichage reste local au composant.
Cette liaison ne modifie ni les données de checklist ni les liens persistants jeu/toolbox.
`sessionStorage["sokkog:guide-checklists"]` contient un objet indexé par
`aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`.

View file

@ -7,8 +7,11 @@ import { normalizeChecklistData } from "../website/src/features/toolboxes/storag
const route = "/#/games/aniimo/aniimos";
const sessionKey = "sokkog:guide-checklists";
const selector = (page) => page.getByRole("combobox", { name: "Checklist associée", exact: true });
const hiddenSwitch = (page) => page.getByRole("button", { name: "Masquer les cochés", exact: true });
const controls = (page, id) => page.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: `Retirer ${id}`, exact: true }) }).locator(".guide-checklist-controls");
const controlsToggle = (page, id) => page.getByRole("button", { name: `Lien avec le guide — ${id}`, exact: true });
const linkButton = (page, id) => controls(page, id).locator(".guide-checklist-link");
const activeButtons = (page) => page.locator('.guide-checklist-link[aria-pressed="true"]');
const hiddenSwitch = (page) => page.locator('.guide-checklist-controls[data-linked="true"]').getByRole("button", { name: "Masquer les cochés", exact: true });
const json = (path) => JSON.parse(readFileSync(`website/public/data/${path}`, "utf8"));
const row = (label, qtyCurrent = 0) => ({ id: label, label, qtyCurrent });
@ -64,19 +67,30 @@ async function openDrawer(page) {
async function closeDrawer(page) {
await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click();
}
async function openControls(page, id) {
const toggle = id ? controlsToggle(page, id) : page.locator('.module-guide-button[data-linked="true"]');
await expect(toggle).toBeVisible();
if (await toggle.getAttribute("aria-expanded") === "false") await toggle.click();
}
async function choose(page, id) {
await openDrawer(page);
await selector(page).selectOption(id);
await openControls(page, id);
await linkButton(page, id).click();
await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
await expect(activeButtons(page)).toHaveCount(1);
await closeDrawer(page);
}
async function toggleHidden(page) {
await openDrawer(page);
await openControls(page);
await hiddenSwitch(page).click();
await closeDrawer(page);
}
async function assertLink(page, id, hidden) {
await openDrawer(page);
await expect(selector(page)).toHaveValue(id);
await expect(activeButtons(page)).toHaveCount(id ? 1 : 0);
if (id) await openControls(page, id);
if (id) await expect(linkButton(page, id)).toHaveAttribute("aria-pressed", "true");
if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden));
else await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page);
@ -118,14 +132,35 @@ for (const width of [1280, 390]) {
await page.getByRole("searchbox").fill("emberpup");
const card = page.locator(".aniimo-result");
await openDrawer(page);
await expect(selector(page)).toHaveValue("");
await expect(selector(page).locator("option")).toHaveCount(4);
await expect(activeButtons(page)).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(3);
await expect(page.locator(".guide-checklist-controls select")).toHaveCount(0);
await expect(controls(page, "unrelated")).toHaveCount(0);
await expect(controlsToggle(page, "unrelated")).toHaveCount(0);
await expect(controls(page, "capture")).toBeHidden();
await openControls(page, "capture");
await expect(controls(page, "capture").getByRole("button", { name: "Masquer les cochés" })).toBeDisabled();
const before = await readModule(page, "forms");
await selector(page).selectOption("forms");
const panel = page.locator(".guide-checklist-controls");
const heading = controlsToggle(page, "forms");
await expect(heading).toHaveAttribute("aria-expanded", "false");
expect(await heading.evaluate((button) => button.previousElementSibling.classList.contains("module-edit-button"))).toBe(true);
await heading.press("Enter");
await expect(heading).toHaveAttribute("aria-pressed", "true");
await expect(controls(page, "forms")).toHaveAttribute("id", await heading.getAttribute("aria-controls"));
await linkButton(page, "forms").press("Enter");
await expect(linkButton(page, "forms")).toHaveAttribute("aria-pressed", "true");
const panel = controls(page, "forms");
await heading.press("Space");
await expect(heading).toHaveAttribute("aria-expanded", "false");
await expect(heading).toHaveAttribute("aria-pressed", "false");
await expect(heading).toHaveAttribute("data-linked", "true");
await expect(linkButton(page, "forms")).toBeHidden();
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await heading.press("Enter");
await expect(linkButton(page, "forms")).toBeVisible();
const bounds = await panel.boundingBox();
const selectBounds = await selector(page).boundingBox();
expect(selectBounds.x + selectBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
const actionsBounds = await panel.locator(".guide-checklist-actions").boundingBox();
expect(actionsBounds.x + actionsBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
@ -146,14 +181,17 @@ for (const width of [1280, 390]) {
await page.getByRole("searchbox").fill("emberpup");
await expect(card).toHaveCount(0);
await openDrawer(page);
await openControls(page, "capture");
await hiddenSwitch(page).press("Space");
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
await openDrawer(page);
await expect(selector(page)).toHaveValue("capture");
await page.getByRole("button", { name: "Dissocier", exact: true }).click();
await expect(selector(page)).toHaveValue("");
await openControls(page, "capture");
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true");
await linkButton(page, "capture").click();
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "false");
await expect(activeButtons(page)).toHaveCount(0);
await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page);
await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0);
@ -192,17 +230,18 @@ test("renames retain a zero-match selection; changing toolbox clears the associa
await choose(page, "capture");
const drawer = await openDrawer(page);
const checklist = drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) });
await openControls(page, "capture");
await checklist.getByRole("button", { name: "Modifier Loufeuteau", exact: true }).click();
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).fill("Unrelated now");
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).press("Enter");
await expect(selector(page)).toHaveValue("capture");
await expect(linkButton(page, "capture")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText("Cette checklist ne contient plus de correspondance avec le guide.")).toBeVisible();
await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Lier une toolbox" });
await dialog.getByRole("combobox").selectOption("other");
await dialog.getByRole("button", { name: "Valider", exact: true }).click();
await expect(selector(page)).toHaveValue("");
await expect(selector(page).locator("option")).toHaveCount(2);
await expect(activeButtons(page)).toHaveCount(0);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(1);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull();
});
@ -214,7 +253,7 @@ test("deleting a selected checklist clears its link immediately", async ({ page
const drawer = await openDrawer(page);
await drawer.getByRole("button", { name: "Retirer capture", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click();
await expect(selector(page)).toHaveValue("");
await expect(activeButtons(page)).toHaveCount(0);
await expect(hiddenSwitch(page)).toHaveCount(0);
await page.reload();
await assertLink(page, "", false);
@ -240,6 +279,10 @@ test("unavailable session storage retains links across navigation; overview has
await openDrawer(page);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await closeDrawer(page);
await page.goto("/#/toolbox/guide");
await expect(page.locator('[data-module-type="checklist"]')).toHaveCount(4);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await expect(page.locator(".module-guide-button")).toHaveCount(0);
await page.goto(route);
await assertLink(page, "perfect", true);
});

View file

@ -90,10 +90,7 @@ export function validateSiteContent(site) {
"gamesPage.emptyTitle",
"gamesPage.emptyText",
"guideChecklist.label",
"guideChecklist.untitled",
"guideChecklist.none",
"guideChecklist.linkToolbox",
"guideChecklist.noCompatible",
"guideChecklist.associate",
"guideChecklist.noMatches",
"guideChecklist.matches",
"guideChecklist.unlink",

View file

@ -2720,11 +2720,8 @@
"emptyText": "Créez une première toolbox pour stocker vos outils dans ce navigateur."
},
"guideChecklist": {
"label": "Checklist associée",
"untitled": "Checklist sans titre",
"none": "Aucune checklist",
"linkToolbox": "Associer une toolbox pour suivre une checklist",
"noCompatible": "Aucune checklist de cette toolbox ne correspond aux éléments du guide.",
"label": "Lien avec le guide",
"associate": "Associer à la page",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances",
"unlink": "Dissocier",

View file

@ -1,32 +1,32 @@
// Rôle : affiche le sélecteur de checklist et le masquage commun aux pages guides.
// Rôle : expose le suivi du guide dans chaque checklist compatible et marque les cartes terminées.
import { Icon } from "../../components/Icon.jsx";
import { useGuideChecklist } from "./useGuideChecklist.js";
export function CurrentGuideChecklistControls({ gameId }) {
const checklist = useGuideChecklist();
return checklist.available && checklist.guideId.split("/")[0] === gameId ? <GuideChecklistControls checklist={checklist} /> : null;
export function GuideChecklistToggle({ checklist, panel, label }) {
if (!panel.list) return null;
return <button
className={`module-guide-button nds-icon-button ${panel.open ? "active" : ""}`}
data-size="md"
data-linked={checklist.selected?.id === panel.list.id || undefined}
type="button"
aria-label={`${checklist.content.label} — ${label}`}
title={checklist.content.label}
aria-expanded={panel.open}
aria-pressed={panel.open}
aria-controls={panel.id}
onClick={panel.toggle}
><Icon name="link" /></button>;
}
export function GuideChecklistControls({ checklist }) {
const { content, toolbox, selected, lists, hideCompleted } = checklist;
return <div className="guide-checklist-controls nds-panel" aria-label={content.label}>
{!toolbox ? <button type="button" className="nds-button" data-size="sm" onClick={checklist.linkToolbox}>{content.linkToolbox}</button> : <>
<label className="field compact">
<span>{content.label}</span>
<select className="nds-select" data-size="sm" value={selected?.id || ""} disabled={!lists.length} onChange={(event) => checklist.associate(event.target.value)}>
<option value="">{content.none}</option>
{lists.map((list) => <option key={list.id} value={list.id}>{list.title} · {list.count} {content.matches}</option>)}
</select>
</label>
{!lists.length && <p>{content.noCompatible}</p>}
{selected && <>
<div className="guide-checklist-actions">
<button type="button" className="nds-button" data-size="sm" onClick={() => checklist.associate("")}>{content.unlink}</button>
<button type="button" className="nds-switch" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
</div>
{!selected.count && <p>{content.noMatches}</p>}
</>}
</>}
export function GuideChecklistControls({ checklist, list, id, open }) {
if (!list) return null;
const { content, selected, hideCompleted } = checklist;
const active = selected?.id === list.id;
return <div id={id} hidden={!open} className="guide-checklist-controls" data-linked={active || undefined}>
<div className="guide-checklist-actions">
<button type="button" className="guide-checklist-link nds-button" data-size="sm" data-variant={active ? "primary" : "secondary"} aria-pressed={active} onClick={() => checklist.associate(active ? "" : list.id)}>{active ? content.unlink : content.associate}</button>
<button type="button" className="nds-switch" disabled={!active} aria-pressed={active && hideCompleted} onClick={checklist.toggleHidden}><Icon name="hide" /><span>{content.hideCompleted}</span><i aria-hidden="true" /></button>
</div>
<p>{list.count ? `${list.count} ${content.matches}` : content.noMatches}</p>
</div>;
}

View file

@ -1,21 +1,28 @@
// Rôle : partage la progression réactive et les commandes de liaison entre les guides.
import { createContext, useContext, useMemo } from "react";
import { createContext, useContext, useId, useMemo, useState } from "react";
import { getGuideChecklistEntries, matchGuideChecklist } from "./guideChecklist.js";
export const GuideChecklistContext = createContext(null);
export function useGuideChecklistPanel(checklist, module) {
const id = useId();
const [open, setOpen] = useState(false);
const list = module.type === "checklist" ? checklist?.lists.find((item) => item.id === module.id) : undefined;
return { id, open, list, toggle: () => setOpen((value) => !value) };
}
export function useGuideChecklist(requestedGuideId) {
const { toolboxes, links, moduleData, guideChecklistSession, actions, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const { toolboxes, links, moduleData, guideChecklistSession, siteContent, route, aniimo, mhwilds, diablo4 } = useContext(GuideChecklistContext);
const guideId = requestedGuideId || route.replace(/^\/games\//, "");
const entries = useMemo(() => getGuideChecklistEntries(guideId, { aniimo, mhwilds, diablo4 }), [guideId, aniimo, mhwilds, diablo4]);
const gameId = guideId.split("/")[0];
const content = siteContent.guideChecklist;
const toolbox = toolboxes.find((item) => item.id === links[gameId]);
const association = guideChecklistSession.associations[guideId];
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module, index) => ({
id: module.id, title: module.title || `${content.untitled} ${index + 1}`,
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
id: module.id,
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
})), [entries, toolbox, moduleData, content.untitled]);
})), [entries, toolbox, moduleData]);
const selected = lists.find((list) => list.id === association?.moduleId);
const hideCompleted = Boolean(selected && association.hideCompleted);
const isCompleted = (key) => Boolean(selected?.completed.has(key));
@ -24,7 +31,6 @@ export function useGuideChecklist(requestedGuideId) {
lists: lists.filter((list) => list.count || list.id === selected?.id),
isVisible: (key) => !hideCompleted || !isCompleted(key),
associate: (moduleId) => guideChecklistSession.setAssociation(guideId, moduleId ? { toolboxId: toolbox.id, moduleId, hideCompleted } : null),
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted }),
linkToolbox: () => actions.setLinkModalGameId(gameId)
toggleHidden: () => guideChecklistSession.setAssociation(guideId, { ...association, hideCompleted: !hideCompleted })
};
}

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { GuidedTooltipTour } from "../../components/GuidedTooltipTour.jsx";
import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx";
import { CurrentGuideChecklistControls } from "../games/GuideChecklistControls.jsx";
import { useGuideChecklist } from "../games/useGuideChecklist.js";
import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
@ -180,7 +180,7 @@ function shouldShowTutorial(toolbox, pendingTutorialToolboxId) {
return toolbox?.tutorial === true || pendingTutorialToolboxId === toolbox?.id;
}
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId }) {
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles, pendingTutorialToolboxId = "", setPendingTutorialToolboxId, guideChecklist }) {
const toolboxId = toolbox.id;
const moduleColumns = embedded || Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const toolboxGameCover = getGameCardCover(toolboxGame);
@ -194,6 +194,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
if (previousTutorial.toolboxId !== toolboxId || tutorialRequested) setShowTutorial(tutorialRequested);
}
const moduleContext = {
guideChecklist,
getModuleData,
ensureModuleData: actions.ensureModuleData,
isModuleDataReady: actions.isModuleDataReady,
@ -341,6 +342,8 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
}
export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, storageReady = true, getModuleData, updateToolbox, updateModuleData, addImageFiles }) {
const checklist = useGuideChecklist();
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("");
@ -400,8 +403,7 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
</div>
<button className="drawer-close-button nds-icon-button" data-size="sm" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
</header>
<CurrentGuideChecklistControls gameId={gameId} />
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} /> : (
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} storageReady={storageReady} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addImageFiles={addImageFiles} guideChecklist={guideChecklist} /> : (
<div className="empty nds-empty-state"><p>Aucune toolbox associée à cette page jeu.</p><button className="nds-button" data-size="sm" data-variant="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
)}
<StorageQuota usage={storageUsage} />

View file

@ -1,7 +1,11 @@
// Rôle : attend les médias persistés avant de monter un outil éditable et propose une reprise locale en cas d'échec.
// Rôle : affiche le chargement des outils et attend leurs médias persistés, avec reprise locale en cas d'échec.
import { useEffect, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
export function ModuleDataGate({ context, toolboxId, moduleId, children }) {
const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true;
const load = context.ensureModuleData;

View file

@ -1,20 +1,18 @@
// Rôle : rend les modules d'une toolbox, leur shell commun et leur réorganisation.
import { Suspense, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { GuideChecklistControls, GuideChecklistToggle } from "../../games/GuideChecklistControls.jsx";
import { useGuideChecklistPanel } from "../../games/useGuideChecklist.js";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
import { ModuleDataGate } from "./ModuleDataGate.jsx";
import { ModuleDataGate, ModuleLoadingFallback } from "./ModuleDataGate.jsx";
const MODULE_COLUMN_IDS = ["left", "right"];
function getModuleMap(modules) {
return new Map(modules.map((module) => [module.id, module]));
}
function getModulesByIds(modules, ids) {
const moduleMap = getModuleMap(modules);
const moduleMap = new Map(modules.map((module) => [module.id, module]));
return (Array.isArray(ids) ? ids : []).map((id) => moduleMap.get(id)).filter(Boolean);
}
@ -157,6 +155,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
const label = definition.label || module.type;
const [editing, setEditing] = useState(false);
const [textExchangeOpen, setTextExchangeOpen] = useState(false);
const guidePanel = useGuideChecklistPanel(context.guideChecklist, module);
const hasAddPanel = definition.editable && definition.addable !== false;
const hasTextExchange = supportsTextExchange(module.type);
const scrollable = definition.scrollable && module.scrollable === true;
@ -242,6 +241,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
<Icon name="add" />
</button>
)}
<GuideChecklistToggle checklist={context.guideChecklist} panel={guidePanel} label={module.title || label} />
{hasTextExchange && (
<button
className={`module-exchange-button nds-icon-button ${textExchangeOpen ? "active" : ""}`}
@ -264,6 +264,7 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<GuideChecklistControls checklist={context.guideChecklist} {...guidePanel} />
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} />
@ -274,10 +275,6 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
);
}
function ModuleLoadingFallback({ label }) {
return <div className="module-loading-fallback">Chargement de {label}...</div>;
}
function EditableModuleTitle({ value, fallback, onSave, tutorialTarget = "" }) {
const inlineEdit = useInlineEdit({
value,

View file

@ -1,18 +1,15 @@
// Rôle : présente la liaison checklist et l'état terminé communs aux cartes de guides.
.guide-checklist-controls {
display: grid;
align-items: center;
text-align: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
padding: var(--space-3);
margin-bottom: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border);
min-width: 0;
.field { min-width: 0; margin: 0; }
select { width: 100%; min-width: 0; }
p { margin: 0; color: var(--color-text-muted); font-size: var(--font-size-sm); }
p { margin: var(--space-2) 0 0; color: var(--color-text-muted); font-size: var(--font-size-xs); }
.nds-switch { max-width: 100%; }
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-3); }
.guide-checklist-actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: var(--space-2); padding-top: var(--space-2); }
}
.module-guide-button[data-linked] .ui-icon { color: var(--color-accent-gold); }
.guide-checklist-mark {
position: absolute;
top: 10px;