Add checklist switching and contextual memo ticker
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
This commit is contained in:
parent
273bc5d528
commit
bd4059c9c1
16 changed files with 231 additions and 28 deletions
|
|
@ -370,7 +370,8 @@ Regles UI :
|
|||
- palette de couleurs compacte reprenant les swatches du Bloc Note ;
|
||||
- la creation de mémo est masquee par defaut et s'ouvre via le bouton `+` du header d'outil ;
|
||||
- réorganisation des cartes par poignée de drag & drop compacte ;
|
||||
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos.
|
||||
- bande deroulante visible uniquement quand elle est active et qu'au moins un mémo existe ; elle affiche tous les mémos dans l'outil et reste fixée en bas des pages complètes de cette toolbox ainsi que des pages du jeu qui lui est associé ;
|
||||
- la bande fixe laisse le contenu verticalement accessible, suit la largeur disponible et remonte au-dessus de la navigation mobile.
|
||||
|
||||
### Calque De Dessin
|
||||
|
||||
|
|
@ -859,10 +860,13 @@ reste dorée lorsque la liste est associée. Le bouton NDS
|
|||
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é.
|
||||
Quand une liste est associée, un encart compact placé en haut à droite du header
|
||||
du guide expose directement le switch « Masquer les cochés » et l'action
|
||||
« Dissocier la checklist ». La colonne d'actions occupe la hauteur existante du
|
||||
garde ses commandes avec un message explicite.
|
||||
Un encart compact placé en haut à droite du header du guide expose un sélecteur dès
|
||||
qu'au moins une checklist compatible existe. Sans association, lui seul est affiché.
|
||||
Une fois une liste choisie, l'encart ajoute le switch « Masquer les cochés » et
|
||||
l'action « Dissocier la checklist ». Changer la
|
||||
valeur du sélecteur remplace immédiatement l'association sans perdre le réglage de
|
||||
masquage. La colonne d'actions occupe la hauteur existante du
|
||||
header afin que cet encart n'ajoute pas de ligne ou d'espace vertical. Cette
|
||||
association est conservée dans `localStorage` entre les sessions ; elle est retirée
|
||||
si la checklist, la toolbox liée ou la liaison jeu/toolbox disparaît.
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ Type : `memos`
|
|||
Stockage compact :
|
||||
|
||||
- `tickerEnabled` vaut `true` par défaut et est omis dans ce cas ;
|
||||
- lorsqu'il est actif, la bande est aussi affichée en position fixe sur la page complète de la toolbox et sur les pages du jeu lié ;
|
||||
- `updatedAt` est omis si vide ou invalide ;
|
||||
- les mémos sans texte sont ignorés ;
|
||||
- si aucun mémo n'est valide et que la bande est active par défaut, l'entrée de module peut être supprimée.
|
||||
|
|
@ -251,7 +252,9 @@ affiche un toggle de lien à côté du « + » de son en-tête dans le panneau l
|
|||
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
|
||||
réglage de masquage ; le sélecteur affiché dans l'en-tête du guide dès qu'une liste
|
||||
compatible existe permet le même changement ; sans association, les autres actions
|
||||
de l'encart restent masquées. 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ not bugs, missing scenarios or a percentage of work remaining.
|
|||
| Checklist: quantities, clamping, completed item/category visibility and persistence | `e2e/basic-tools.spec.js` |
|
||||
| Images: gallery, detail navigation, keyboard controls and focus restoration | `e2e/basic-tools.spec.js` |
|
||||
| Guide checklists: persistent association, page controls, drawer scope and live completion | `e2e/guide-checklist.spec.js` |
|
||||
| Mémos : bande fixe, portée par route et persistance du toggle | `e2e/memos-ticker.spec.js` |
|
||||
| Planner: editing, recurrence, prerequisites, subtree reorder and persistence | `e2e/task-planner.spec.js` |
|
||||
| Representative visual states and responsive layouts | `e2e/style-layouts.spec.js`, `tool-style-states.spec.js`, `card-surfaces.spec.js`, `nds-controls.spec.js`, `nds-patterns.spec.js` |
|
||||
| Production onboarding | `e2e/production/onboarding.spec.js` |
|
||||
|
|
|
|||
|
|
@ -132,6 +132,12 @@ for (const width of [1280, 390]) {
|
|||
await page.goto(route);
|
||||
await page.getByRole("searchbox").fill("emberpup");
|
||||
const card = page.locator(".aniimo-result");
|
||||
const pageControls = page.locator(".guide-checklist-page-controls");
|
||||
const unlinkedSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
|
||||
await expect(pageControls).toBeVisible();
|
||||
await expect(unlinkedSelect).toHaveValue("");
|
||||
await expect(unlinkedSelect).toContainText("Choisir une checklist");
|
||||
await expect(pageControls.getByRole("button")).toHaveCount(0);
|
||||
await openDrawer(page);
|
||||
await expect(activeButtons(page)).toHaveCount(0);
|
||||
await expect(page.locator(".guide-checklist-controls")).toHaveCount(3);
|
||||
|
|
@ -164,7 +170,6 @@ for (const width of [1280, 390]) {
|
|||
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);
|
||||
const pageControls = page.locator(".guide-checklist-page-controls");
|
||||
await expect(pageControls).toBeVisible();
|
||||
const pageControlsBounds = await pageControls.boundingBox();
|
||||
const pageHeadingBounds = await page.locator(".page-heading").boundingBox();
|
||||
|
|
@ -175,6 +180,14 @@ for (const width of [1280, 390]) {
|
|||
expect(headingActionsBounds.height).toBeCloseTo(pageHeadingBounds.height, 0);
|
||||
expect(pageControlsBounds.y).toBeCloseTo(pageHeadingBounds.y, 0);
|
||||
}
|
||||
const checklistSelect = pageControls.getByRole("combobox", { name: "Checklist associée", exact: true });
|
||||
await expect(checklistSelect).toHaveValue("forms");
|
||||
await expect(checklistSelect.locator("option")).toHaveCount(3);
|
||||
await expect(checklistSelect.locator('option[value="unrelated"]')).toHaveCount(0);
|
||||
await checklistSelect.selectOption("capture");
|
||||
await expect(checklistSelect).toHaveValue("capture");
|
||||
await checklistSelect.selectOption("forms");
|
||||
await expect(checklistSelect).toHaveValue("forms");
|
||||
await expect(pageControls.getByRole("button", { name: "Masquer les cochés", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(pageControls.getByRole("button", { name: "Dissocier la checklist", exact: true })).toBeVisible();
|
||||
await expect(card).toHaveAttribute("data-checklist-complete", "true");
|
||||
|
|
|
|||
56
e2e/memos-ticker.spec.js
Normal file
56
e2e/memos-ticker.spec.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Rôle : vérifie la bande de mémos fixe sur les pages de toolbox et de jeu associées.
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||
|
||||
async function seedMemosToolbox(page) {
|
||||
await resetSokkoStorage(page);
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = globalThis.indexedDB.open("sokkog", 1);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const tx = db.transaction(["kv", "modules"], "readwrite");
|
||||
tx.objectStore("kv").put([
|
||||
{ id: "memo-toolbox", name: "Toolbox mémos", modules: [{ id: "memos", type: "memos", title: "Mémos" }], moduleColumns: 1 }
|
||||
], "toolboxes");
|
||||
tx.objectStore("kv").put({ mhwilds: "memo-toolbox" }, "links");
|
||||
tx.objectStore("modules").put({
|
||||
key: "memo-toolbox:memos",
|
||||
data: { items: [{ id: "memo-1", text: "Préparer les potions", color: "#f6c453" }] }
|
||||
});
|
||||
tx.oncomplete = () => { db.close(); resolve(); };
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("shows and persists the memo ticker on linked game and full toolbox pages", async ({ page }) => {
|
||||
await seedMemosToolbox(page);
|
||||
|
||||
await page.goto("/#/games/mhwilds/monsters");
|
||||
const globalTicker = page.locator(".global-memos-ticker");
|
||||
await expect(globalTicker).toBeVisible();
|
||||
await expect(globalTicker).toContainText("Préparer les potions");
|
||||
await expect(globalTicker).toHaveCSS("position", "fixed");
|
||||
|
||||
await page.goto("/#/games/aniimo/aniimos");
|
||||
await expect(globalTicker).toHaveCount(0);
|
||||
|
||||
await page.goto("/#/toolbox/memo-toolbox");
|
||||
await expect(globalTicker).toBeVisible();
|
||||
const toggle = page.locator(".memos-ticker-toggle");
|
||||
await expect(toggle).toHaveAccessibleName("Masquer la bande");
|
||||
await toggle.click();
|
||||
await expect(globalTicker).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(globalTicker).toHaveCount(0);
|
||||
await expect(toggle).toHaveAccessibleName("Afficher la bande");
|
||||
await toggle.click();
|
||||
await expect(globalTicker).toBeVisible();
|
||||
|
||||
await page.goto("/#/toolboxes");
|
||||
await expect(globalTicker).toHaveCount(0);
|
||||
});
|
||||
|
|
@ -90,6 +90,8 @@ export function validateSiteContent(site) {
|
|||
"gamesPage.emptyTitle",
|
||||
"gamesPage.emptyText",
|
||||
"guideChecklist.label",
|
||||
"guideChecklist.selectLabel",
|
||||
"guideChecklist.selectPlaceholder",
|
||||
"guideChecklist.associate",
|
||||
"guideChecklist.noMatches",
|
||||
"guideChecklist.matches",
|
||||
|
|
|
|||
|
|
@ -2771,6 +2771,8 @@
|
|||
},
|
||||
"guideChecklist": {
|
||||
"label": "Lien avec le guide",
|
||||
"selectLabel": "Checklist associée",
|
||||
"selectPlaceholder": "Choisir une checklist",
|
||||
"associate": "Associer à la page",
|
||||
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
|
||||
"matches": "correspondances",
|
||||
|
|
|
|||
|
|
@ -31,16 +31,27 @@ export function GuideChecklistControls({ checklist, list, id, open }) {
|
|||
}
|
||||
|
||||
export function GuideChecklistPageControls({ checklist }) {
|
||||
if (!checklist.selected) return null;
|
||||
if (!checklist.lists.length) return null;
|
||||
const { content, hideCompleted } = checklist;
|
||||
return (
|
||||
<aside className="guide-checklist-page-controls nds-tool-panel" aria-label={content.label}>
|
||||
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
|
||||
<Icon name="hide" />
|
||||
<span>{content.hideCompleted}</span>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
|
||||
<label className="guide-checklist-page-select">
|
||||
<span>{content.selectLabel}</span>
|
||||
<select className="nds-select" data-size="sm" value={checklist.selected?.id || ""} onChange={(event) => checklist.associate(event.target.value)}>
|
||||
{!checklist.selected && <option value="" disabled>{content.selectPlaceholder}</option>}
|
||||
{checklist.lists.map((list) => <option key={list.id} value={list.id}>{list.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{checklist.selected && (
|
||||
<div className="guide-checklist-page-actions">
|
||||
<button className={`nds-switch ${hideCompleted ? "active" : ""}`} data-size="sm" type="button" aria-pressed={hideCompleted} onClick={checklist.toggleHidden}>
|
||||
<Icon name="hide" />
|
||||
<span>{content.hideCompleted}</span>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
<button className="nds-button" data-size="xs" data-variant="secondary" type="button" onClick={() => checklist.associate("")}>{content.unlink}</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function useGuideChecklist(requestedGuideId) {
|
|||
const association = guideChecklistStorage.associations[guideId];
|
||||
const lists = useMemo(() => (toolbox?.modules || []).filter((module) => module.type === "checklist").map((module) => ({
|
||||
id: module.id,
|
||||
title: module.title || "Checklist",
|
||||
...matchGuideChecklist(entries || [], moduleData[`${toolbox.id}:${module.id}`])
|
||||
})), [entries, toolbox, moduleData]);
|
||||
const selected = lists.find((list) => list.id === association?.moduleId);
|
||||
|
|
|
|||
33
website/src/features/toolboxes/GlobalMemosTicker.jsx
Normal file
33
website/src/features/toolboxes/GlobalMemosTicker.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Rôle : affiche en bas de page les mémos actifs de la toolbox courante ou liée au jeu.
|
||||
import { useMemo } from "react";
|
||||
import { moduleStorageKey, normalizeMemosData } from "./storage/toolboxStorage.js";
|
||||
import { MemosTicker } from "./modules/MemosTicker.jsx";
|
||||
|
||||
function getRouteToolboxId(route, links) {
|
||||
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
|
||||
if (toolboxMatch) return decodeURIComponent(toolboxMatch[1]);
|
||||
const gameMatch = route.match(/^\/games\/([^/]+)/);
|
||||
return gameMatch ? links[gameMatch[1]] || "" : "";
|
||||
}
|
||||
|
||||
export function GlobalMemosTicker({ route, toolboxes, links, moduleData, textContent }) {
|
||||
const items = useMemo(() => {
|
||||
const toolboxId = getRouteToolboxId(route, links);
|
||||
const toolbox = toolboxes.find((item) => item.id === toolboxId);
|
||||
if (!toolbox) return [];
|
||||
return toolbox.modules
|
||||
.filter((module) => module.type === "memos")
|
||||
.flatMap((module) => {
|
||||
const data = normalizeMemosData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
|
||||
if (!data.tickerEnabled) return [];
|
||||
return data.items.map((memo) => ({ ...memo, id: `${module.id}:${memo.id}` }));
|
||||
});
|
||||
}, [route, toolboxes, links, moduleData]);
|
||||
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<aside className="global-memos-ticker" aria-label={textContent?.tickerLabel || "Mémos"}>
|
||||
<MemosTicker items={items} label={textContent?.tickerLabel || "Mémos"} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
|
|||
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
|
||||
import { useMemo } from "react";
|
||||
import { MemoTextEditor } from "./MemoTextEditor.jsx";
|
||||
import { MemosTicker } from "./MemosTicker.jsx";
|
||||
import { TextExchangeActions } from "./TextExchangeActions.jsx";
|
||||
import { TEXT_COLORS } from "./notepadUtils.js";
|
||||
|
||||
|
|
@ -92,21 +93,12 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
|
|||
type="button"
|
||||
onClick={() => save({ ...data, tickerEnabled: !data.tickerEnabled })}
|
||||
aria-pressed={data.tickerEnabled}
|
||||
aria-label={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"}
|
||||
title={data.tickerEnabled ? textContent.hideTickerTitle || "Masquer la bande" : textContent.showTickerTitle || "Afficher la bande"}
|
||||
>
|
||||
<Icon name={data.tickerEnabled ? "eye-open" : "eye-closed"} />
|
||||
</button>
|
||||
{tickerItems.length > 0 && (
|
||||
<div className="memos-ticker" aria-label={textContent.tickerLabel || "Mémos"}>
|
||||
<div className="memos-ticker-track">
|
||||
{[...tickerItems, ...tickerItems].map((memo, index) => (
|
||||
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
|
||||
{memo.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MemosTicker items={tickerItems} label={textContent.tickerLabel || "Mémos"} />
|
||||
</div>
|
||||
|
||||
<div className="memos-list">
|
||||
|
|
|
|||
15
website/src/features/toolboxes/modules/MemosTicker.jsx
Normal file
15
website/src/features/toolboxes/modules/MemosTicker.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Rôle : affiche la bande déroulante partagée par l'outil Mémos et les pages associées.
|
||||
export function MemosTicker({ items, label = "Mémos", className = "" }) {
|
||||
if (!items?.length) return null;
|
||||
return (
|
||||
<div className={["memos-ticker", className].filter(Boolean).join(" ")} aria-label={label}>
|
||||
<div className="memos-ticker-track">
|
||||
{[...items, ...items].map((memo, index) => (
|
||||
<span className="memos-ticker-item" key={`${memo.id}-${index}`} style={{ "--memo-color": memo.color }}>
|
||||
{memo.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { RouteContent } from "./router/RouteContent.jsx";
|
|||
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";
|
||||
|
||||
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
|
||||
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
||||
|
|
@ -211,6 +212,13 @@ function App() {
|
|||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<GlobalMemosTicker
|
||||
route={route}
|
||||
toolboxes={store.toolboxes}
|
||||
links={store.links}
|
||||
moduleData={store.moduleData}
|
||||
textContent={siteContent.toolboxes.modules.memos}
|
||||
/>
|
||||
<AppOverlays
|
||||
storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle}
|
||||
actions={actions}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.guide-checklist-page-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.home-onboarding-steps-action {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,31 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
.guide-checklist-page-controls {
|
||||
display: flex;
|
||||
display: grid;
|
||||
align-self: flex-end;
|
||||
gap: var(--space-2);
|
||||
width: min(100%, 360px);
|
||||
padding: 8px;
|
||||
}
|
||||
.guide-checklist-page-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.guide-checklist-page-select > span { white-space: nowrap; }
|
||||
.guide-checklist-page-select .nds-select {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.guide-checklist-page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
width: fit-content;
|
||||
padding: 8px;
|
||||
}
|
||||
.guide-checklist-page-controls .nds-switch { max-width: 100%; }
|
||||
.game-heading-navigation {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,45 @@
|
|||
background: rgba(5, 7, 17, 0.56);
|
||||
}
|
||||
|
||||
.global-memos-ticker {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 260px;
|
||||
z-index: 45;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.28);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(7, 9, 19, 0.9);
|
||||
box-shadow: var(--shadow-md);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.global-memos-ticker .memos-ticker {
|
||||
width: 100%;
|
||||
background: rgba(5, 7, 17, 0.72);
|
||||
}
|
||||
|
||||
.app-shell.sidebar-compact .global-memos-ticker {
|
||||
left: 84px;
|
||||
}
|
||||
|
||||
.app-shell:has(.global-memos-ticker) main {
|
||||
padding-bottom: calc(var(--space-12) + 60px);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.global-memos-ticker { left: 84px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.global-memos-ticker {
|
||||
right: 12px;
|
||||
bottom: 84px;
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.memos-ticker-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue