diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs
index 3ad9a09..3ba083e 100644
--- a/tests/static-toolboxes.test.mjs
+++ b/tests/static-toolboxes.test.mjs
@@ -14,6 +14,7 @@ test("toolbox storage, cards and pages are wired", async () => {
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
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");
assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.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, /updateToolboxOrder/);
assert.match(toolboxPages, /useGroupedReorder/);
+ assert.match(toolboxPages, /useInlineEdit/);
+ assert.match(toolboxPages, /getContentEditableProps/);
assert.match(toolboxPages, /orientation: "horizontal"/);
assert.match(toolboxPages, /components\/StorageQuota\.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, /data-reorder-orientation/);
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 () => {
@@ -126,6 +134,8 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /itemReorder\.startDrag/);
assert.match(moduleRegistry, /onPointerDown/);
assert.match(moduleRegistry, /useGroupedReorder/);
+ assert.match(moduleRegistry, /useInlineEdit/);
+ assert.match(moduleRegistry, /getContentEditableProps/);
assert.match(moduleRegistry, /tool-add-card/);
assert.match(moduleRegistry, /tool-quick-add-button/);
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, /keyboardLayout/);
assert.match(combosModule, /simultaneousMode/);
+ assert.match(combosModule, /useInlineEdit/);
assert.match(combosModule, /useGroupedReorder/);
assert.match(combosModule, /reorderFeatures/);
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, /activeParentId/);
assert.match(calculatorModule, /parentId/);
+ assert.match(calculatorModule, /useInlineEdit/);
assert.match(calculatorModule, /useGroupedReorder/);
assert.match(calculatorModule, /calculator-drag-handle/);
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, /ArrowRight/);
assert.match(tableModule, /function HeaderLabelInput/);
+ assert.match(tableModule, /useInlineEdit/);
assert.match(tableModule, /event\.target\.select/);
assert.match(tableFormulaEngine, /export function evaluateTableCell/);
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, /stopwatch/);
assert.match(timerModule, /countdown/);
+ assert.match(timerModule, /useInlineEdit/);
assert.match(timerModule, /normalizeTimerData/);
assert.match(taskPlannerModule, /export function TaskPlannerModule/);
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, /task-planner-warning/);
assert.match(taskPlannerModule, /useGroupedReorder/);
+ assert.match(taskPlannerModule, /useInlineEdit/);
+ assert.match(taskPlannerModule, /commitOnEnter: false/);
assert.match(taskPlannerModule, /reorderFeatures/);
assert.doesNotMatch(taskPlannerModule, /canMoveItem:/);
assert.doesNotMatch(taskPlannerModule, /canMoveGroup:/);
diff --git a/website/src/features/toolboxes/ToolboxPages.jsx b/website/src/features/toolboxes/ToolboxPages.jsx
index 17782d9..be139f4 100644
--- a/website/src/features/toolboxes/ToolboxPages.jsx
+++ b/website/src/features/toolboxes/ToolboxPages.jsx
@@ -5,6 +5,7 @@ import { ImportButton } from "../../components/ImportButton.jsx";
import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { useGroupedReorder } from "../../hooks/useGroupedReorder.js";
+import { useInlineEdit } from "../../hooks/useInlineEdit.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js";
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" }) {
- const ref = useRef(null);
- useEffect(() => {
- if (ref.current && document.activeElement !== ref.current) ref.current.textContent = value;
- }, [value]);
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit: (title) => onSave(title || fallback),
+ blurOnEscape: true
+ });
+
return (
{ 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();
- }
- }}
+ {...inlineEdit.getContentEditableProps({
+ className,
+ title: "Cliquer pour renommer"
+ })}
>{value}
);
}
diff --git a/website/src/features/toolboxes/modules/CalculatorModule.jsx b/website/src/features/toolboxes/modules/CalculatorModule.jsx
index 0335d76..61613b8 100644
--- a/website/src/features/toolboxes/modules/CalculatorModule.jsx
+++ b/website/src/features/toolboxes/modules/CalculatorModule.jsx
@@ -1,6 +1,7 @@
// Rôle : fournit l'outil calculateur avec résultats enregistrés en arborescence.
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
function calculateExpression(expression) {
@@ -243,34 +244,20 @@ function CalculatorEntries({ entries, parentId, getScopedParentId, activeParentI
}
function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
- const [label, setLabel] = useState(entry.label);
- const inputRef = useRef(null);
-
- useLayoutEffect(() => {
- inputRef.current?.focus();
- }, [entry.id]);
-
- function saveLabel() {
- const cleanLabel = label.trim();
- if (cleanLabel !== entry.label) onRename(entry.id, cleanLabel);
- onDone();
- }
+ const inlineEdit = useInlineEdit({
+ value: entry.label,
+ onCommit: (label) => onRename(entry.id, label),
+ onDone,
+ autoFocus: true,
+ focusKey: entry.id
+ });
return (
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}`}
+ {...inlineEdit.getInputProps({
+ className: "tool-split-entry-label",
+ "aria-label": `${textContent.renameTitle || "Renommer"} ${entry.label}`
+ })}
/>
);
}
diff --git a/website/src/features/toolboxes/modules/CombosModule.jsx b/website/src/features/toolboxes/modules/CombosModule.jsx
index fa82989..443be8c 100644
--- a/website/src/features/toolboxes/modules/CombosModule.jsx
+++ b/website/src/features/toolboxes/modules/CombosModule.jsx
@@ -1,6 +1,7 @@
// Rôle : fournit l'outil Combos avec palettes d'inputs et rendu visuel par périphérique.
import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import {
applyGroupedReorderOperation,
getGroupedEntries,
@@ -240,32 +241,19 @@ function ComboSequence({ inputs, device, emptyLabel, activeStepIndex = -1, onSel
}
function InlineTextInput({ value, onCommit, className, ariaLabel, placeholder = "" }) {
- const [draft, setDraft] = useState(value);
-
- useEffect(() => {
- setDraft(value);
- }, [value]);
-
- function commit() {
- const cleanDraft = draft.trim();
- if (cleanDraft !== value) onCommit(cleanDraft);
- }
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit,
+ blurOnEscape: true
+ });
return (
setDraft(event.target.value)}
- onBlur={commit}
- onKeyDown={(event) => {
- if (event.key === "Enter") event.currentTarget.blur();
- if (event.key === "Escape") {
- setDraft(value);
- event.currentTarget.blur();
- }
- }}
- aria-label={ariaLabel}
+ {...inlineEdit.getInputProps({
+ className,
+ placeholder,
+ "aria-label": ariaLabel
+ })}
/>
);
}
diff --git a/website/src/features/toolboxes/modules/TableModule.jsx b/website/src/features/toolboxes/modules/TableModule.jsx
index fc689d0..243508a 100644
--- a/website/src/features/toolboxes/modules/TableModule.jsx
+++ b/website/src/features/toolboxes/modules/TableModule.jsx
@@ -1,6 +1,7 @@
// Rôle : fournit l'outil tableau avec cellules libres et formules simples.
import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { cellAddress, columnIndexToName, evaluateTable, parseCellAddress } from "./tableFormulaEngine.js";
const DEFAULT_ROWS = 10;
@@ -597,46 +598,23 @@ function TableRow({ rowIndex, columns, cells, evaluatedCells, editingCell, highl
}
function HeaderLabelInput({ className, value, fallback, onSave, ...props }) {
- const [draft, setDraft] = useState(value);
- const [focused, setFocused] = useState(false);
- const skipSaveRef = useRef(false);
-
- useEffect(() => {
- if (!focused) setDraft(value);
- }, [focused, value]);
-
- function saveDraft() {
- setFocused(false);
- if (skipSaveRef.current) {
- skipSaveRef.current = false;
- setDraft(value);
- return;
- }
- onSave(draft);
- }
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit: onSave,
+ transform: (draft) => String(draft || ""),
+ blurOnEscape: true
+ });
return (
{
- setFocused(true);
- setDraft(value);
- if (value === fallback) window.requestAnimationFrame(() => event.target.select());
- }}
- 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"
+ {...inlineEdit.getInputProps({
+ ...props,
+ className,
+ onFocus: (event) => {
+ if (value === fallback) window.requestAnimationFrame(() => event.target.select());
+ },
+ spellCheck: "false"
+ })}
/>
);
}
diff --git a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
index c981696..2ecac72 100644
--- a/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
+++ b/website/src/features/toolboxes/modules/TaskPlannerModule.jsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { InlineNotice } from "../../../components/AppOverlays.jsx";
import { applyGroupedReorderOperation, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
const TASK_TYPES = ["daily", "weekly", "unique"];
const WEEK_DAYS = [
@@ -770,13 +771,10 @@ function TaskPlannerItem({
onSetTaskCategory,
children
}) {
- const [titleDraft, setTitleDraft] = useState(task.title);
- const [descriptionDraft, setDescriptionDraft] = useState(task.description);
const parentTask = parentId ? getTaskById(tasks, parentId) : null;
const inheritedCategory = parentTask ? getEffectiveTaskCategory(parentTask, tasks, parentMap) : "";
const categoryValue = parentTask ? inheritedCategory : task.category || "";
const categoryEditable = !parentTask;
- const [categoryDraft, setCategoryDraft] = useState(categoryValue);
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 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.getDropPlacement("item", task.id) === "after" ? "drop-after" : ""
].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) {
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 (
@@ -847,14 +833,10 @@ function TaskPlannerItem({
aria-label={`${task.checked ? textContent.uncheckTitle || "Marquer non effectué" : textContent.checkTitle || "Marquer effectué"} ${task.title}`}
/>
setTitleDraft(event.target.value)}
- onBlur={commitTitleDraft}
- onKeyDown={(event) => {
- if (event.key === "Enter") event.currentTarget.blur();
- }}
- aria-label={textContent.titleLabel || "Titre"}
+ {...titleEdit.getInputProps({
+ className: "tool-split-entry-label task-planner-title-input",
+ "aria-label": textContent.titleLabel || "Titre"
+ })}
/>
diff --git a/website/src/features/toolboxes/modules/TimerModule.jsx b/website/src/features/toolboxes/modules/TimerModule.jsx
index 0e93517..449c28c 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 { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import {
formatDuration,
@@ -581,36 +582,23 @@ function CountdownList({ countdowns, nowMs, textContent, reorder, reorderEnabled
}
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
- const [label, setLabel] = useState(value);
- const inputRef = useRef(null);
-
- useLayoutEffect(() => {
- inputRef.current?.focus();
- }, []);
-
- function saveLabel() {
- onRename(label);
- onDone();
- }
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit: onRename,
+ onDone,
+ autoFocus: true,
+ commitUnchanged: true
+ });
return (
<>
{children}
setLabel(event.target.value)}
- onBlur={saveLabel}
- onKeyDown={(event) => {
- if (event.key === "Enter") event.currentTarget.blur();
- if (event.key === "Escape") {
- setLabel(value);
- onDone();
- }
- }}
- placeholder={placeholder}
- aria-label={ariaLabel}
+ {...inlineEdit.getInputProps({
+ className: "tool-split-entry-label timer-lap-label",
+ placeholder,
+ "aria-label": ariaLabel
+ })}
/>
>
);
diff --git a/website/src/features/toolboxes/modules/index.jsx b/website/src/features/toolboxes/modules/index.jsx
index 518fcaf..c307dde 100644
--- a/website/src/features/toolboxes/modules/index.jsx
+++ b/website/src/features/toolboxes/modules/index.jsx
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
+import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { lockBodyScroll } from "../../../utils/bodyScrollLock.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
@@ -370,26 +371,18 @@ function ModuleShell({ toolbox, module, context, reorder, onRename, onUpdateModu
}
function EditableModuleTitle({ value, fallback, onSave }) {
+ const inlineEdit = useInlineEdit({
+ value,
+ onCommit: (title) => onSave(title || fallback),
+ blurOnEscape: true
+ });
+
return (
{ 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();
- }
- }}
+ {...inlineEdit.getContentEditableProps({
+ className: "module-title",
+ title: "Cliquer pour renommer"
+ })}
>
{value}
diff --git a/website/src/hooks/useInlineEdit.js b/website/src/hooks/useInlineEdit.js
new file mode 100644
index 0000000..b73dfa3
--- /dev/null
+++ b/website/src/hooks/useInlineEdit.js
@@ -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
+ };
+}