@activecollab/components
Version:
ActiveCollab Components
192 lines (186 loc) • 6.49 kB
JavaScript
import { useCallback, useEffect, useRef, useState } from "react";
/**
* useInlineEdit — story-local click-to-edit state machine for StackedCard
* (task 4/8). Presentation-only; not exported from the design-system barrel.
*
* A card in preview/placeholder mode enters an EDITING state on click: a
* card-centered text field with confirm/cancel keyboard rules, an optional
* validator, and a single-line ↔ multi-line mode switch. The hook owns only the
* editing lifecycle (mode, working value, error, keyboard + outside-click); the
* card chrome and the async handoff after commit (e.g. the link unfurl ladder
* from task 3/8) live in the story that consumes it.
*
* ── The decided rules (AC task #454744) ──
* Single-line Enter confirms · click-outside confirms · Esc cancels + reverts
* Multi-line Enter = newline · Cmd/Ctrl+Enter saves · click-outside saves ·
* Esc cancels + reverts
* Validator validate(value) => true | errorMessage, run before every accept
* pass → commit + exit editing
* fail via a key → STAY editing, surface the message (retry)
* fail via outside → REVERT (acts as cancel) — user never trapped
*
* Keyboard is handled on the field's own keydown (never a global listener) and
* every keydown stops propagation, so the card focus model (task 2/8) can't
* swallow the Enter/Esc that belong to the editor.
*/
/** Props to spread onto the `<input>` or `<textarea>` while editing. */
export const useInlineEdit = function (_temp) {
let _ref = _temp === void 0 ? {} : _temp,
_ref$initialValue = _ref.initialValue,
initialValue = _ref$initialValue === void 0 ? "" : _ref$initialValue,
_ref$multiline = _ref.multiline,
multiline = _ref$multiline === void 0 ? false : _ref$multiline,
validate = _ref.validate,
onCommit = _ref.onCommit;
const _useState = useState(false),
editing = _useState[0],
setEditingState = _useState[1];
const _useState2 = useState(initialValue),
value = _useState2[0],
setValueState = _useState2[1];
const _useState3 = useState(initialValue),
committed = _useState3[0],
setCommittedState = _useState3[1];
const _useState4 = useState(null),
error = _useState4[0],
setError = _useState4[1];
// Refs mirror the state that the (stable) event handlers read, so the handlers
// never go stale and never need to be re-created as the value changes.
const elRef = useRef(null);
const valueRef = useRef(value);
const committedRef = useRef(committed);
const editingRef = useRef(editing);
// Set right before a programmatic exit so the blur that follows the field
// unmounting is ignored (it isn't a real click-outside).
const skipBlurRef = useRef(false);
const validateRef = useRef(validate);
const onCommitRef = useRef(onCommit);
useEffect(() => {
validateRef.current = validate;
onCommitRef.current = onCommit;
});
const setValue = useCallback(v => {
valueRef.current = v;
setValueState(v);
}, []);
const setCommitted = useCallback(v => {
committedRef.current = v;
setCommittedState(v);
}, []);
const setEditing = useCallback(v => {
editingRef.current = v;
setEditingState(v);
}, []);
const begin = useCallback(() => {
skipBlurRef.current = false;
setValue(committedRef.current);
setError(null);
setEditing(true);
}, [setValue, setEditing]);
const cancel = useCallback(() => {
skipBlurRef.current = true;
setValue(committedRef.current);
setError(null);
setEditing(false);
}, [setValue, setEditing]);
const reset = useCallback(function (next) {
if (next === void 0) {
next = initialValue;
}
skipBlurRef.current = true;
setCommitted(next);
setValue(next);
setError(null);
setEditing(false);
}, [initialValue, setCommitted, setValue, setEditing]);
const commit = useCallback(v => {
skipBlurRef.current = true;
setCommitted(v);
setValue(v);
setError(null);
setEditing(false);
onCommitRef.current == null || onCommitRef.current(v);
}, [setCommitted, setValue, setEditing]);
// Run the validator (if any) and accept, or handle the failure per source:
// a key retry keeps the user in the field with a message; an outside click
// reverts so the user is never trapped in an invalid editor.
const attempt = useCallback(source => {
const v = valueRef.current;
const result = validateRef.current ? validateRef.current(v) : true;
if (result === true) {
commit(v);
return true;
}
if (source === "outside") {
cancel();
return false;
}
setError(result);
return false;
}, [commit, cancel]);
const onChange = useCallback(e => {
setValue(e.target.value);
// Clear a stale error as the user edits; the next accept re-validates.
setError(prev => prev == null ? prev : null);
}, [setValue]);
const onKeyDown = useCallback(e => {
// Keep Enter/Esc from reaching the card's own keyboard model (task 2/8).
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
cancel();
return;
}
if (e.key === "Enter") {
const withMeta = e.metaKey || e.ctrlKey;
// Multi-line: a bare Enter is a newline; only Cmd/Ctrl+Enter saves.
if (multiline && !withMeta) return;
e.preventDefault();
attempt("key");
}
}, [attempt, cancel, multiline]);
const onBlur = useCallback(() => {
if (skipBlurRef.current) {
skipBlurRef.current = false;
return;
}
if (!editingRef.current) return;
attempt("outside");
}, [attempt]);
const setRef = useCallback(el => {
elRef.current = el;
}, []);
// On entering editing, focus the field and drop the caret at the end so the
// cursor is visible and existing text is ready to extend.
useEffect(() => {
if (!editing) return;
const el = elRef.current;
if (!el) return;
el.focus();
const len = el.value.length;
try {
el.setSelectionRange(len, len);
} catch (_unused) {
/* some input types disallow setSelectionRange — harmless here. */
}
}, [editing]);
return {
editing,
value,
committed,
error,
multiline,
begin,
cancel,
reset,
bind: {
ref: setRef,
value,
onChange,
onKeyDown,
onBlur,
"aria-invalid": error != null
}
};
};
//# sourceMappingURL=useInlineEdit.js.map