update checklist component
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-07-24 13:38:17 +02:00
parent 772b9951de
commit d9d22683dc
13 changed files with 709 additions and 65 deletions

View file

@ -1,38 +1,55 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { TextImportModal } from "./TextImportModal.jsx";
import { parseColonImportLines } from "./textImport.js";
export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
const data = context.normalizeChecklistData(context.getModuleData(toolboxId, moduleId, { items: [] }));
const textContent = context.moduleText?.checklist || {};
const [label, setLabel] = useState("");
const [sectionTitle, setSectionTitle] = useState("");
const [qty, setQty] = useState(1);
const [textImport, setTextImport] = useState("");
const [importOpen, setImportOpen] = useState(false);
const closeImportModal = useCallback(() => setImportOpen(false), []);
const completedNamedSections = data.sections.filter((section) => section.title && isSectionComplete(section, context));
const allCompletedSectionsHidden = completedNamedSections.length > 0 && completedNamedSections.every((section) => getSectionHideWhenComplete(section, data.hideCompletedSections));
function save(items) {
context.setModuleData(toolboxId, moduleId, { items });
function save(sections, settings = {}) {
context.setModuleData(toolboxId, moduleId, { ...data, ...settings, sections });
}
function appendItems(sections, title, items) {
const cleanTitle = title.trim();
const existingIndex = sections.findIndex((section) => section.title.toLowerCase() === cleanTitle.toLowerCase());
if (existingIndex >= 0) {
return sections.map((section, index) => index === existingIndex ? { ...section, items: [...section.items, ...items] } : section);
}
return [...sections, { id: context.uid("section"), title: cleanTitle, items }];
}
function addItem(event) {
event.preventDefault();
const cleanLabel = label.trim();
if (!cleanLabel) return;
save([...data.items, { id: context.uid("item"), label: cleanLabel, qtyTarget: Math.max(1, Number(qty) || 1), qtyCurrent: 0 }]);
const nextItem = { id: context.uid("item"), label: cleanLabel, qtyTarget: Math.max(1, Number(qty) || 1), qtyCurrent: 0 };
save(appendItems(data.sections, sectionTitle, [nextItem]));
setLabel("");
setQty(1);
}
function importItems(event) {
event.preventDefault();
const imported = parseColonImportLines(textImport).map((entry) => ({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
qtyCurrent: 0
}));
if (!imported.length) return;
save([...data.items, ...imported]);
const importedSections = parseChecklistImport(textImport, context);
if (!importedSections.length) return;
const nextSections = importedSections.reduce((sections, section) => appendItems(sections, section.title, section.items), data.sections);
save(nextSections);
setTextImport("");
setImportOpen(false);
}
function setHideCompletedSections(hideCompletedSections) {
save(data.sections.map((section) => ({ ...section, hideWhenComplete: undefined })), { hideCompletedSections });
}
return (
@ -40,30 +57,156 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) {
{editing && (
<div className="module-add-panel">
<form className="inline-form checklist-add-form" onSubmit={addItem}>
<input name="section" placeholder={textContent.sectionPlaceholder || "Catégorie"} value={sectionTitle} onChange={(event) => setSectionTitle(event.target.value)} />
<input name="label" placeholder={textContent.itemPlaceholder || "Nouvel item"} value={label} onChange={(event) => setLabel(event.target.value)} />
<input className="checklist-qty-input" name="qty" type="number" min="1" value={qty} onChange={(event) => setQty(event.target.value)} aria-label={textContent.quantityLabel || "Quantité cible"} />
<button className="primary">{textContent.addButton || "Ajouter"}</button>
</form>
<form className="text-import-form" onSubmit={importItems}>
<textarea
value={textImport}
onChange={(event) => setTextImport(event.target.value)}
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
rows={3}
/>
<button type="submit">{textContent.importButton || "Importer le texte"}</button>
</form>
<div className="text-import-actions">
<button type="button" onClick={() => setImportOpen(true)}>
<Icon name="import" />
<span>{textContent.importOpenButton || "Importer du texte"}</span>
</button>
</div>
</div>
)}
{importOpen && (
<TextImportModal
title={textContent.importModalTitle || "Importer une checklist"}
value={textImport}
onChange={setTextImport}
placeholder={textContent.importPlaceholder || "Importer plusieurs items...\nPotion:10\nMéga potion:5"}
submitLabel={textContent.importButton || "Importer le texte"}
onSubmit={importItems}
onClose={closeImportModal}
/>
)}
{completedNamedSections.length > 0 && (
<div className="checklist-toolbar">
<button
className={`checklist-hide-toggle ${allCompletedSectionsHidden ? "active" : ""}`}
type="button"
onClick={() => setHideCompletedSections(!allCompletedSectionsHidden)}
aria-pressed={allCompletedSectionsHidden}
title={textContent.hideCompletedSectionsTitle || "Masquer les catégories terminées"}
aria-label={textContent.hideCompletedSectionsTitle || "Masquer les catégories terminées"}
>
<Icon name="hide" />
<i aria-hidden="true" />
</button>
</div>
)}
<ul className="checklist">
{data.items.map((item) => (
<ChecklistItem key={item.id} item={item} toolboxId={toolboxId} moduleId={moduleId} context={context} items={data.items} save={save} />
{data.sections.map((section) => (
<ChecklistSection key={section.id} section={section} sections={data.sections} data={data} context={context} save={save} />
))}
</ul>
</>
);
}
function parseChecklistImport(text, context) {
const sections = [];
let currentSection = { id: context.uid("section"), title: "", items: [] };
String(text || "").split(/\r?\n/).forEach((line) => {
const cleanLine = line.trim();
if (!cleanLine) return;
if (cleanLine.startsWith("#")) {
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
currentSection = { id: context.uid("section"), title: cleanLine.replace(/^#+/, "").trim(), items: [] };
return;
}
parseColonImportLines(cleanLine).forEach((entry) => {
currentSection.items.push({
id: context.uid("item"),
label: entry.label,
qtyTarget: Math.max(1, Number.parseInt(entry.value, 10) || 1),
qtyCurrent: 0
});
});
});
if (currentSection.items.length || currentSection.title) sections.push(currentSection);
return sections.filter((section) => section.items.length);
}
function isSectionComplete(section, context) {
return section.items.length > 0 && section.items.every((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget);
}
function getSectionHideWhenComplete(section, globalHide) {
return typeof section.hideWhenComplete === "boolean" ? section.hideWhenComplete : globalHide;
}
function ChecklistSection({ section, context, sections, data, save }) {
const showTitle = Boolean(section.title);
const sectionComplete = showTitle && isSectionComplete(section, context);
const hideWhenComplete = getSectionHideWhenComplete(section, data.hideCompletedSections);
const isAutoCollapsed = sectionComplete && hideWhenComplete;
const isCollapsed = Boolean(section.collapsed) || isAutoCollapsed;
function saveItems(items) {
save(sections.map((entry) => entry.id === section.id ? { ...entry, items } : entry).filter((entry) => entry.items.length || entry.title));
}
function toggleSectionCollapsed() {
const nextCollapsed = !isCollapsed;
const nextSections = sections.map((entry) => {
if (entry.id === section.id) {
const nextSection = { ...entry };
if (nextCollapsed) {
if (sectionComplete) {
nextSection.hideWhenComplete = true;
delete nextSection.collapsed;
} else {
nextSection.collapsed = true;
}
} else {
delete nextSection.collapsed;
if (sectionComplete) nextSection.hideWhenComplete = false;
}
return nextSection;
}
if (data.hideCompletedSections && !nextCollapsed && entry.title && isSectionComplete(entry, context)) return { ...entry, hideWhenComplete: true };
return entry;
});
const nextCompletedSections = nextSections.filter((entry) => entry.title && isSectionComplete(entry, context));
const allHidden = nextCompletedSections.length > 0 && nextCompletedSections.every((entry) => getSectionHideWhenComplete(entry, false));
save(nextSections, { hideCompletedSections: allHidden });
}
return (
<li className={`checklist-section ${showTitle ? "is-grouped" : ""} ${isCollapsed ? "is-collapsed" : ""}`}>
{showTitle && (
<div className="checklist-section-header">
<h3>{section.title || "Sans catégorie"}</h3>
<div>
<span>{section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget).length} / {section.items.length}</span>
<button
className="checklist-section-collapse-button"
type="button"
onClick={toggleSectionCollapsed}
aria-expanded={!isCollapsed}
title={isCollapsed ? context.moduleText?.checklist?.showCompletedSectionTitle || "Afficher cette catégorie" : context.moduleText?.checklist?.hideCompletedSectionTitle || "Masquer cette catégorie"}
aria-label={isCollapsed ? context.moduleText?.checklist?.showCompletedSectionTitle || "Afficher cette catégorie" : context.moduleText?.checklist?.hideCompletedSectionTitle || "Masquer cette catégorie"}
>
<Icon name={isCollapsed ? "chevron-down" : "chevron-up"} />
</button>
</div>
</div>
)}
{!isCollapsed && (
<ul className="checklist-section-items">
{section.items.map((item) => (
<ChecklistItem key={item.id} item={item} context={context} items={section.items} save={saveItems} />
))}
</ul>
)}
</li>
);
}
function ChecklistItem({ item, context, items, save }) {
const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget;