From 8eb622e05d778d6532a24999c2f5ba6fead1b815 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Sun, 2 Aug 2026 22:12:33 +0200 Subject: [PATCH] Improve toolbox reorder and checklist editing UX --- tests/helpers/data-validation.mjs | 6 + tests/static-toolboxes.test.mjs | 23 +- website/public/data/site.json | 6 + .../toolboxes/modules/ChecklistModule.jsx | 342 ++++++++++++++++-- .../toolboxes/modules/CombosModule.jsx | 16 +- .../toolboxes/modules/CountersModule.jsx | 17 +- .../toolboxes/modules/LinksModule.jsx | 34 +- .../toolboxes/modules/TaskPlannerModule.jsx | 44 +-- .../toolboxes/modules/TimerModule.jsx | 78 ++-- website/src/hooks/useDraftForm.js | 47 +++ website/src/styles/_toolboxes.scss | 222 ++++++++++-- 11 files changed, 695 insertions(+), 140 deletions(-) create mode 100644 website/src/hooks/useDraftForm.js diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index f367b4c..95ca4cf 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -132,6 +132,12 @@ export function validateSiteContent(site) { "toolboxes.modules.checklist.decrementLabel", "toolboxes.modules.checklist.incrementLabel", "toolboxes.modules.checklist.currentQuantityLabel", + "toolboxes.modules.checklist.reorderTitle", + "toolboxes.modules.checklist.reorderSectionTitle", + "toolboxes.modules.checklist.sectionEditTitle", + "toolboxes.modules.checklist.itemEditLabel", + "toolboxes.modules.checklist.categoryEditLabel", + "toolboxes.modules.checklist.editTitle", "toolboxes.modules.checklist.deleteTitle", "toolboxes.modules.images.addImages", "toolboxes.modules.images.pastePlaceholder", diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 3ba083e..332f110 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -15,6 +15,7 @@ test("toolbox storage, cards and pages are wired", async () => { const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8"); + const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8"); assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/); assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/); @@ -90,6 +91,10 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(inlineEditHook, /blurOnEscape/); assert.match(inlineEditHook, /commitOnEnter/); assert.match(inlineEditHook, /getContentEditableProps/); + assert.match(draftFormHook, /export function useDraftForm/); + assert.match(draftFormHook, /getFieldProps/); + assert.match(draftFormHook, /handleSubmit/); + assert.match(draftFormHook, /preventDefault/); }); test("toolbox module registry and modules expose expected behavior", async () => { @@ -156,6 +161,8 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(combosModule, /useInlineEdit/); assert.match(combosModule, /useGroupedReorder/); assert.match(combosModule, /reorderFeatures/); + assert.match(combosModule, /getGroupBoundaryProps/); + assert.match(combosModule, /combos-category-boundary-drop-zone/); assert.doesNotMatch(combosModule, /canMoveItem:/); assert.doesNotMatch(combosModule, /canMoveGroup:/); assert.match(calculatorModule, /export function CalculatorModule/); @@ -208,6 +215,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(timerModule, /stopwatch/); assert.match(timerModule, /countdown/); assert.match(timerModule, /useInlineEdit/); + assert.match(timerModule, /useDraftForm/); assert.match(timerModule, /normalizeTimerData/); assert.match(taskPlannerModule, /export function TaskPlannerModule/); assert.match(taskPlannerModule, /normalizeTaskPlannerData/); @@ -219,6 +227,7 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(taskPlannerModule, /task-planner-warning/); assert.match(taskPlannerModule, /useGroupedReorder/); assert.match(taskPlannerModule, /useInlineEdit/); + assert.match(taskPlannerModule, /useDraftForm/); assert.match(taskPlannerModule, /commitOnEnter: false/); assert.match(taskPlannerModule, /reorderFeatures/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); @@ -237,7 +246,15 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(checklistModule, /Number\.parseInt/); assert.match(checklistModule, /checklist-qty-current/); assert.match(checklistModule, /checklist-delete-button danger/); - assert.doesNotMatch(checklistModule, /useGroupedReorder/); + assert.match(checklistModule, /useGroupedReorder/); + assert.match(checklistModule, /getItemProps/); + assert.match(checklistModule, /getGroupProps/); + assert.match(checklistModule, /getGroupBoundaryProps/); + assert.match(checklistModule, /checklist-category-boundary-drop-zone/); + assert.match(checklistModule, /itemReorder\.startDrag/); + assert.match(checklistModule, /groupReorder\.startDrag/); + assert.match(checklistModule, /useInlineEdit/); + assert.match(checklistModule, /moveChecklistItemToCategory/); assert.match(textImportModal, /createPortal/); assert.match(textImportModal, /lockBodyScroll/); assert.match(imagesModule, /export function ImagesModule/); @@ -264,11 +281,15 @@ test("toolbox module registry and modules expose expected behavior", async () => assert.match(linksModule, /parseColonImportLines/); assert.match(linksModule, /context\.normalizeUrl/); assert.match(linksModule, /useGroupedReorder/); + assert.match(linksModule, /useDraftForm/); + assert.match(linksModule, /linkDraft\.getFieldProps/); assert.match(linksModule, /link-drag-handle/); assert.match(countersModule, /export function CountersModule/); assert.match(countersModule, /editing &&/); assert.match(countersModule, /counter-actions/); assert.match(countersModule, /useGroupedReorder/); + assert.match(countersModule, /useDraftForm/); + assert.match(countersModule, /counterDraft\.getFieldProps/); assert.match(countersModule, /orientation: "horizontal"/); assert.match(countersModule, /counter-drag-handle/); assert.match(timerModule, /useGroupedReorder/); diff --git a/website/public/data/site.json b/website/public/data/site.json index 4ac81ce..e386587 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -252,6 +252,12 @@ "decrementLabel": "Retirer une quantité", "incrementLabel": "Ajouter une quantité", "currentQuantityLabel": "Quantité actuelle", + "reorderTitle": "Déplacer", + "reorderSectionTitle": "Déplacer la catégorie", + "sectionEditTitle": "Renommer la catégorie", + "itemEditLabel": "Libellé", + "categoryEditLabel": "Catégorie", + "editTitle": "Modifier", "deleteTitle": "Supprimer" }, "images": { diff --git a/website/src/features/toolboxes/modules/ChecklistModule.jsx b/website/src/features/toolboxes/modules/ChecklistModule.jsx index 8fd9514..2928013 100644 --- a/website/src/features/toolboxes/modules/ChecklistModule.jsx +++ b/website/src/features/toolboxes/modules/ChecklistModule.jsx @@ -1,6 +1,8 @@ // Rôle : fournit l'outil checklist avec quantités, catégories et imports texte. import { useCallback, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { useGroupedReorder, moveItem } from "../../../hooks/useGroupedReorder.js"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { TextImportModal } from "./TextImportModal.jsx"; import { parseColonImportLines } from "./textImport.js"; @@ -15,6 +17,49 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) { const closeImportModal = useCallback(() => setImportOpen(false), []); const namedSections = data.sections.filter((section) => section.title); const completedSectionsMode = data.hideCompletedSectionsFully ? "hidden" : data.hideCompletedSections ? "reduced" : "visible"; + const reorderItems = getChecklistReorderItems(data.sections); + const { + itemReorder, + groupReorder, + getItemProps, + getGroupProps, + getGroupBoundaryProps, + isItemDragging, + isItemDropTarget, + isGroupDragging, + isGroupDropTarget, + isBoundaryDropTarget, + getDropPlacement, + shouldShowGroupBoundaries + } = useGroupedReorder({ + namespace: `checklist-${moduleId}`, + items: reorderItems, + getItemId: (item) => item.id, + getItemGroup: (item) => item.sectionId, + getEffectiveGroup: (item) => item.sectionId, + reorderFeatures: { + item: { groupChange: true, boundaryDrop: true }, + group: { reorder: true, boundaryDrop: true } + }, + onItemMove: (operation) => save(moveChecklistItem(data.sections, operation, context)), + onGroupMove: (operation) => save(moveItem(data.sections, operation.sourceId, operation.targetId, operation.placement)) + }); + const reorder = { + itemReorder, + groupReorder, + getItemProps, + getGroupProps, + getGroupBoundaryProps, + isItemDragging, + isItemDropTarget, + isGroupDragging, + isGroupDropTarget, + isBoundaryDropTarget, + getDropPlacement, + shouldShowGroupBoundaries + }; + const showBoundaryDropZones = reorder.shouldShowGroupBoundaries(); + const visibleSections = data.sections.filter((section) => !(section.title && data.hideCompletedSectionsFully && isSectionComplete(section, context))); function save(sections, settings = {}) { context.setModuleData(toolboxId, moduleId, { ...data, ...settings, sections }); @@ -57,6 +102,26 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) { }); } + function updateSection(sectionId, updater) { + save(data.sections.map((section) => section.id === sectionId ? updater(section) : section).filter(keepChecklistSection)); + } + + function renameSection(sectionId, title) { + save(renameChecklistSection(data.sections, sectionId, title)); + } + + function updateItem(itemId, updater) { + save(updateChecklistItem(data.sections, itemId, updater)); + } + + function moveItemToCategory(itemId, title) { + save(moveChecklistItemToCategory(data.sections, itemId, title, context)); + } + + function deleteItem(itemId) { + save(data.sections.map((section) => ({ ...section, items: section.items.filter((item) => item.id !== itemId) })).filter(keepChecklistSection)); + } + return ( <> {editing && ( @@ -109,9 +174,38 @@ export function ChecklistModule({ toolboxId, moduleId, context, editing }) { )} ); @@ -151,30 +245,142 @@ function getSectionHideWhenComplete(section, globalHide) { return typeof section.hideWhenComplete === "boolean" ? section.hideWhenComplete : globalHide; } -function CategoryTitle({ title }) { - return String(title || "").split(/([♂♀])/g).map((part, index) => ( - part === "♂" || part === "♀" - ? {part} - : part - )); +function keepChecklistSection(section) { + return section.items.length || section.title; } -function ChecklistSection({ section, context, sections, data, save }) { +function getChecklistReorderItems(sections) { + return sections.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id }))); +} + +function findChecklistSectionByItemId(sections, itemId) { + return sections.find((section) => section.items.some((item) => item.id === itemId)); +} + +function renameChecklistSection(sections, sectionId, title) { + const cleanTitle = title.trim(); + const section = sections.find((entry) => entry.id === sectionId); + if (!section) return sections; + const existingSection = sections.find((entry) => entry.id !== sectionId && entry.title.toLowerCase() === cleanTitle.toLowerCase()); + if (!existingSection) { + return sections.map((entry) => entry.id === sectionId ? { ...entry, title: cleanTitle } : entry).filter(keepChecklistSection); + } + return sections.map((entry) => { + if (entry.id === existingSection.id) return { ...entry, items: [...entry.items, ...section.items] }; + return entry; + }).filter((entry) => entry.id !== sectionId).filter(keepChecklistSection); +} + +function updateChecklistItem(sections, itemId, updater) { + return sections.map((section) => ({ + ...section, + items: section.items.map((item) => item.id === itemId ? updater(item) : item).filter((item) => item.label) + })).filter(keepChecklistSection); +} + +function moveChecklistItemToCategory(sections, itemId, title, context) { + const cleanTitle = title.trim(); + const sourceSection = findChecklistSectionByItemId(sections, itemId); + const item = sourceSection?.items.find((entry) => entry.id === itemId); + if (!sourceSection || !item || sourceSection.title.toLowerCase() === cleanTitle.toLowerCase()) return sections; + const targetSection = sections.find((section) => section.title.toLowerCase() === cleanTitle.toLowerCase()); + const sectionsWithoutItem = sections.map((section) => ({ + ...section, + items: section.items.filter((entry) => entry.id !== itemId) + })).filter(keepChecklistSection); + if (targetSection) { + return sectionsWithoutItem.map((section) => section.id === targetSection.id ? { ...section, items: [...section.items, item] } : section); + } + return [...sectionsWithoutItem, { id: context.uid("section"), title: cleanTitle, items: [item] }]; +} + +function moveChecklistItem(sections, operation, context) { + const sourceSection = findChecklistSectionByItemId(sections, operation.sourceId); + const sourceItem = sourceSection?.items.find((item) => item.id === operation.sourceId); + if (!sourceSection || !sourceItem) return sections; + if (operation.targetType === "boundary") return moveChecklistItemToUngroupedBoundary(sections, sourceSection, sourceItem, operation, context); + const targetSection = operation.targetType === "item" + ? findChecklistSectionByItemId(sections, operation.targetId) + : sections.find((section) => section.id === operation.targetGroup); + if (!targetSection) return sections; + const sectionsWithoutItem = sections.map((section) => ({ + ...section, + items: section.items.filter((item) => item.id !== operation.sourceId) + })).filter(keepChecklistSection); + return sectionsWithoutItem.map((section) => { + if (section.id !== targetSection.id) return section; + if (operation.targetType === "item") { + const targetIndex = section.items.findIndex((item) => item.id === operation.targetId); + if (targetIndex < 0) return { ...section, items: [...section.items, sourceItem] }; + const nextItems = [...section.items]; + nextItems.splice(operation.placement === "after" ? targetIndex + 1 : targetIndex, 0, sourceItem); + return { ...section, items: nextItems }; + } + return { + ...section, + items: operation.placement === "after" ? [...section.items, sourceItem] : [sourceItem, ...section.items] + }; + }); +} + +function moveChecklistItemToUngroupedBoundary(sections, sourceSection, sourceItem, operation, context) { + const targetIndex = sections.findIndex((section) => section.id === operation.targetGroup); + if (targetIndex < 0) return sections; + const preferredUngroupedSection = sourceSection.title ? sections.find((section) => !section.title && section.id !== sourceSection.id) : sourceSection; + const ungroupedSectionId = preferredUngroupedSection?.id || context.uid("section"); + const baseSections = sections + .map((section) => ({ + ...section, + items: section.items.filter((item) => item.id !== sourceItem.id) + })) + .filter((section) => section.id !== ungroupedSectionId) + .filter(keepChecklistSection); + const targetSection = sections[targetIndex]; + const nextTargetIndex = baseSections.findIndex((section) => section.id === targetSection.id); + const insertIndex = nextTargetIndex < 0 + ? Math.min(targetIndex, baseSections.length) + : nextTargetIndex + (operation.placement === "after" ? 1 : 0); + const ungroupedSection = { + id: ungroupedSectionId, + title: "", + items: [...(preferredUngroupedSection?.items || []).filter((item) => item.id !== sourceItem.id), sourceItem] + }; + const nextSections = [...baseSections]; + nextSections.splice(insertIndex, 0, ungroupedSection); + return nextSections; +} + +function ChecklistSection({ section, context, data, reorder, onRenameSection, onUpdateSection, onSaveSections, onUpdateItem, onMoveItemToCategory, onDeleteItem }) { 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; + const textContent = context.moduleText?.checklist || {}; + const className = [ + "checklist-section", + showTitle ? "is-grouped" : "", + sectionComplete ? "is-complete" : "", + isCollapsed ? "is-collapsed" : "", + reorder.isGroupDragging(section.id) ? "is-dragging" : "", + reorder.isGroupDropTarget(section.id) ? "is-drop-target" : "", + reorder.getDropPlacement("group", section.id) === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + const titleEdit = useInlineEdit({ + value: section.title, + onCommit: (title) => onRenameSection(section.id, title) + }); + const [editingItemId, setEditingItemId] = useState(""); if (sectionComplete && data.hideCompletedSectionsFully) return null; function saveItems(items) { - save(sections.map((entry) => entry.id === section.id ? { ...entry, items } : entry).filter((entry) => entry.items.length || entry.title)); + onUpdateSection(section.id, (entry) => ({ ...entry, items })); } function toggleSectionCollapsed() { const nextCollapsed = !isCollapsed; - const nextSections = sections.map((entry) => { + const nextSections = data.sections.map((entry) => { if (entry.id === section.id) { const nextSection = { ...entry }; if (nextCollapsed) { @@ -195,23 +401,40 @@ function ChecklistSection({ section, context, sections, data, save }) { }); 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 }); + onSaveSections(nextSections, { hideCompletedSections: allHidden }); } return ( -
  • +
  • {showTitle && (
    -

    -
    + +

    + +

    +
    {section.items.filter((item) => context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget).length} / {section.items.length} @@ -221,7 +444,20 @@ function ChecklistSection({ section, context, sections, data, save }) { {!isCollapsed && (
      {section.items.map((item) => ( - + setEditingItemId(editingItemId === item.id ? "" : item.id)} + /> ))}
    )} @@ -229,16 +465,51 @@ function ChecklistSection({ section, context, sections, data, save }) { ); } -function ChecklistItem({ item, context, items, save }) { +function ChecklistItem({ item, section, context, reorder, items, save, onUpdateItem, onMoveItemToCategory, onDeleteItem, editing, onToggleEditing }) { const done = context.clampQty(item.qtyCurrent, item.qtyTarget) >= item.qtyTarget; + const textContent = context.moduleText?.checklist || {}; + const className = [ + "checklist-item", + done ? "is-complete" : "", + reorder.isItemDragging(item.id) ? "is-dragging" : "", + reorder.isItemDropTarget(item.id) ? "is-drop-target" : "", + reorder.getDropPlacement("item", item.id) === "after" ? "drop-after" : "" + ].filter(Boolean).join(" "); + const labelEdit = useInlineEdit({ + value: item.label, + transform: (draft) => String(draft || "").trim() || item.label, + onCommit: (label) => onUpdateItem(item.id, (entry) => ({ ...entry, label })), + autoFocus: editing, + focusKey: item.id + }); + const categoryEdit = useInlineEdit({ + value: section.title, + onCommit: (title) => onMoveItemToCategory(item.id, title) + }); function updateItem(updater) { save(items.map((entry) => entry.id === item.id ? updater(entry) : entry)); } + function toggleChecked() { + updateItem((entry) => ({ + ...entry, + qtyCurrent: done ? 0 : entry.qtyTarget + })); + } + return ( -
  • -
    +
  • +
    + {item.qtyTarget === 1 ? ( updateItem((entry) => ({ ...entry, qtyCurrent: context.clampQty(entry.qtyCurrent + 1, entry.qtyTarget) }))} aria-label={context.moduleText?.checklist?.incrementLabel || "Ajouter une quantité"}>+
    )} - {item.label} + {editing ? ( + <> + + + + ) : ( + + )} - +
  • diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx index 443be8c..b8b3143 100644 --- a/website/src/features/toolboxes/modules/CombosModule.jsx +++ b/website/src/features/toolboxes/modules/CombosModule.jsx @@ -553,9 +553,10 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { ); } - function renderCategory(group) { + function renderCategory(group, index, groupCount) { const category = group.category; const isCollapsed = data.collapsedCategories.includes(category); + const isLastCategory = index === groupCount - 1; const categoryClassName = [ "combos-category checklist-section is-grouped", isCollapsed ? "is-collapsed" : "", @@ -614,10 +615,19 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { )} , - afterDropZone + showBoundaryDropZones && isLastCategory ? afterDropZone : null ]; } + const categoryCount = entries.filter((entry) => entry.type === "group").length; + let renderedCategoryIndex = -1; + + function renderEntry(entry) { + if (entry.type !== "group") return renderCombo(entry.item); + renderedCategoryIndex += 1; + return renderCategory(entry.group, renderedCategoryIndex, categoryCount); + } + return (
    {(editing || editingComboId) && ( @@ -738,7 +748,7 @@ export function CombosModule({ toolboxId, moduleId, context, editing }) { )}
    - {entries.map((entry) => entry.type === "group" ? renderCategory(entry.group) : renderCombo(entry.item))} + {entries.map(renderEntry)}
    ); diff --git a/website/src/features/toolboxes/modules/CountersModule.jsx b/website/src/features/toolboxes/modules/CountersModule.jsx index 3d3a0e9..32517ff 100644 --- a/website/src/features/toolboxes/modules/CountersModule.jsx +++ b/website/src/features/toolboxes/modules/CountersModule.jsx @@ -1,12 +1,12 @@ // Rôle : fournit l'outil compteurs personnalisables. -import { useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; export function CountersModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeCountersData(context.getModuleData(toolboxId, moduleId, { counters: [] })); const textContent = context.moduleText?.counters || {}; - const [label, setLabel] = useState(""); + const counterDraft = useDraftForm({ label: "" }); const { itemReorder, getItemProps, @@ -28,12 +28,13 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) { } function addCounter(event) { - event.preventDefault(); - const cleanLabel = label.trim(); - if (!cleanLabel) return; + counterDraft.handleSubmit(event, (draft, { reset }) => { + const cleanLabel = draft.label.trim(); + if (!cleanLabel) return; - save([...data.counters, { id: context.uid("counter"), label: cleanLabel, value: 0 }]); - setLabel(""); + save([...data.counters, { id: context.uid("counter"), label: cleanLabel, value: 0 }]); + reset(); + }); } function updateCounter(counterId, updater) { @@ -44,7 +45,7 @@ export function CountersModule({ toolboxId, moduleId, context, editing }) { <> {editing && (
    - setLabel(event.target.value)} /> +
    )} diff --git a/website/src/features/toolboxes/modules/LinksModule.jsx b/website/src/features/toolboxes/modules/LinksModule.jsx index 95d9d1f..fb47bcc 100644 --- a/website/src/features/toolboxes/modules/LinksModule.jsx +++ b/website/src/features/toolboxes/modules/LinksModule.jsx @@ -1,6 +1,7 @@ // Rôle : fournit l'outil liens avec ajout manuel et import texte. import { useCallback, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; +import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { TextImportModal } from "./TextImportModal.jsx"; import { parseColonImportLines } from "./textImport.js"; @@ -8,8 +9,7 @@ import { parseColonImportLines } from "./textImport.js"; export function LinksModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeLinksData(context.getModuleData(toolboxId, moduleId, { links: [] })); const textContent = context.moduleText?.links || {}; - const [title, setTitle] = useState(""); - const [url, setUrl] = useState(""); + const linkDraft = useDraftForm({ title: "", url: "" }); const [textImport, setTextImport] = useState(""); const [importOpen, setImportOpen] = useState(false); const [copiedId, setCopiedId] = useState(""); @@ -34,20 +34,20 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) { } function addLink(event) { - event.preventDefault(); - const cleanUrl = context.normalizeUrl(url); - if (!cleanUrl) return; + linkDraft.handleSubmit(event, (draft, { reset }) => { + const cleanUrl = context.normalizeUrl(draft.url); + if (!cleanUrl) return; - save([ - ...data.links, - { - id: context.uid("link"), - title: title.trim(), - url: cleanUrl - } - ]); - setTitle(""); - setUrl(""); + save([ + ...data.links, + { + id: context.uid("link"), + title: draft.title.trim(), + url: cleanUrl + } + ]); + reset(); + }); } function importLinks(event) { @@ -79,8 +79,8 @@ export function LinksModule({ toolboxId, moduleId, context, editing }) { {editing && (
    - setTitle(event.target.value)} /> - setUrl(event.target.value)} /> + +
    diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx index 2ecac72..67381a2 100644 --- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx +++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx"; +import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; @@ -222,8 +223,7 @@ function getRootTaskIdsForCategory(tasks, parentMap, category) { export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { const data = context.normalizeTaskPlannerData(context.getModuleData(toolboxId, moduleId, { tasks: [] })); const textContent = context.moduleText?.taskPlanner || {}; - const [title, setTitle] = useState(""); - const [type, setType] = useState("daily"); + const taskDraft = useDraftForm({ title: "", type: "daily" }); const [nowMs, setNowMs] = useState(() => Date.now()); const [settingsOpen, setSettingsOpen] = useState(false); const [openDescriptions, setOpenDescriptions] = useState(() => new Set()); @@ -358,25 +358,25 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) { }); function addTask(event) { - event.preventDefault(); - const cleanTitle = title.trim(); - if (!cleanTitle) return; - save({ - ...data, - tasks: [ - ...data.tasks, - { - id: context.uid("task"), - title: cleanTitle, - description: "", - type, - checked: false, - checkedAt: 0 - } - ] + taskDraft.handleSubmit(event, (draft, { reset }) => { + const cleanTitle = draft.title.trim(); + if (!cleanTitle) return; + save({ + ...data, + tasks: [ + ...data.tasks, + { + id: context.uid("task"), + title: cleanTitle, + description: "", + type: draft.type, + checked: false, + checkedAt: 0 + } + ] + }); + reset(); }); - setTitle(""); - setType("daily"); } function updateTask(taskId, updater) { @@ -509,8 +509,8 @@ export function TaskPlannerModule({ toolboxId, moduleId, context, editing }) {
    {editing && (
    - setTitle(event.target.value)} /> - + diff --git a/website/src/features/toolboxes/modules/TimerModule.jsx b/website/src/features/toolboxes/modules/TimerModule.jsx index 449c28c..d3cf3fd 100644 --- a/website/src/features/toolboxes/modules/TimerModule.jsx +++ b/website/src/features/toolboxes/modules/TimerModule.jsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Icon } from "../../../components/Icon.jsx"; import { Tabs } from "../../../components/Tabs.jsx"; +import { useDraftForm } from "../../../hooks/useDraftForm.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { @@ -24,6 +25,15 @@ const TIMER_TABS = [ ]; const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"]; +const EMPTY_TIME_PARTS = { hours: "", minutes: "", seconds: "" }; +const DEFAULT_COUNTDOWN_FORM = { + label: "", + type: "duration", + duration: EMPTY_TIME_PARTS, + time: EMPTY_TIME_PARTS, + pattern: EMPTY_TIME_PARTS, + interval: EMPTY_TIME_PARTS +}; const ALERT_MODES = [ { mode: "off", icon: "sound-mute" }, { mode: "visible", icon: "sound-min" }, @@ -73,14 +83,7 @@ export function TimerModule({ toolboxId, moduleId, context }) { const textContent = context.moduleText?.timer || {}; const activeTab = data.activeTab; const [nowMs, setNowMs] = useState(getNowMs); - const [countdownForm, setCountdownForm] = useState({ - label: "", - type: "duration", - duration: { hours: "", minutes: "", seconds: "" }, - time: { hours: "", minutes: "", seconds: "" }, - pattern: { hours: "", minutes: "", seconds: "" }, - interval: { hours: "", minutes: "", seconds: "" } - }); + const countdownDraft = useDraftForm(DEFAULT_COUNTDOWN_FORM); useEffect(() => { const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50); @@ -172,38 +175,35 @@ export function TimerModule({ toolboxId, moduleId, context }) { }); } - function updateCountdownForm(patch) { - setCountdownForm((current) => ({ ...current, ...patch })); - } - function createCountdown(event) { - event.preventDefault(); - const type = COUNTDOWN_TYPES.includes(countdownForm.type) ? countdownForm.type : "duration"; - const label = countdownForm.label.trim() || textContent.defaultCountdownLabel || "Timer"; - const now = getNowMs(); - let countdown = null; + countdownDraft.handleSubmit(event, (draft, { updateValues }) => { + const type = COUNTDOWN_TYPES.includes(draft.type) ? draft.type : "duration"; + const label = draft.label.trim() || textContent.defaultCountdownLabel || "Timer"; + const now = getNowMs(); + let countdown = null; - if (type === "duration") { - const durationMs = timePartsToDurationMs(countdownForm.duration); - if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs }; - } - if (type === "daily_time") { - const time = timePartsToString(countdownForm.time); - const targetAt = getDailyTargetMs(time, now); - if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt }; - } - if (type === "time_pattern") { - const pattern = timePartsToString(countdownForm.pattern, { allowWildcard: true }); - if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern }; - } - if (type === "interval") { - const intervalMs = timePartsToDurationMs(countdownForm.interval); - if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now }; - } + if (type === "duration") { + const durationMs = timePartsToDurationMs(draft.duration); + if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs }; + } + if (type === "daily_time") { + const time = timePartsToString(draft.time); + const targetAt = getDailyTargetMs(time, now); + if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt }; + } + if (type === "time_pattern") { + const pattern = timePartsToString(draft.pattern, { allowWildcard: true }); + if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern }; + } + if (type === "interval") { + const intervalMs = timePartsToDurationMs(draft.interval); + if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now }; + } - if (!countdown) return; - save({ ...data, countdowns: [...data.countdowns, countdown] }); - setCountdownForm((current) => ({ ...current, label: "" })); + if (!countdown) return; + save({ ...data, countdowns: [...data.countdowns, countdown] }); + updateValues({ label: "" }); + }); } function deleteCountdown(countdownId) { @@ -274,8 +274,8 @@ export function TimerModule({ toolboxId, moduleId, context }) { ) : ( )} diff --git a/website/src/hooks/useDraftForm.js b/website/src/hooks/useDraftForm.js new file mode 100644 index 0000000..93f3aa3 --- /dev/null +++ b/website/src/hooks/useDraftForm.js @@ -0,0 +1,47 @@ +// Rôle : centralise les petits formulaires contrôlés avec brouillon local. +// Fournit helpers de champs, submit preventDefault et reset, sans porter de validation métier. +// À utiliser quand plusieurs champs sont simplement saisis puis nettoyés au submit. +import { useState } from "react"; + +export function useDraftForm(initialValues) { + const [values, setValues] = useState(initialValues); + + function setField(name, value) { + setValues((current) => ({ ...current, [name]: value })); + } + + function updateValues(patch) { + setValues((current) => ({ ...current, ...patch })); + } + + function reset(nextValues = initialValues) { + setValues(nextValues); + } + + function getFieldProps(name, props = {}) { + return { + ...props, + name: props.name || name, + value: values[name] ?? "", + onChange: (event) => { + setField(name, event.target.value); + props.onChange?.(event); + } + }; + } + + function handleSubmit(event, onSubmit) { + event.preventDefault(); + onSubmit(values, { reset, setField, setValues, updateValues }); + } + + return { + values, + setField, + setValues, + updateValues, + reset, + getFieldProps, + handleSubmit + }; +} diff --git a/website/src/styles/_toolboxes.scss b/website/src/styles/_toolboxes.scss index cffd1d4..6659c90 100644 --- a/website/src/styles/_toolboxes.scss +++ b/website/src/styles/_toolboxes.scss @@ -470,13 +470,35 @@ [data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target, [data-reorder-target="group"][data-reorder-orientation="vertical"].is-drop-target { + position: relative; border-color: rgba(246, 196, 83, 0.72); - box-shadow: var(--reorder-drop-shadow, none), inset 0 3px 0 rgba(246, 196, 83, 0.8); + box-shadow: var(--reorder-drop-shadow, none); +} + +[data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target::before, +[data-reorder-target="group"][data-reorder-orientation="vertical"].is-drop-target::before { + position: absolute; + z-index: 3; + top: 0; + right: 0; + left: 0; + height: 3px; + border-radius: var(--radius-pill); + background: rgba(246, 196, 83, 0.9); + box-shadow: 0 0 12px rgba(246, 196, 83, 0.24); + content: ""; + pointer-events: none; } [data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target.drop-after, [data-reorder-target="group"][data-reorder-orientation="vertical"].is-drop-target.drop-after { - box-shadow: var(--reorder-drop-shadow, none), inset 0 -3px 0 rgba(246, 196, 83, 0.8); + box-shadow: var(--reorder-drop-shadow, none); +} + +[data-reorder-target="item"][data-reorder-orientation="vertical"].is-drop-target.drop-after::before, +[data-reorder-target="group"][data-reorder-orientation="vertical"].is-drop-target.drop-after::before { + top: auto; + bottom: 0; } [data-reorder-target="item"][data-reorder-orientation="horizontal"].is-drop-target, @@ -1441,7 +1463,12 @@ textarea:focus { padding: 0 2px; } -.checklist-section-header > div { +.checklist-section-header > h3 { + flex: 1 1 auto; + min-width: 0; +} + +.checklist-section-actions { display: inline-flex; flex: 0 0 auto; align-items: center; @@ -1454,18 +1481,22 @@ textarea:focus { font-size: var(--font-size-sm); } -.checklist-gender-symbol { - display: inline-block; - margin-left: 3px; - color: currentColor; - font-family: "Segoe UI Symbol", "Noto Sans Symbols 2", "Noto Sans Symbols", "Apple Symbols", sans-serif; - font-size: 1.18em; - font-weight: 900; - line-height: 1; - text-shadow: - 0 0 0 currentColor, - 0 0 4px rgba(246, 196, 83, 0.22); - transform: translateY(1px); +.checklist-section-title-input { + width: 100%; + min-height: 30px; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-accent-gold); + font: inherit; + outline: none; +} + +.checklist-section-title-input:hover, +.checklist-section-title-input:focus { + border-color: rgba(246, 196, 83, 0.34); + background: rgba(21, 26, 48, 0.34); } .checklist-section.is-complete .checklist-section-header h3 { @@ -1473,7 +1504,12 @@ textarea:focus { text-decoration: line-through; } -.checklist-section-header > div > span { +.checklist-section.is-complete .checklist-section-title-input { + color: var(--color-text-muted); + text-decoration: line-through; +} + +.checklist-section-actions > span { flex: 0 0 auto; padding: 3px 9px; border: 1px solid rgba(246, 196, 83, 0.22); @@ -1573,15 +1609,29 @@ textarea:focus { list-style: none; } +.checklist-category-boundary-drop-zone { + min-height: 10px; + border: 1px dashed rgba(165, 180, 252, 0.16); + border-radius: var(--radius-sm); + background: rgba(15, 23, 42, 0.12); +} + +.checklist-category-boundary-drop-zone.is-drop-target { + border-color: rgba(246, 196, 83, 0.72); + background: rgba(246, 196, 83, 0.08); + box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.24); +} + .checklist-item { display: grid; - grid-template-columns: 1fr 42px; + grid-template-columns: 1fr 42px 42px; gap: 8px; align-items: center; } .checklist-item-main { - display: flex; + display: grid; + grid-template-columns: 30px auto minmax(0, 1fr); min-height: 42px; align-items: center; gap: 10px; @@ -1591,11 +1641,129 @@ textarea:focus { background: rgba(5, 7, 17, 0.44); } +.checklist-item-main:hover { + border-color: rgba(196, 181, 253, 0.22); + background: rgba(10, 13, 30, 0.54); +} + +.checklist-item-main.is-editing { + grid-template-columns: 30px auto minmax(96px, 1fr) minmax(84px, 0.45fr); +} + +.checklist-drag-handle { + display: inline-grid; + width: 30px; + min-width: 30px; + min-height: 30px; + place-items: center; + padding: 0; + border: 1px solid rgba(165, 180, 252, 0.1); + border-radius: var(--radius-md); + background: rgba(21, 26, 48, 0.76); + color: var(--color-text-secondary); + cursor: grab; + touch-action: none; + user-select: none; +} + +.checklist-drag-handle .ui-icon { + width: 15px; + height: 15px; +} + +.checklist-drag-handle:active { + cursor: grabbing; +} + +.checklist-section.is-drop-target { + border-color: rgba(246, 196, 83, 0.72); +} + +.checklist-item.is-dragging .checklist-item-main { + opacity: 0.58; +} + +.checklist-label-input, +.checklist-category-input { + min-width: 0; + width: 100%; + min-height: 30px; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-primary); +} + +.checklist-category-input { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); +} + +.checklist-label-input:hover, +.checklist-label-input:focus, +.checklist-category-input:hover, +.checklist-category-input:focus { + border-color: rgba(246, 196, 83, 0.34); + background: rgba(21, 26, 48, 0.42); +} + +.checklist-item-summary { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + justify-content: flex-start; + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-primary); + text-align: left; + box-shadow: none; +} + +.checklist-item-summary:hover { + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.checklist-item-summary span { + min-width: 0; + overflow: hidden; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.checklist-edit-button { + display: inline-grid; + width: 40px; + min-width: 40px; + min-height: 40px; + place-items: center; + padding: 0; + border: 1px solid rgba(165, 180, 252, 0.1); + border-radius: var(--radius-md); + background: rgba(21, 26, 48, 0.76); + color: var(--color-text-secondary); +} + +.checklist-edit-button .ui-icon { + width: 17px; + height: 17px; +} + .checklist input[type="checkbox"] { flex: 0 0 auto; } -.checklist-item.is-complete .checklist-item-main > span { +.checklist-item.is-complete .checklist-label-input { + color: var(--color-text-muted); + text-decoration: line-through; +} + +.checklist-item.is-complete .checklist-item-summary span { color: var(--color-text-muted); text-decoration: line-through; } @@ -1946,11 +2114,10 @@ textarea:focus { .task-planner-category-section.is-drop-target { border-color: rgba(246, 196, 83, 0.72); - box-shadow: inset 0 3px 0 rgba(246, 196, 83, 0.8); } .task-planner-category-section.is-drop-target.drop-after { - box-shadow: inset 0 -3px 0 rgba(246, 196, 83, 0.8); + border-color: rgba(246, 196, 83, 0.72); } .task-planner-item { @@ -2459,15 +2626,16 @@ textarea:focus { } .combos-category-boundary-drop-zone { - min-height: 6px; - border-radius: 999px; + min-height: 10px; + border: 1px dashed rgba(165, 180, 252, 0.16); + border-radius: var(--radius-sm); + background: rgba(15, 23, 42, 0.12); } .combos-category-boundary-drop-zone.is-drop-target { - min-height: 12px; border: 1px dashed rgba(246, 196, 83, 0.72); - background: rgba(246, 196, 83, 0.12); - box-shadow: inset 0 2px 0 rgba(246, 196, 83, 0.74); + background: rgba(246, 196, 83, 0.08); + box-shadow: inset 0 0 0 1px rgba(246, 196, 83, 0.24); } .combos-list { @@ -3736,6 +3904,8 @@ button.combo-input-token.combo-input-mouse { .tool-split-root-button:hover, .tool-split-action-button:hover:not(:disabled), .checklist-qty-controls button:hover, +.checklist-drag-handle:hover, +.checklist-edit-button:hover, .tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value, .danger):hover, .counter-actions button:not(.danger):hover, .link-item button:not(.danger):hover,