{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "editable",
  "title": "Editable",
  "description": "Text that turns into a field in place without moving the page: Enter commits, Escape reverts, leaving commits or reverts by prop, with validation and optional buttons.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/editable.tsx",
      "content": "import type * as React from \"react\"\nimport { EditableActions, EditablePencil } from \"@/registry/miseui/ui/editable-parts\"\nimport { useEditable, type EditableBlurAction } from \"@/registry/miseui/ui/editable-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype EditableVariant = \"text\" | \"heading\" | \"area\" | \"field\"\ntype EditableEffect = \"materialize\"\ntype EditableState = ReturnType<typeof useEditable>\ntype EditorState = Pick<EditableState, \"draft\" | \"error\" | \"errorId\" | \"fieldRef\" | \"onDraftChange\">\n\n/**\n * The read view and the input share one type class and one box model, down to the transparent border,\n * so switching between them does not move a single pixel of the surrounding layout.\n */\nconst TYPE_CLASS: Record<EditableVariant, string> = {\n  text: \"truncate px-2 py-1 text-sm leading-6 pointer-coarse:py-2\",\n  heading: \"wrap-anywhere whitespace-pre-wrap px-2 py-1 text-2xl leading-8 font-semibold tracking-tight\",\n  area: \"wrap-anywhere whitespace-pre-wrap px-3 py-2 text-sm leading-6\",\n  field: \"truncate px-3 py-1.5 pr-10 text-sm leading-6 pointer-coarse:py-2\",\n}\nconst READ_HOVER_CLASS: Record<EditableVariant, string> = {\n  text: \"hover:bg-accent\",\n  heading: \"hover:bg-accent\",\n  area: \"hover:bg-accent\",\n  field: \"hover:border-input\",\n}\nconst BOX_CLASS = \"col-start-1 row-start-1 block w-full min-w-0 rounded-md border\"\n/** Zero width: makes a trailing line break take up a line in the sizing copy, as it does in the textarea. */\nconst LINE_END = String.fromCharCode(0x200b)\nconst FIELD_CLASS = \"resize-none overflow-hidden border-input bg-transparent ring-[3px] ring-ring/50 outline-none\"\ntype EditableProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  value?: string\n  defaultValue?: string\n  /** Fires on commit only, never per keystroke. */\n  onValueChange?: (value: string) => void\n  onEditingChange?: (editing: boolean) => void\n  /** Return a message to refuse the value; the field stays open and shows it. */\n  validate?: (value: string) => string | undefined\n  /** `text` inline, `heading` page title, `area` several lines with save and cancel, `field` a settings row boxed on hover. */\n  variant?: EditableVariant\n  /** `materialize` brings the action buttons into focus when editing starts. */\n  effect?: EditableEffect\n  /** What leaving the field means. Defaults to `commit`. */\n  blurAction?: EditableBlurAction\n  /** Shows save and cancel buttons. Defaults to on for `area` and `field`. */\n  showActions?: boolean\n  /** The name of the thing being edited, used to name the controls. */\n  label: string\n  placeholder?: string\n  maxLength?: number\n  selectOnEdit?: boolean\n  disabled?: boolean\n  saveLabel?: string\n  cancelLabel?: string\n}\n\ninterface EditorProps extends EditorState, Pick<EditableProps, \"label\" | \"placeholder\" | \"maxLength\"> {\n  variant: EditableVariant\n  roomForActions: boolean\n}\n\nfunction EditableEditor({ draft, error, errorId, fieldRef, onDraftChange, variant, label, placeholder, maxLength, roomForActions }: EditorProps) {\n  const usesTextarea = variant === \"area\" || variant === \"heading\"\n  const box = cn(BOX_CLASS, TYPE_CLASS[variant])\n  const field = {\n    \"aria-label\": label,\n    \"aria-invalid\": error ? true : undefined,\n    \"aria-describedby\": error ? errorId : undefined,\n    placeholder,\n    maxLength,\n    value: draft,\n    className: cn(box, FIELD_CLASS, roomForActions && \"pr-20 pointer-coarse:pr-24\", error && \"border-destructive ring-destructive/20\"),\n    onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onDraftChange(event.target.value),\n  }\n  if (!usesTextarea) return <input ref={fieldRef as React.Ref<HTMLInputElement>} type=\"text\" {...field} />\n  return (\n    <>\n      {/* The same text in the same box, invisible: it gives the textarea its height, so nothing jumps as lines come and go. */}\n      <div aria-hidden=\"true\" className={cn(box, \"invisible border-transparent\")}>\n        {`${draft || placeholder}${LINE_END}`}\n      </div>\n      <textarea ref={fieldRef as React.Ref<HTMLTextAreaElement>} rows={1} {...field} />\n    </>\n  )\n}\n\ninterface ReadViewProps extends Pick<EditableProps, \"label\" | \"placeholder\" | \"disabled\"> {\n  value: string\n  variant: EditableVariant\n  readRef: React.Ref<HTMLButtonElement>\n  onStart: () => void\n}\n\nfunction ReadView({ value, variant, label, placeholder, disabled, readRef, onStart }: ReadViewProps) {\n  return (\n    <button\n      ref={readRef}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={`Edit ${label}: ${value || placeholder}`}\n      onClick={onStart}\n      className={cn(\n        BOX_CLASS,\n        TYPE_CLASS[variant],\n        \"group/read relative border-transparent text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise) disabled:pointer-events-none disabled:opacity-50\",\n        READ_HOVER_CLASS[variant],\n        !value && \"text-muted-foreground\",\n      )}\n    >\n      {value || placeholder}\n      {variant === \"field\" ? <EditablePencil /> : null}\n    </button>\n  )\n}\n\nfunction Editable({\n  value: valueProp, defaultValue, onValueChange, onEditingChange, validate, variant = \"text\", effect, blurAction = \"commit\", showActions, label,\n  placeholder = \"Empty\", maxLength, selectOnEdit = true, disabled = false, saveLabel = \"Save\", cancelLabel = \"Cancel\", className, onKeyDown, onBlur, ...props\n}: EditableProps) {\n  const options = { value: valueProp, defaultValue, onValueChange, onEditingChange, validate, blurAction, selectOnEdit, disabled }\n  const { value, editing, draft, error, errorId, fieldRef, readRef, rootRef, start, commit, cancel, onDraftChange, ...handlers } = useEditable({ ...options, multiline: variant === \"area\" })\n  const actions = showActions ?? (variant === \"area\" || variant === \"field\")\n  const inside = variant === \"field\"\n  const editor = { draft, error, errorId, fieldRef, onDraftChange, variant, label, placeholder, maxLength, roomForActions: actions && inside }\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"editable\"\n      data-variant={variant}\n      data-effect={effect}\n      data-state={editing ? \"editing\" : \"reading\"}\n      className={cn(\"relative grid min-w-0 grid-cols-[minmax(0,1fr)]\", className)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        handlers.onKeyDown(event)\n      }}\n      onBlur={(event) => {\n        onBlur?.(event)\n        handlers.onBlur(event)\n      }}\n      {...props}\n    >\n      {editing ? <EditableEditor {...editor} /> : <ReadView {...{ value, variant, label, placeholder, disabled, readRef }} onStart={start} />}\n      {editing && actions ? <EditableActions inside={inside} materialize={effect === \"materialize\"} {...{ saveLabel, cancelLabel }} onSave={() => commit()} onCancel={() => cancel()} /> : null}\n      {editing && error ? (\n        <p id={errorId} role=\"alert\" data-slot=\"editable-error\" className={cn(\"absolute top-full left-0 z-10 mt-1 text-[13px] leading-5 text-destructive\", actions && !inside && \"max-w-[calc(100%-6rem)]\")}>\n          {error}\n        </p>\n      ) : null}\n    </div>\n  )\n}\n\nexport { Editable, type EditableProps, type EditableVariant, type EditableEffect, type EditableBlurAction }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/editable-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { Check, Pencil, X } from \"lucide-react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst ACTION_CLASS = [\n  \"inline-flex size-8 items-center justify-center rounded-md outline-none pointer-coarse:size-10\",\n  \"transition-[color,background-color,scale] duration-(--duration-fast) ease-(--ease-mise)\",\n  \"focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.97] active:duration-100 motion-reduce:active:scale-100\",\n]\nconst PENCIL_CLASS = [\n  \"absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground opacity-0 motion-reduce:transition-none\",\n  \"transition-opacity duration-(--duration-fast) group-hover/read:opacity-100 group-focus-visible/read:opacity-100 pointer-coarse:opacity-100\",\n]\n\nfunction EditablePencil() {\n  return <Pencil aria-hidden=\"true\" className={cn(PENCIL_CLASS)} />\n}\n\ninterface ActionsProps {\n  inside: boolean\n  materialize: boolean\n  saveLabel: string\n  cancelLabel: string\n  onSave: () => void\n  onCancel: () => void\n}\n\nfunction EditableActions({ inside, materialize, saveLabel, cancelLabel, onSave, onCancel }: ActionsProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: \"blur(4px)\" }\n  const shown = reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n  const position = inside\n    ? \"absolute top-1/2 right-1 z-10 -translate-y-1/2\"\n    : \"absolute top-full right-0 z-10 mt-1 rounded-md border bg-popover p-1 shadow-md\"\n  const shared = {\n    \"data-slot\": \"editable-actions\",\n    className: cn(\"flex gap-1\", position),\n    // Keeps focus in the field, so the press is not read as leaving it. Safari gives buttons no focus at all.\n    onPointerDown: (event: React.PointerEvent) => event.preventDefault(),\n  }\n  const buttons = (\n    <>\n      <button type=\"button\" aria-label={cancelLabel} className={cn(ACTION_CLASS, \"text-muted-foreground hover:bg-accent hover:text-foreground\")} onClick={onCancel}>\n        <X aria-hidden=\"true\" className=\"size-4\" />\n      </button>\n      <button type=\"button\" aria-label={saveLabel} className={cn(ACTION_CLASS, \"bg-mise text-mise-foreground hover:bg-mise/90\")} onClick={onSave}>\n        <Check aria-hidden=\"true\" className=\"size-4\" />\n      </button>\n    </>\n  )\n  if (!materialize) return <div {...shared}>{buttons}</div>\n  return (\n    <motion.div {...shared} initial={hidden} animate={shown} transition={SPRING_DEFAULT}>\n      {buttons}\n    </motion.div>\n  )\n}\n\nexport { EditableActions, EditablePencil }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/editable-state.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useId, useRef, useState } from \"react\"\n\ntype EditableBlurAction = \"commit\" | \"revert\"\ntype EditableField = HTMLInputElement | HTMLTextAreaElement\n\ninterface UseEditableOptions {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  onEditingChange?: (editing: boolean) => void\n  /** Return a message to refuse the value. */\n  validate?: (value: string) => string | undefined\n  blurAction: EditableBlurAction\n  multiline: boolean\n  selectOnEdit: boolean\n  disabled: boolean\n}\n\n/** Composition is checked by the caller: during IME input Enter confirms the syllable and Escape cancels it, not the field. */\nfunction keyAction(event: React.KeyboardEvent, onField: boolean, multiline: boolean) {\n  if (event.key === \"Escape\") return \"cancel\"\n  if (event.key !== \"Enter\" || !onField) return null\n  // Enter belongs to the text in a multi-line field, so only Mod+Enter commits there.\n  return multiline && !event.metaKey && !event.ctrlKey ? null : \"commit\"\n}\n\n/** Puts focus in the field on the way in, and back on the text only after a keyboard finish. */\nfunction useEditFocus(editing: boolean, selectOnEdit: boolean) {\n  const fieldRef = useRef<EditableField | null>(null)\n  const readRef = useRef<HTMLButtonElement | null>(null)\n  // Set by a keyboard finish only: a click elsewhere must keep the focus it just took.\n  const returnFocusRef = useRef(false)\n  useEffect(() => {\n    if (editing) {\n      const field = fieldRef.current\n      field?.focus({ preventScroll: true })\n      if (selectOnEdit) field?.select()\n      else field?.setSelectionRange(field.value.length, field.value.length)\n      return\n    }\n    if (returnFocusRef.current) {\n      returnFocusRef.current = false\n      readRef.current?.focus({ preventScroll: true })\n    }\n  }, [editing, selectOnEdit])\n  return { fieldRef, readRef, returnFocusRef }\n}\n\nfunction useEditable(options: UseEditableOptions) {\n  const { value: controlled, defaultValue = \"\", onValueChange, onEditingChange, validate, blurAction, multiline, selectOnEdit, disabled } = options\n  const [uncontrolled, setUncontrolled] = useState(defaultValue)\n  const [editing, setEditing] = useState(false)\n  const [draft, setDraft] = useState(\"\")\n  const [error, setError] = useState<string | undefined>(undefined)\n  const value = controlled ?? uncontrolled\n  const errorId = useId()\n  const rootRef = useRef<HTMLDivElement | null>(null)\n  const { fieldRef, readRef, returnFocusRef } = useEditFocus(editing, selectOnEdit)\n\n  const finish = (restoreFocus: boolean) => {\n    returnFocusRef.current = restoreFocus\n    setError(undefined)\n    setEditing(false)\n    onEditingChange?.(false)\n  }\n\n  const start = () => {\n    if (disabled || editing) return\n    setDraft(value)\n    setEditing(true)\n    onEditingChange?.(true)\n  }\n\n  const cancel = (restoreFocus = true) => finish(restoreFocus)\n  /** A refused value keeps the field open with its message: throwing away what someone typed is the worse failure. */\n  const commit = (restoreFocus = true) => {\n    const message = validate?.(draft)\n    if (message) {\n      setError(message)\n      return\n    }\n    if (draft !== value) {\n      setUncontrolled(draft)\n      onValueChange?.(draft)\n    }\n    finish(restoreFocus)\n  }\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {\n    if (!editing || event.nativeEvent.isComposing) return\n    const action = keyAction(event, event.target === fieldRef.current, multiline)\n    if (!action) return\n    if (action === \"cancel\") {\n      // A dialog around this field must not close along with the edit.\n      event.stopPropagation()\n      cancel()\n    } else {\n      event.preventDefault()\n      commit()\n    }\n  }\n\n  /** Listens on the root so moving from the field to its own buttons is not a way out. */\n  const onBlur = (event: React.FocusEvent<HTMLElement>) => {\n    const next = event.relatedTarget as Node | null\n    if (!editing || (next && rootRef.current?.contains(next))) return\n    if (blurAction === \"commit\") commit(false)\n    else cancel(false)\n  }\n\n  /** A pasted line break would otherwise sneak into a value that has no way to show it. */\n  const onDraftChange = (next: string) => {\n    setDraft(multiline ? next : next.replace(/\\r?\\n/g, \" \"))\n    setError(undefined)\n  }\n\n  return { value, editing, draft, error, errorId, fieldRef, readRef, rootRef, start, commit, cancel, onKeyDown, onBlur, onDraftChange }\n}\n\nexport { useEditable, type EditableBlurAction, type EditableField }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}