UNPKG

@activecollab/components

Version:

ActiveCollab Components

192 lines (186 loc) 6.49 kB
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