# All files / src/components/editable useEditableField.ts

99.04% Statements 104/105
100% Branches 33/33
100% Functions 1/1
99.04% Lines 104/105

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    1801x

    1x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x

    52x
    52x
    52x

    52x

    52x
    10x
    10x
    52x

    52x

    52x

    52x
    7x
    7x
    7x
    7x
    52x

    52x
    7x

    7x
    2x
    2x
    2x
    2x
    2x
    2x

    7x
    7x
    1x
    1x
    1x

    4x
    4x
    52x

    52x
    15x
    15x
    15x
    15x
    52x

    52x
    52x
    3x
    1x
    1x
    1x
    2x
    2x
    1x
    3x
    1x
    1x
    1x
    3x
    52x
    52x

    52x
    52x
    3x
    1x
    3x
    1x
    1x
    3x
    52x
    52x

    52x
    52x
    52x
    52x
    52x
    52x

    52x
    52x
    1x
    1x
    1x
    52x

    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
    52x
     import { useCallback, useEffect, useRef, useState } from "react";

    /** Options for the useEditableField hook. */
    export interface UseEditableFieldOptions {
      /** Current persisted value. */
      value: string;
      /** Called when the user saves a new value. */
      onSave: (value: string) => void;
      /** Optional validation — return an error string, or undefined if valid. */
      validate?: (value: string) => string | undefined;
      /** Unique identifier for this field (used for coordination). */
      fieldId: string;
      /** Which field is currently being edited (coordination from parent). */
      activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
      /** Callback to tell the parent which field is active. */
      onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
      /** Whether Enter key triggers save (true for text inputs, false for textarea). */
      enterToSave?: boolean;
      /** Whether to trim whitespace before saving. Default true. */
      trimOnSave?: boolean;
    }

    /** Return type for the useEditableField hook. */
    export interface UseEditableFieldReturn {
      /** Whether this field is currently in edit mode. */
      isEditing: boolean;
      /** The current draft value while editing. */
      draft: string;
      /** Validation error message, or empty string. */
      error: string;
      /** Whether the draft differs from the persisted value. */
      isDirty: boolean;
      /** Enter edit mode with the current value as the draft. */
      startEdit: () => void;
      /** Exit edit mode without saving. */
      cancelEdit: () => void;
      /** Validate and save the current draft. */
      save: () => void;
      /** Update the draft value. Also clears any validation error. */
      setDraft: (value: string) => void;
      /** Clear the validation error. */
      clearError: () => void;
      /** Blur handler that auto-saves, respecting ignoreInitialBlur and data-edit-action. */
      handleBlur: (event: React.FocusEvent) => void;
      /** Keyboard handler for Escape (cancel) and optionally Enter (save). */
      handleKeyDown: (event: React.KeyboardEvent) => void;
      /**
       * Ref that prevents the initial blur (caused by clicking the edit button)
       * from triggering a save. Set to true when startEdit is called, reset on
       * first blur.
       */
      ignoreInitialBlurRef: React.RefObject<boolean>;
    }

    /**
     * Shared hook that encapsulates the click-to-edit state machine used by
     * EditableTextField, EditableTextArea, and EditableSelect.
     */
    export function useEditableField(options: UseEditableFieldOptions): UseEditableFieldReturn {
      const {
        value,
        onSave,
        validate,
        fieldId,
        activeFieldId,
        onActivate,
        enterToSave = true,
        trimOnSave = true,
      } = options;

      const [draft, setDraftRaw] = useState("");
      const [error, setError] = useState("");
      const ignoreInitialBlurRef = useRef<boolean>(false);

      const isEditing = activeFieldId === fieldId;

      const setDraft = useCallback((v: string) => {
        setDraftRaw(v);
        setError("");
      }, []);

      const clearError = useCallback(() => {
        setError("");
      }, []);

      const cancelEdit = useCallback(() => {
        ignoreInitialBlurRef.current = false;
        onActivate?.(null);
        setDraftRaw("");
        setError("");
      }, [onActivate]);

      const save = useCallback(() => {
        const saveValue = trimOnSave ? draft.trim() : draft;

        if (validate) {
          const validationError = validate(draft);
          if (validationError) {
            setError(validationError);
            return;
          }
        }

        // No-op when value hasn't changed
        const compareValue = trimOnSave ? value.trim() : value;
        if (saveValue === compareValue) {
          cancelEdit();
          return;
        }

        onSave(saveValue);
        cancelEdit();
      }, [draft, value, trimOnSave, validate, onSave, cancelEdit]);

      const startEdit = useCallback(() => {
        ignoreInitialBlurRef.current = true;
        onActivate?.(fieldId);
        setDraftRaw(value);
        setError("");
      }, [fieldId, value, onActivate]);

      const handleBlur = useCallback(
        (event: React.FocusEvent) => {
          if (ignoreInitialBlurRef.current) {
            ignoreInitialBlurRef.current = false;
            return;
          }
          if (
            event.relatedTarget instanceof HTMLElement &&
            event.relatedTarget.dataset.editAction === fieldId
          ) {
            return;
          }
          save();
        },
        [fieldId, save],
      );

      const handleKeyDown = useCallback(
        (event: React.KeyboardEvent) => {
          if (event.key === "Escape") {
            cancelEdit();
          } else if (event.key === "Enter" && enterToSave) {
            save();
          }
        },
        [cancelEdit, enterToSave, save],
      );

      const isDirty = (() => {
        if (!isEditing) return false;
        const compareValue = trimOnSave ? value.trim() : value;
        const draftValue = trimOnSave ? draft.trim() : draft;
        return draftValue !== compareValue;
      })();

      // If another field becomes active, reset our local state
      useEffect(() => {
        if (!isEditing && (draft !== "" || error !== "")) {
          setDraftRaw("");
          setError("");
        }
      }, [isEditing, draft, error]);

      return {
        isEditing,
        draft,
        error,
        isDirty,
        startEdit,
        cancelEdit,
        save,
        setDraft,
        clearError,
        handleBlur,
        handleKeyDown,
        ignoreInitialBlurRef,
      };
    }