{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "editable-cells",
  "type": "registry:ui",
  "title": "Editable Cells",
  "description": "Spreadsheet cells that edit in place, validate per cell and flash when saved.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/editable-cells.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type EditableColumn<T> = {\n  /** The row field this column shows and edits. */\n  key: keyof T & string;\n  header: React.ReactNode;\n  /** Plain words for labels and announcements when header isn't a string. */\n  label?: string;\n  width?: number | string;\n  /** Right-aligned, tabular figures, and a decimal keypad on phones. */\n  numeric?: boolean;\n  /** Read-only columns can still be focused and copied. Default true. */\n  editable?: boolean;\n  /** How the value reads in the cell. Defaults to String(value). */\n  format?: (value: T[keyof T], row: T) => React.ReactNode;\n  /** The text the editor starts from. Defaults to String(value). */\n  toInput?: (value: T[keyof T], row: T) => string;\n  /** Turns what was typed into the stored value. Defaults to the text itself. */\n  parse?: (input: string) => T[keyof T];\n  /** Return a message to reject the value; it stays in the editor with the message beside it. */\n  validate?: (value: T[keyof T], input: string, row: T) => string | null | undefined;\n  /** A cell under the column, e.g. a total. */\n  footer?: (rows: T[]) => React.ReactNode;\n  placeholder?: string;\n};\n\nexport type EditableCellsProps<T> = Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  columns: EditableColumn<T>[];\n  rows: T[];\n  getRowId: (row: T) => string;\n  /** Names a row in labels and announcements, e.g. the item name. */\n  getRowLabel?: (row: T) => string;\n  caption: string;\n  /**\n   * Persist one cell. Update your rows here. Return a promise to show the new value as saving\n   * until it settles; throw or reject with an Error to put the old value back and show why.\n   */\n  onCellChange: (change: { rowId: string; key: keyof T & string; value: T[keyof T]; row: T }) => void | Promise<unknown>;\n  minWidth?: number;\n};\n\ntype Pos = { r: number; c: number };\ntype Editing = Pos & { text: string; error?: string; caret: \"select\" | \"end\" };\n\nconst cellKey = (id: string, key: string) => `${id}\\u0000${key}`;\n\nexport function EditableCells<T>({\n  columns,\n  rows,\n  getRowId,\n  getRowLabel,\n  caption,\n  onCellChange,\n  minWidth,\n  className,\n  ...rest\n}: EditableCellsProps<T>) {\n  const [active, setActive] = useState<Pos>({ r: 0, c: 0 });\n  // Pointer moves glide the highlight; keyboard moves land on the same frame.\n  const [via, setVia] = useState<\"pointer\" | \"keyboard\">(\"keyboard\");\n  const [editing, setEditing] = useState<Editing | null>(null);\n  // A rejected value left behind by clicking away: kept, marked, and picked up again on the next edit.\n  const [drafts, setDrafts] = useState<Record<string, { text: string; error: string }>>({});\n  const [pending, setPending] = useState<Record<string, T[keyof T]>>({});\n  const [failed, setFailed] = useState<Record<string, string>>({});\n  const [saved, setSaved] = useState<Record<string, number>>({});\n  const [announce, setAnnounce] = useState(\"\");\n  const grid = useRef<HTMLTableElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const [edges, setEdges] = useState({ left: false, right: false });\n  // Which sides hide more columns, for the edge shadows when the grid scrolls sideways.\n  const measure = (el: HTMLDivElement) => {\n    const left = el.scrollLeft > 1;\n    const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;\n    setEdges((e) => (e.left === left && e.right === right ? e : { left, right }));\n  };\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => measure(el));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const input = useRef<HTMLInputElement>(null);\n  const focusAfter = useRef(false);\n  // True only while an editor is open, so the blur that follows Enter, Tab or a click elsewhere can't save twice.\n  const live = useRef(false);\n  const alive = useRef(true);\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const captionId = `${uid}-caption`;\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const clampR = Math.min(active.r, Math.max(0, rows.length - 1));\n  const clampC = Math.min(active.c, Math.max(0, columns.length - 1));\n  const editable = (c: number) => columns[c]?.editable !== false;\n  const nameOf = (c: number) => columns[c].label ?? (typeof columns[c].header === \"string\" ? (columns[c].header as string) : columns[c].key);\n  const rowName = (r: number) => (getRowLabel ? getRowLabel(rows[r]) : `row ${r + 1}`);\n\n  // Focus follows the active cell after keyboard moves and closed editors.\n  useEffect(() => {\n    if (editing) {\n      const el = input.current;\n      if (el && document.activeElement !== el) {\n        el.focus();\n        if (editing.caret === \"select\") el.select();\n        else el.setSelectionRange(el.value.length, el.value.length);\n      }\n      return;\n    }\n    if (!focusAfter.current) return;\n    focusAfter.current = false;\n    grid.current?.querySelector<HTMLElement>(`[data-cell=\"${clampR}:${clampC}\"]`)?.focus({ preventScroll: false });\n  }, [editing, clampR, clampC]);\n\n  const startEdit = (p: Pos, how: { text?: string; caret?: \"select\" | \"end\" } = {}) => {\n    if (!editable(p.c) || !rows[p.r]) return;\n    const row = rows[p.r];\n    const col = columns[p.c];\n    const k = cellKey(getRowId(row), col.key);\n    const draft = drafts[k];\n    const value = k in pending ? pending[k] : row[col.key];\n    const text = how.text ?? draft?.text ?? (col.toInput ? col.toInput(value, row) : String(value ?? \"\"));\n    live.current = true;\n    setActive(p);\n    setFailed((f) => omit(f, k));\n    setEditing({ ...p, text, error: how.text == null ? draft?.error : undefined, caret: how.caret ?? (how.text != null ? \"end\" : \"select\") });\n  };\n\n  /** Next editable cell in reading order, or null at the end. */\n  const step = (p: Pos, dir: 1 | -1): Pos | null => {\n    let { r, c } = p;\n    for (;;) {\n      c += dir;\n      if (c >= columns.length) {\n        c = 0;\n        r++;\n      } else if (c < 0) {\n        c = columns.length - 1;\n        r--;\n      }\n      if (r < 0 || r >= rows.length) return null;\n      if (editable(c)) return { r, c };\n    }\n  };\n\n  const move = (p: Pos, how: \"pointer\" | \"keyboard\" = \"keyboard\") => {\n    setVia(how);\n    setActive({ r: Math.max(0, Math.min(rows.length - 1, p.r)), c: Math.max(0, Math.min(columns.length - 1, p.c)) });\n    if (how === \"keyboard\") focusAfter.current = true;\n  };\n\n  /** Validates and saves the open editor. Returns false when the value was rejected. */\n  const commit = (then?: Pos | null, keepDraftOnError = false): boolean => {\n    if (!editing || !live.current) return true;\n    const { r, c, text } = editing;\n    const row = rows[r];\n    const col = columns[c];\n    const id = getRowId(row);\n    const k = cellKey(id, col.key);\n    const value = (col.parse ? col.parse(text) : text) as T[keyof T];\n    const error = col.validate?.(value, text, row) ?? undefined;\n    if (error) {\n      if (keepDraftOnError) {\n        live.current = false;\n        setDrafts((d) => ({ ...d, [k]: { text, error } }));\n        setEditing(null);\n      } else {\n        setEditing({ ...editing, error, caret: \"end\" });\n        setAnnounce(error);\n      }\n      return false;\n    }\n    live.current = false;\n    setDrafts((d) => omit(d, k));\n    setEditing(null);\n    if (then) move(then);\n    else if (!keepDraftOnError) focusAfter.current = true;\n\n    const before = col.toInput ? col.toInput(row[col.key], row) : String(row[col.key] ?? \"\");\n    const after = col.toInput ? col.toInput(value, row) : String(value ?? \"\");\n    if (before === after) return true;\n\n    const flash = () => {\n      setSaved((s) => ({ ...s, [k]: (s[k] ?? 0) + 1 }));\n      setAnnounce(`Saved ${nameOf(c)} for ${rowName(r)}`);\n    };\n    let result: void | Promise<unknown>;\n    try {\n      result = onCellChange({ rowId: id, key: col.key, value, row });\n    } catch (e) {\n      setFailed((f) => ({ ...f, [k]: messageOf(e) }));\n      setAnnounce(messageOf(e));\n      return true;\n    }\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      // Optimistic: the new value shows at once, dimmed, until the save settles.\n      setPending((p) => ({ ...p, [k]: value }));\n      (result as Promise<unknown>)\n        .then(() => alive.current && flash())\n        .catch((e) => {\n          if (!alive.current) return;\n          setFailed((f) => ({ ...f, [k]: messageOf(e) }));\n          setAnnounce(messageOf(e));\n        })\n        .finally(() => alive.current && setPending((p) => omit(p, k)));\n    } else flash();\n    return true;\n  };\n\n  const cancel = () => {\n    live.current = false;\n    setEditing(null);\n    focusAfter.current = true;\n  };\n\n  const onCellKey = (e: React.KeyboardEvent, p: Pos) => {\n    if (editing) return;\n    const row = rows[p.r];\n    const col = columns[p.c];\n    const k = cellKey(getRowId(row), col.key);\n    const mod = e.metaKey || e.ctrlKey;\n    const go = (to: Pos) => {\n      e.preventDefault();\n      move(to);\n    };\n    switch (e.key) {\n      case \"ArrowUp\":\n        return go({ r: mod ? 0 : p.r - 1, c: p.c });\n      case \"ArrowDown\":\n        return go({ r: mod ? rows.length - 1 : p.r + 1, c: p.c });\n      case \"ArrowLeft\":\n        return go({ r: p.r, c: p.c - 1 });\n      case \"ArrowRight\":\n        return go({ r: p.r, c: p.c + 1 });\n      case \"Home\":\n        return go({ r: mod ? 0 : p.r, c: 0 });\n      case \"End\":\n        return go({ r: mod ? rows.length - 1 : p.r, c: columns.length - 1 });\n      case \"Tab\": {\n        const next = step(p, e.shiftKey ? -1 : 1);\n        if (next) go(next);\n        return;\n      }\n      case \"Enter\":\n      case \"F2\":\n        e.preventDefault();\n        return startEdit(p, { caret: e.key === \"F2\" ? \"end\" : \"select\" });\n      case \"Escape\":\n        if (drafts[k]) {\n          e.preventDefault();\n          setDrafts((d) => omit(d, k));\n        }\n        return;\n      case \"Backspace\":\n      case \"Delete\":\n        e.preventDefault();\n        return startEdit(p, { text: \"\" });\n    }\n    if (mod && e.key.toLowerCase() === \"c\") {\n      e.preventDefault();\n      const value = k in pending ? pending[k] : row[col.key];\n      navigator.clipboard?.writeText(col.toInput ? col.toInput(value, row) : String(value ?? \"\")).then(\n        () => alive.current && setAnnounce(\"Copied\"),\n        () => alive.current && setAnnounce(\"Couldn’t copy\"),\n      );\n      return;\n    }\n    // Typing on a cell replaces its contents, as a spreadsheet does.\n    if (e.key.length === 1 && !mod && !e.altKey && editable(p.c)) {\n      e.preventDefault();\n      startEdit(p, { text: e.key });\n    }\n  };\n\n  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (!editing) return;\n    const p = { r: editing.r, c: editing.c };\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      cancel();\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      // Enter saves and drops to the cell below; Shift+Enter goes up.\n      const to = { r: Math.max(0, Math.min(rows.length - 1, p.r + (e.shiftKey ? -1 : 1))), c: p.c };\n      commit(to);\n    } else if (e.key === \"Tab\") {\n      // Tab saves and moves to the next editable cell. At the last one it saves and stays; the next Tab leaves the grid.\n      e.preventDefault();\n      commit(step(p, e.shiftKey ? -1 : 1));\n    }\n  };\n\n  const hasFooter = columns.some((c) => c.footer);\n\n  return (\n    <div className={cn(\"group/grid relative flex flex-col overflow-hidden rounded-xl border border-line bg-frame\", className)} {...rest}>\n      <div ref={scroller} onScroll={(e) => measure(e.currentTarget)} className=\"max-h-[inherit] min-h-0 flex-1 overflow-auto overscroll-contain\">\n        <LayoutGroup id={uid}>\n          <table\n            ref={grid}\n            role=\"grid\"\n            aria-labelledby={captionId}\n            style={{ minWidth }}\n            className=\"w-full table-fixed border-separate border-spacing-0 text-[13px]\"\n          >\n            <caption id={captionId} className=\"sr-only\">\n              {caption}\n            </caption>\n            <colgroup>\n              {columns.map((c) => (\n                <col key={c.key} style={c.width != null ? { width: c.width } : undefined} />\n              ))}\n            </colgroup>\n            <thead>\n              <tr>\n                {columns.map((col) => (\n                  <th\n                    key={col.key}\n                    scope=\"col\"\n                    className={cn(\n                      \"sticky top-0 z-(--z-sticky) h-9 whitespace-nowrap border-b border-line bg-frame px-3 text-[12px] font-medium text-fg-3\",\n                      col.numeric ? \"text-right\" : \"text-left\",\n                    )}\n                  >\n                    {col.header}\n                    {col.editable === false && <span className=\"sr-only\"> (read-only)</span>}\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody>\n              {rows.map((row, r) => {\n                const id = getRowId(row);\n                return (\n                  <tr key={id} className=\"group/row\">\n                    {columns.map((col, c) => {\n                      const k = cellKey(id, col.key);\n                      const isActive = r === clampR && c === clampC;\n                      const isEditing = editing?.r === r && editing?.c === c;\n                      const draft = drafts[k];\n                      const saving = k in pending;\n                      const value = saving ? pending[k] : row[col.key];\n                      const error = isEditing ? editing?.error : (draft?.error ?? failed[k]);\n                      const errorId = `${uid}-err-${r}-${c}`;\n                      const canEdit = col.editable !== false;\n                      // Errors open below, except near the bottom where they'd be cut off.\n                      const above = r >= rows.length - 2 && rows.length > 2;\n                      return (\n                        <td\n                          key={col.key}\n                          data-cell={`${r}:${c}`}\n                          tabIndex={isActive && !isEditing ? 0 : -1}\n                          aria-readonly={!canEdit || undefined}\n                          aria-invalid={error ? true : undefined}\n                          aria-describedby={error && !isEditing ? errorId : undefined}\n                          aria-busy={saving || undefined}\n                          onFocus={() => !isActive && move({ r, c }, \"pointer\")}\n                          onKeyDown={(e) => onCellKey(e, { r, c })}\n                          onPointerDown={(e) => {\n                            if (isEditing || e.button !== 0) return;\n                            if (editing) commit(null, true);\n                            move({ r, c }, \"pointer\");\n                          }}\n                          onClick={() => {\n                            if (!isEditing && canEdit) startEdit({ r, c });\n                          }}\n                          className={cn(\n                            \"relative h-10 border-b border-line p-0 outline-none group-last/row:border-b-0\",\n                            canEdit ? \"cursor-text\" : \"cursor-default\",\n                            !isEditing && canEdit && \"hover:bg-hover\",\n                            \"transition-colors duration-100\",\n                          )}\n                        >\n                          {/* One highlight for the whole grid, handed from cell to cell. */}\n                          {isActive && (\n                            // The wrapper owns visibility (while the grid has focus, is editing, or holds an error);\n                            // the inner span is the one highlight, handed from cell to cell.\n                            <span\n                              aria-hidden\n                              className={cn(\n                                \"pointer-events-none absolute -inset-px z-[2] opacity-0 transition-opacity duration-150 group-focus-within/grid:opacity-100\",\n                                (isEditing || error) && \"opacity-100\",\n                              )}\n                            >\n                              <motion.span\n                                layoutId=\"active-cell\"\n                                transition={via === \"keyboard\" || reduce ? { duration: 0 } : spring.follow}\n                                className={cn(\n                                  \"absolute inset-0 rounded-[5px] border\",\n                                  isEditing\n                                    ? error\n                                      ? \"border-danger shadow-[0_0_0_3px_var(--danger-soft)]\"\n                                      : \"border-fg-3 shadow-[0_0_0_3px_color-mix(in_srgb,var(--fg)_8%,transparent)]\"\n                                    : error\n                                      ? \"border-danger\"\n                                      : \"border-fg-4\",\n                                )}\n                              />\n                            </span>\n                          )}\n\n                          {/* The saved flash: a wash of the success tint that fades on its own. */}\n                          <AnimatePresence>\n                            {saved[k] != null && (\n                              <motion.span\n                                key={saved[k]}\n                                aria-hidden\n                                initial={{ opacity: 1 }}\n                                animate={{ opacity: 0 }}\n                                transition={{ duration: 1.1, ease: ease.outQuart, delay: 0.15 }}\n                                className=\"pointer-events-none absolute inset-0 bg-success-soft\"\n                              />\n                            )}\n                          </AnimatePresence>\n\n                          {isEditing ? (\n                            <input\n                              ref={input}\n                              value={editing.text}\n                              onChange={(e) => setEditing({ ...editing, text: e.target.value, error: undefined })}\n                              onKeyDown={onInputKey}\n                              // Clicking elsewhere saves; a rejected value is kept as a marked draft, never thrown away.\n                              onBlur={() => commit(null, true)}\n                              aria-label={`${nameOf(c)}, ${rowName(r)}`}\n                              aria-invalid={error ? true : undefined}\n                              aria-describedby={error ? errorId : undefined}\n                              inputMode={col.numeric ? \"decimal\" : undefined}\n                              placeholder={col.placeholder}\n                              autoComplete=\"off\"\n                              spellCheck={false}\n                              // Same box, padding and alignment as the text it replaces, so nothing moves when editing starts.\n                              className={cn(\n                                \"absolute inset-0 z-[1] h-full w-full min-w-0 rounded-[4px] bg-raised px-3 text-[13px] text-fg outline-none placeholder:text-fg-4 pointer-coarse:text-base\",\n                                col.numeric && \"text-right tabular\",\n                              )}\n                            />\n                          ) : (\n                            <span\n                              className={cn(\n                                \"relative flex h-full min-w-0 items-center px-3\",\n                                col.numeric && \"justify-end tabular\",\n                                saving && \"text-fg-3\",\n                              )}\n                            >\n                              {draft ? (\n                                <span className=\"truncate text-danger\">{draft.text || \" \"}</span>\n                              ) : (\n                                <span className={cn(\"truncate\", value == null || value === \"\" ? \"text-fg-4\" : \"text-fg\")}>\n                                  {value == null || value === \"\" ? (col.placeholder ?? \"—\") : col.format ? col.format(value, row) : String(value)}\n                                </span>\n                              )}\n                              {saving && <Saving side={col.numeric ? \"left\" : \"right\"} />}\n                            </span>\n                          )}\n\n                          <AnimatePresence>\n                            {error && (isEditing || isActive) && (\n                              <motion.span\n                                id={errorId}\n                                role={isEditing ? undefined : \"note\"}\n                                initial={reduce ? { opacity: 0 } : { opacity: 0, y: above ? 4 : -4, scale: 0.98 }}\n                                animate={{ opacity: 1, y: 0, scale: 1 }}\n                                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                                transition={{ duration: 0.16, ease: ease.out }}\n                                className={cn(\n                                  \"pointer-events-none absolute z-(--z-dropdown) w-max max-w-[240px] rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-danger shadow-pop\",\n                                  above ? \"bottom-[calc(100%+4px)] origin-bottom\" : \"top-[calc(100%+4px)] origin-top\",\n                                  col.numeric ? \"right-0\" : \"left-0\",\n                                )}\n                              >\n                                {error}\n                              </motion.span>\n                            )}\n                          </AnimatePresence>\n                        </td>\n                      );\n                    })}\n                  </tr>\n                );\n              })}\n            </tbody>\n            {hasFooter && (\n              <tfoot>\n                <tr>\n                  {columns.map((col) => (\n                    <td\n                      key={col.key}\n                      className={cn(\"h-10 border-t border-line px-3 font-medium text-fg\", col.numeric ? \"text-right tabular\" : \"text-left\")}\n                    >\n                      {col.footer?.(rows)}\n                    </td>\n                  ))}\n                </tr>\n              </tfoot>\n            )}\n          </table>\n        </LayoutGroup>\n      </div>\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 left-0 z-(--z-sticky) w-4 bg-linear-to-r from-overlay to-transparent transition-opacity duration-200\", edges.left ? \"opacity-50\" : \"opacity-0\")} />\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 right-0 z-(--z-sticky) w-4 bg-linear-to-l from-overlay to-transparent transition-opacity duration-200\", edges.right ? \"opacity-50\" : \"opacity-0\")} />\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nfunction Saving({ side }: { side: \"left\" | \"right\" }) {\n  // Only appears if the save is slow enough to notice.\n  return (\n    <motion.svg\n      width=\"12\"\n      height=\"12\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n      aria-hidden\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      transition={{ delay: 0.15, duration: 0.15 }}\n      className={cn(\"absolute top-1/2 -mt-1.5 text-fg-3 motion-safe:animate-spin\", side === \"left\" ? \"left-2\" : \"right-2\")}\n    >\n      <path d=\"M8 2.25a5.75 5.75 0 1 0 5.75 5.75\" />\n    </motion.svg>\n  );\n}\n\nfunction omit<V>(record: Record<string, V>, key: string) {\n  if (!(key in record)) return record;\n  const next = { ...record };\n  delete next[key];\n  return next;\n}\n\nfunction messageOf(e: unknown) {\n  return e instanceof Error && e.message ? e.message : \"Couldn’t save. Try again.\";\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/editable-cells"
}
