Refactor inline editing into shared hook
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s

This commit is contained in:
Shinuwa 2026-08-02 11:27:26 +02:00
parent 1879b245fb
commit 1e4ac4259b
9 changed files with 239 additions and 205 deletions

View file

@ -14,6 +14,7 @@ test("toolbox storage, cards and pages are wired", async () => {
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8");
assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/); assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/);
assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/); assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/);
@ -55,6 +56,8 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/); assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/);
assert.match(toolboxPages, /updateToolboxOrder/); assert.match(toolboxPages, /updateToolboxOrder/);
assert.match(toolboxPages, /useGroupedReorder/); assert.match(toolboxPages, /useGroupedReorder/);
assert.match(toolboxPages, /useInlineEdit/);
assert.match(toolboxPages, /getContentEditableProps/);
assert.match(toolboxPages, /orientation: "horizontal"/); assert.match(toolboxPages, /orientation: "horizontal"/);
assert.match(toolboxPages, /components\/StorageQuota\.jsx/); assert.match(toolboxPages, /components\/StorageQuota\.jsx/);
assert.match(toolboxPages, /components\/ImportButton\.jsx/); assert.match(toolboxPages, /components\/ImportButton\.jsx/);
@ -82,6 +85,11 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/); assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
assert.match(groupedReorderHook, /data-reorder-orientation/); assert.match(groupedReorderHook, /data-reorder-orientation/);
assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/); assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/);
assert.match(inlineEditHook, /export function useInlineEdit/);
assert.match(inlineEditHook, /skipCommitRef/);
assert.match(inlineEditHook, /blurOnEscape/);
assert.match(inlineEditHook, /commitOnEnter/);
assert.match(inlineEditHook, /getContentEditableProps/);
}); });
test("toolbox module registry and modules expose expected behavior", async () => { test("toolbox module registry and modules expose expected behavior", async () => {
@ -126,6 +134,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /itemReorder\.startDrag/); assert.match(moduleRegistry, /itemReorder\.startDrag/);
assert.match(moduleRegistry, /onPointerDown/); assert.match(moduleRegistry, /onPointerDown/);
assert.match(moduleRegistry, /useGroupedReorder/); assert.match(moduleRegistry, /useGroupedReorder/);
assert.match(moduleRegistry, /useInlineEdit/);
assert.match(moduleRegistry, /getContentEditableProps/);
assert.match(moduleRegistry, /tool-add-card/); assert.match(moduleRegistry, /tool-add-card/);
assert.match(moduleRegistry, /tool-quick-add-button/); assert.match(moduleRegistry, /tool-quick-add-button/);
assert.match(moduleRegistry, /tool-add-quick-toggle/); assert.match(moduleRegistry, /tool-add-quick-toggle/);
@ -143,6 +153,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(combosModule, /ComboSequence/); assert.match(combosModule, /ComboSequence/);
assert.match(combosModule, /keyboardLayout/); assert.match(combosModule, /keyboardLayout/);
assert.match(combosModule, /simultaneousMode/); assert.match(combosModule, /simultaneousMode/);
assert.match(combosModule, /useInlineEdit/);
assert.match(combosModule, /useGroupedReorder/); assert.match(combosModule, /useGroupedReorder/);
assert.match(combosModule, /reorderFeatures/); assert.match(combosModule, /reorderFeatures/);
assert.doesNotMatch(combosModule, /canMoveItem:/); assert.doesNotMatch(combosModule, /canMoveItem:/);
@ -151,6 +162,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(calculatorModule, /calculateExpression/); assert.match(calculatorModule, /calculateExpression/);
assert.match(calculatorModule, /activeParentId/); assert.match(calculatorModule, /activeParentId/);
assert.match(calculatorModule, /parentId/); assert.match(calculatorModule, /parentId/);
assert.match(calculatorModule, /useInlineEdit/);
assert.match(calculatorModule, /useGroupedReorder/); assert.match(calculatorModule, /useGroupedReorder/);
assert.match(calculatorModule, /calculator-drag-handle/); assert.match(calculatorModule, /calculator-drag-handle/);
assert.match(calculatorModule, /scrollResults/); assert.match(calculatorModule, /scrollResults/);
@ -186,6 +198,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(tableModule, /ArrowLeft/); assert.match(tableModule, /ArrowLeft/);
assert.match(tableModule, /ArrowRight/); assert.match(tableModule, /ArrowRight/);
assert.match(tableModule, /function HeaderLabelInput/); assert.match(tableModule, /function HeaderLabelInput/);
assert.match(tableModule, /useInlineEdit/);
assert.match(tableModule, /event\.target\.select/); assert.match(tableModule, /event\.target\.select/);
assert.match(tableFormulaEngine, /export function evaluateTableCell/); assert.match(tableFormulaEngine, /export function evaluateTableCell/);
assert.doesNotMatch(tableFormulaEngine, /Function\(/); assert.doesNotMatch(tableFormulaEngine, /Function\(/);
@ -194,6 +207,7 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(timerModule, /Tabs/); assert.match(timerModule, /Tabs/);
assert.match(timerModule, /stopwatch/); assert.match(timerModule, /stopwatch/);
assert.match(timerModule, /countdown/); assert.match(timerModule, /countdown/);
assert.match(timerModule, /useInlineEdit/);
assert.match(timerModule, /normalizeTimerData/); assert.match(timerModule, /normalizeTimerData/);
assert.match(taskPlannerModule, /export function TaskPlannerModule/); assert.match(taskPlannerModule, /export function TaskPlannerModule/);
assert.match(taskPlannerModule, /normalizeTaskPlannerData/); assert.match(taskPlannerModule, /normalizeTaskPlannerData/);
@ -204,6 +218,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(taskPlannerModule, /categoryLabel/); assert.match(taskPlannerModule, /categoryLabel/);
assert.match(taskPlannerModule, /task-planner-warning/); assert.match(taskPlannerModule, /task-planner-warning/);
assert.match(taskPlannerModule, /useGroupedReorder/); assert.match(taskPlannerModule, /useGroupedReorder/);
assert.match(taskPlannerModule, /useInlineEdit/);
assert.match(taskPlannerModule, /commitOnEnter: false/);
assert.match(taskPlannerModule, /reorderFeatures/); assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/); assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/); assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);

View file

@ -5,6 +5,7 @@ import { ImportButton } from "../../components/ImportButton.jsx";
import { StorageQuota } from "../../components/StorageQuota.jsx"; import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx"; import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js"; import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../hooks/useInlineEdit.js";
import { compressImage } from "../../utils/imageCompression.js"; import { compressImage } from "../../utils/imageCompression.js";
import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js"; import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js";
import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx"; import { getGameCardCover, ToolboxCard, ToolboxGameIcon, ToolboxIconPicker } from "./ToolboxCard.jsx";
@ -324,31 +325,18 @@ export function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, act
} }
function EditableTitle({ value, fallback, onSave, className = "module-title" }) { function EditableTitle({ value, fallback, onSave, className = "module-title" }) {
const ref = useRef(null); const inlineEdit = useInlineEdit({
useEffect(() => { value,
if (ref.current && document.activeElement !== ref.current) ref.current.textContent = value; onCommit: (title) => onSave(title || fallback),
}, [value]); blurOnEscape: true
});
return ( return (
<h1 <h1
ref={ref} {...inlineEdit.getContentEditableProps({
className={className} className,
contentEditable title: "Cliquer pour renommer"
suppressContentEditableWarning })}
spellCheck="false"
title="Cliquer pour renommer"
onFocus={(event) => { event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }}
onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value;
event.currentTarget.blur();
}
}}
>{value}</h1> >{value}</h1>
); );
} }

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence. // Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
function calculateExpression(expression) { function calculateExpression(expression) {
@ -243,34 +244,20 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
} }
function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) { function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
const [label, setLabel] = useState(entry.label); const inlineEdit = useInlineEdit({
const inputRef = useRef(null); value: entry.label,
onCommit: (label) => onRename(entry.id, label),
useLayoutEffect(() => { onDone,
inputRef.current?.focus(); autoFocus: true,
}, [entry.id]); focusKey: entry.id
});
function saveLabel() {
const cleanLabel = label.trim();
if (cleanLabel !== entry.label) onRename(entry.id, cleanLabel);
onDone();
}
return ( return (
<input <input
ref={inputRef} {...inlineEdit.getInputProps({
className="tool-split-entry-label" className: "tool-split-entry-label",
value={label} "aria-label": `${textContent.renameTitle || "Renommer"} ${entry.label}`
onChange={(event) => setLabel(event.target.value)} })}
onBlur={saveLabel}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setLabel(entry.label);
onDone();
}
}}
aria-label={`${textContent.renameTitle || "Renommer"} ${entry.label}`}
/> />
); );
} }

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique. // Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { import {
applyGroupedReorderOperation, applyGroupedReorderOperation,
getGroupedEntries, getGroupedEntries,
@ -240,32 +241,19 @@ function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, onSel
} }
function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) { function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) {
const [draft, setDraft] = useState(value); const inlineEdit = useInlineEdit({
value,
useEffect(() => { onCommit,
setDraft(value); blurOnEscape: true
}, [value]); });
function commit() {
const cleanDraft = draft.trim();
if (cleanDraft !== value) onCommit(cleanDraft);
}
return ( return (
<input <input
className={className} {...inlineEdit.getInputProps({
value={draft} className,
placeholder={placeholder} placeholder,
onChange={(event) => setDraft(event.target.value)} "aria-label": ariaLabel
onBlur={commit} })}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(value);
event.currentTarget.blur();
}
}}
aria-label={ariaLabel}
/> />
); );
} }

View file

@ -1,6 +1,7 @@
// Rôle : fournit l'outil tableau avec cellules libres et formules simples. // Rôle : fournit l'outil tableau avec cellules libres et formules simples.
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js"; import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
const DEFAULT_ROWS = 10; const DEFAULT_ROWS = 10;
@ -597,46 +598,23 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
} }
function HeaderLabelInput({ className, value, fallback, onSave, ...props }) { function HeaderLabelInput({ className, value, fallback, onSave, ...props }) {
const [draft, setDraft] = useState(value); const inlineEdit = useInlineEdit({
const [focused, setFocused] = useState(false); value,
const skipSaveRef = useRef(false); onCommit: onSave,
transform: (draft) => String(draft || ""),
useEffect(() => { blurOnEscape: true
if (!focused) setDraft(value); });
}, [focused, value]);
function saveDraft() {
setFocused(false);
if (skipSaveRef.current) {
skipSaveRef.current = false;
setDraft(value);
return;
}
onSave(draft);
}
return ( return (
<input <input
{...props} {...inlineEdit.getInputProps({
className={className} ...props,
value={focused ? draft : value} className,
onFocus={(event) => { onFocus: (event) => {
setFocused(true); if (value === fallback) window.requestAnimationFrame(() => event.target.select());
setDraft(value); },
if (value === fallback) window.requestAnimationFrame(() => event.target.select()); spellCheck: "false"
}} })}
onChange={(event) => setDraft(event.target.value)}
onBlur={saveDraft}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
skipSaveRef.current = true;
setDraft(value);
setFocused(false);
event.currentTarget.blur();
}
}}
spellCheck="false"
/> />
); );
} }

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx"; import { InlineNotice } from "../../../components/AppOverlays.jsx";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
const TASK_TYPES = ["daily", "weekly", "unique"]; const TASK_TYPES = ["daily", "weekly", "unique"];
const WEEK_DAYS = [ const WEEK_DAYS = [
@ -770,13 +771,10 @@ function TaskPlannerItem({
onSetTaskCategory, onSetTaskCategory,
children children
}) { }) {
const [titleDraft, setTitleDraft] = useState(task.title);
const [descriptionDraft, setDescriptionDraft] = useState(task.description);
const parentTask = parentId ? getTaskById(tasks, parentId) : null; const parentTask = parentId ? getTaskById(tasks, parentId) : null;
const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : ""; const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
const categoryValue = parentTask ? inheritedCategory : task.category || ""; const categoryValue = parentTask ? inheritedCategory : task.category || "";
const categoryEditable = !parentTask; const categoryEditable = !parentTask;
const [categoryDraft, setCategoryDraft] = useState(categoryValue);
const relations = data.relations.filter((relation) => relation.fromTaskId === task.id); const relations = data.relations.filter((relation) => relation.fromTaskId === task.id);
const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked); const missingPrerequisites = relations.filter((relation) => relation.prerequisite && !tasks.find((item) => item.id === relation.toTaskId)?.checked);
const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id)); const relationTargets = tasks.filter((item) => item.id !== task.id && !relations.some((relation) => relation.toTaskId === item.id));
@ -789,6 +787,22 @@ function TaskPlannerItem({
reorder.isItemDropTarget(task.id) ? "is-drop-target" : "", reorder.isItemDropTarget(task.id) ? "is-drop-target" : "",
reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : "" reorder.getDropPlacement("item", task.id) === "after" ? "drop-after" : ""
].filter(Boolean).join(" "); ].filter(Boolean).join(" ");
const titleEdit = useInlineEdit({
value: task.title,
transform: (draft) => String(draft || "").trim() || task.title,
onCommit: (title) => onUpdateTask(task.id, (current) => ({ ...current, title }))
});
const categoryEdit = useInlineEdit({
value: categoryValue,
onCommit: (category) => {
if (categoryEditable) onSetTaskCategory(task.id, category);
}
});
const descriptionEdit = useInlineEdit({
value: task.description,
onCommit: (description) => onUpdateTask(task.id, (current) => ({ ...current, description })),
commitOnEnter: false
});
function updateChecked(event) { function updateChecked(event) {
const checked = event.target.checked; const checked = event.target.checked;
@ -800,34 +814,6 @@ function TaskPlannerItem({
})); }));
} }
useEffect(() => {
setCategoryDraft(categoryValue);
}, [categoryValue, task.id]);
useEffect(() => {
setTitleDraft(task.title);
}, [task.id, task.title]);
useEffect(() => {
setDescriptionDraft(task.description);
}, [task.description, task.id]);
function commitTitleDraft() {
const title = titleDraft.trim() || task.title;
if (title !== task.title) onUpdateTask(task.id, (current) => ({ ...current, title }));
else if (titleDraft !== title) setTitleDraft(title);
}
function commitDescriptionDraft() {
const description = descriptionDraft.trim();
if (description !== task.description) onUpdateTask(task.id, (current) => ({ ...current, description }));
else if (descriptionDraft !== description) setDescriptionDraft(description);
}
function commitCategoryDraft() {
if (categoryEditable && categoryDraft.trim() !== (task.category || "")) onSetTaskCategory(task.id, categoryDraft);
}
return ( return (
<li className={className} {...reorder.getItemProps({ itemId: task.id, groupId: getTaskCategoryDataset(task, tasks, parentMap), parentId })}> <li className={className} {...reorder.getItemProps({ itemId: task.id, groupId: getTaskCategoryDataset(task, tasks, parentMap), parentId })}>
<div className="task-planner-line"> <div className="task-planner-line">
@ -847,14 +833,10 @@ function TaskPlannerItem({
aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`} aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`}
/> />
<input <input
className="tool-split-entry-label task-planner-title-input" {...titleEdit.getInputProps({
value={titleDraft} className: "tool-split-entry-label task-planner-title-input",
onChange={(event) => setTitleDraft(event.target.value)} "aria-label": textContent.titleLabel || "Titre"
onBlur={commitTitleDraft} })}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
aria-label={textContent.titleLabel || "Titre"}
/> />
<select <select
className="task-planner-type-select" className="task-planner-type-select"
@ -906,26 +888,21 @@ function TaskPlannerItem({
<label className={`task-planner-category-field ${categoryEditable ? "" : "is-disabled"}`}> <label className={`task-planner-category-field ${categoryEditable ? "" : "is-disabled"}`}>
<span>{textContent.categoryLabel || "Catégorie"}</span> <span>{textContent.categoryLabel || "Catégorie"}</span>
<input <input
value={categoryDraft} {...categoryEdit.getInputProps({
onChange={(event) => setCategoryDraft(event.target.value)} disabled: !categoryEditable,
onBlur={commitCategoryDraft} placeholder: textContent.categoryPlaceholder || "Nom de catégorie",
onKeyDown={(event) => { "aria-label": `${textContent.categoryLabel || "Catégorie"} ${task.title}`
if (event.key === "Enter") event.currentTarget.blur(); })}
}}
disabled={!categoryEditable}
placeholder={textContent.categoryPlaceholder || "Nom de catégorie"}
aria-label={`${textContent.categoryLabel || "Catégorie"} ${task.title}`}
/> />
</label> </label>
<label className="task-planner-description-field"> <label className="task-planner-description-field">
<span>{textContent.descriptionLabel || "Description"}</span> <span>{textContent.descriptionLabel || "Description"}</span>
<textarea <textarea
className="task-planner-description" {...descriptionEdit.getInputProps({
value={descriptionDraft} className: "task-planner-description",
onChange={(event) => setDescriptionDraft(event.target.value)} placeholder: textContent.descriptionPlaceholder || "Description",
onBlur={commitDescriptionDraft} "aria-label": `${textContent.descriptionLabel || "Description"} ${task.title}`
placeholder={textContent.descriptionPlaceholder || "Description"} })}
aria-label={`${textContent.descriptionLabel || "Description"} ${task.title}`}
/> />
</label> </label>
</div> </div>

View file

@ -2,6 +2,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx"; import { Tabs } from "../../../components/Tabs.jsx";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { import {
formatDuration, formatDuration,
@ -581,36 +582,23 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
} }
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) { function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const [label, setLabel] = useState(value); const inlineEdit = useInlineEdit({
const inputRef = useRef(null); value,
onCommit: onRename,
useLayoutEffect(() => { onDone,
inputRef.current?.focus(); autoFocus: true,
}, []); commitUnchanged: true
});
function saveLabel() {
onRename(label);
onDone();
}
return ( return (
<> <>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span> <span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input <input
ref={inputRef} {...inlineEdit.getInputProps({
className="tool-split-entry-label timer-lap-label" className: "tool-split-entry-label timer-lap-label",
value={label} placeholder,
onChange={(event) => setLabel(event.target.value)} "aria-label": ariaLabel
onBlur={saveLabel} })}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setLabel(value);
onDone();
}
}}
placeholder={placeholder}
aria-label={ariaLabel}
/> />
</> </>
); );

View file

@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js"; import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { CalculatorModule } from "./CalculatorModule.jsx"; import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx"; import { ChecklistModule } from "./ChecklistModule.jsx";
@ -370,26 +371,18 @@ function ModuleShell({ toolbox, module, context, reorder, onRename, onUpdateModu
} }
function EditableModuleTitle({ value, fallback, onSave }) { function EditableModuleTitle({ value, fallback, onSave }) {
const inlineEdit = useInlineEdit({
value,
onCommit: (title) => onSave(title || fallback),
blurOnEscape: true
});
return ( return (
<h2 <h2
className="module-title" {...inlineEdit.getContentEditableProps({
contentEditable className: "module-title",
suppressContentEditableWarning title: "Cliquer pour renommer"
spellCheck="false" })}
title="Cliquer pour renommer"
onFocus={(event) => { event.currentTarget.dataset.previousTitle = event.currentTarget.textContent.trim(); }}
onBlur={(event) => onSave(event.currentTarget.textContent.trim() || fallback)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
event.currentTarget.textContent = event.currentTarget.dataset.previousTitle || value;
event.currentTarget.blur();
}
}}
> >
{value} {value}
</h2> </h2>

View file

@ -0,0 +1,119 @@
// Rôle : centralise l'édition inline contrôlée pour inputs, textarea et titres contentEditable.
// Gère draft local, synchronisation, focus auto, commit blur/Enter et annulation Escape.
// À privilégier pour les libellés éditables avant de recréer une logique locale.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
function getEditableText(element) {
return element?.isContentEditable ? element.textContent : undefined;
}
export function useInlineEdit({
value,
onCommit,
onCancel,
onDone,
transform = (draft) => String(draft || "").trim(),
commitUnchanged = false,
commitOnEnter = true,
autoFocus = false,
focusKey = "",
blurOnEscape = false
}) {
const inputRef = useRef(null);
const skipCommitRef = useRef(false);
const [draft, setDraft] = useState(value);
function syncContentEditable(nextValue, force = false) {
if (inputRef.current?.isContentEditable && (force || document.activeElement !== inputRef.current)) {
inputRef.current.textContent = String(nextValue || "");
}
}
useEffect(() => {
setDraft(value);
syncContentEditable(value);
}, [value]);
useLayoutEffect(() => {
if (!autoFocus) return;
inputRef.current?.focus();
}, [autoFocus, focusKey]);
function commit(rawDraft = draft) {
if (skipCommitRef.current) {
skipCommitRef.current = false;
return;
}
const nextValue = transform(rawDraft);
setDraft(nextValue);
if (commitUnchanged || nextValue !== value) onCommit?.(nextValue);
onDone?.();
}
function cancel(event) {
setDraft(value);
syncContentEditable(value, true);
onCancel?.();
onDone?.();
if (blurOnEscape) {
skipCommitRef.current = true;
event?.currentTarget?.blur();
}
}
function getEditHandlers(props) {
return {
onFocus: (event) => props.onFocus?.(event),
onBlur: (event) => {
commit(getEditableText(event.currentTarget) ?? draft);
props.onBlur?.(event);
},
onKeyDown: (event) => {
const contentEditable = event.currentTarget.isContentEditable;
if (event.key === "Enter" && commitOnEnter) {
if (contentEditable) event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
if (contentEditable) event.preventDefault();
cancel(event);
}
props.onKeyDown?.(event);
}
};
}
function getInputProps(props = {}) {
return {
...props,
...getEditHandlers(props),
ref: inputRef,
value: draft,
onChange: (event) => {
setDraft(event.target.value);
props.onChange?.(event);
}
};
}
function getContentEditableProps(props = {}) {
return {
...props,
ref: inputRef,
...getEditHandlers(props),
contentEditable: true,
suppressContentEditableWarning: true,
spellCheck: props.spellCheck ?? "false"
};
}
return {
inputRef,
draft,
setDraft,
commit,
cancel,
getInputProps,
getContentEditableProps
};
}