Refactor inline editing into shared hook
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
1879b245fb
commit
1e4ac4259b
9 changed files with 239 additions and 205 deletions
|
|
@ -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:/);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
setDraft(value);
|
|
||||||
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
|
if (value === fallback) window.requestAnimationFrame(() => event.target.select());
|
||||||
}}
|
},
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
spellCheck: "false"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
119
website/src/hooks/useInlineEdit.js
Normal file
119
website/src/hooks/useInlineEdit.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue