{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"editable","title":"Editable","description":"Show a value as text and edit it in place in its field. Search terms: inline edit, edit in place, click to edit, editable text, rename.","dependencies":["lucide-react@1.18.0"],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/input","@siteplane/motion","@siteplane/textarea"],"files":[{"path":"packages/ui/src/components/editable/editable.tsx","content":"\"use client\";\n\nimport { PencilIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { useTruncated } from \"../../system/hooks/use-truncated\";\nimport { useReducedMotionPreference } from \"../../system/motion/use-reduced-motion-preference\";\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport { focusRingRecipe } from \"../../system/recipes/focus-ring.recipe\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button, type ButtonProps } from \"../button\";\nimport {\n  type FieldCheckedControl,\n  useFieldValueCheck,\n} from \"../field/field-value-check\";\nimport { Input, type InputProps } from \"../input\";\nimport {\n  holdPanePlace,\n  type PanePlace,\n  panePin,\n  placeOf,\n  swapContainerClassName,\n  useHeightFollower,\n} from \"../motion/motion-follow\";\nimport {\n  inSecondFrame,\n  keyframeTiming,\n  measureSwapTiming,\n  playFromSecondFrame,\n  swapFadeClassName,\n} from \"../motion/motion-presence\";\nimport { Textarea, type TextareaProps } from \"../textarea\";\nimport {\n  type EditableActivationMode,\n  type EditableEnd,\n  type EditableSide,\n  type EditableSubmitMode,\n  type EditableSwap,\n  editableFocusReturn,\n  editableKeyAction,\n  editableLeaveAction,\n  finishEdit,\n  inPlaceAlignment,\n  onFontsLoaded,\n  isApplePlatform,\n  isOutsideEditable,\n  keepsFocusElsewhere,\n  nextSwap,\n  previewActivates,\n  previewCanActivate,\n  swapAttributes,\n  swapDuration,\n  swapPhase,\n} from \"./editable-interaction\";\n\nimport \"./editable.css\";\n\nexport type { EditableActivationMode, EditableSubmitMode };\n\n/** The words of an Editable's triggers. */\nexport type EditableLabels = {\n  /** The name of the edit trigger, which shows the pencil alone. */\n  edit: string;\n  /** The text of the submit trigger. */\n  submit: string;\n  /** The text of the cancel trigger. */\n  cancel: string;\n};\n\nconst defaultEditableLabels: EditableLabels = {\n  cancel: \"Cancel\",\n  edit: \"Edit\",\n  submit: \"Save\",\n};\n\n/**\n * The placeholder of the field and of the preview of an empty value: one text\n * for both, or one each.\n */\nexport type EditablePlaceholder = string | { edit: string; preview: string };\n\nexport type EditableProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\" | \"placeholder\"\n> & {\n  /** The value, for a controlled Editable; it holds the change being typed. */\n  value?: string | undefined;\n  /** The value an uncontrolled Editable starts with. */\n  defaultValue?: string | undefined;\n  /** Called with the value whenever it changes, also while typing. */\n  onValueChange?: ((value: string) => void) | undefined;\n  /** Called with the value when a change is saved. */\n  onValueCommit?: ((value: string) => void) | undefined;\n  /** Called with the value that was put back when a change is cancelled. */\n  onValueRevert?: ((value: string) => void) | undefined;\n  /** Whether the value is being edited, for a controlled edit state. */\n  edit?: boolean | undefined;\n  /** Whether an uncontrolled Editable starts in its field. */\n  defaultEdit?: boolean | undefined;\n  /** Called when editing should start (`true`) or end (`false`). */\n  onEditChange?: ((edit: boolean) => void) | undefined;\n  /**\n   * What starts editing from the preview: `focus` (the default) focusing it,\n   * `click` a click, `dblclick` a double click, `none` only the edit trigger.\n   */\n  activationMode?: EditableActivationMode | undefined;\n  /**\n   * What saves a change: `both` (the default) Enter and leaving the\n   * editable, `enter` Enter, `blur` leaving it, `none` only the submit\n   * trigger. Leaving an editable that does not save cancels the change.\n   */\n  submitMode?: EditableSubmitMode | undefined;\n  /** Whether the field selects its text when editing starts. */\n  selectOnFocus?: boolean | undefined;\n  /** The placeholder of the field, and of the preview of an empty value. */\n  placeholder?: EditablePlaceholder | undefined;\n  disabled?: boolean | undefined;\n  /** Shows the value without a way to edit it. */\n  readOnly?: boolean | undefined;\n  required?: boolean | undefined;\n  /** Marks the field and the value invalid, as an error from the server does. */\n  invalid?: boolean | undefined;\n  /** The name the value submits with in a form. */\n  name?: string | undefined;\n  /** The id of a form the field belongs to, outside it. */\n  form?: string | undefined;\n  maxLength?: number | undefined;\n  /**\n   * The element that takes focus once an edit ends, in place of the edit\n   * trigger, or of the preview when there is no edit trigger.\n   */\n  finalFocusEl?: (() => HTMLElement | null) | undefined;\n  /** The control size of the field and of the optional Save and Cancel. */\n  size?: SiteplaneControlSize | undefined;\n  /** Replaces the English words of the triggers. */\n  labels?: Partial<EditableLabels> | undefined;\n};\n\ntype EditableContextValue = {\n  activationMode: EditableActivationMode;\n  cancel: () => void;\n  disabled: boolean;\n  editing: boolean;\n  empty: boolean;\n  form: string | undefined;\n  interactive: boolean;\n  invalid: boolean;\n  labels: EditableLabels;\n  maxLength: number | undefined;\n  name: string | undefined;\n  onFieldKeyDown: (\n    event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n  ) => void;\n  placeholder: { edit?: string | undefined; preview?: string | undefined };\n  previewFocus: () => void;\n  readOnly: boolean;\n  refs: {\n    cancelTrigger: React.RefObject<HTMLButtonElement | null>;\n    editTrigger: React.RefObject<HTMLButtonElement | null>;\n    field: React.RefObject<FieldCheckedControl | null>;\n    /** The pane of the field, whose frame fades. */\n    fieldPane: React.RefObject<HTMLSpanElement | null>;\n    /** Where the pencil stood at rest, against the Editable. */\n    pencilPlace: React.RefObject<PanePlace | null>;\n    preview: React.RefObject<HTMLElement | null>;\n    /** Where the value stood at rest, against the Editable. */\n    previewPlace: React.RefObject<PanePlace | null>;\n    root: React.RefObject<HTMLDivElement | null>;\n    submitTrigger: React.RefObject<HTMLButtonElement | null>;\n  };\n  required: boolean | undefined;\n  setValue: (value: string) => void;\n  size: SiteplaneControlSize | undefined;\n  startEdit: () => void;\n  submit: () => void;\n  /** The switch between the two sides (rulebook M8, its Editable note). */\n  swap: { starting: boolean; swapping: boolean };\n  value: string;\n};\n\nconst EditableContext = React.createContext<EditableContextValue | null>(null);\n\nfunction useEditableContext(part: string): EditableContextValue {\n  const context = React.useContext(EditableContext);\n  if (!context) throw new Error(`${part} must be placed within an Editable.`);\n  return context;\n}\n\n/**\n * How a part takes its turn: `fade` with the kit's swap fade (the pencil and\n * the optional Save and Cancel), `frame` with the fade of the field's frame\n * that the Editable plays (the field, whose own text waits meanwhile) and\n * `text`, never faded, above the field while they switch (the value): the\n * one text that paints until the switch is over. The value shows the field's\n * text colour, so the two texts take turns in one frame, pixel for pixel.\n */\ntype SwapPartKind = \"fade\" | \"frame\" | \"text\";\n\n/**\n * A part of one side of the switch (rulebook M8, its Editable note). The value\n * never fades or moves: the side that takes over lays out the row at once,\n * and the side that leaves stays pinned in the box it had, out of the flow,\n * until it is gone, the value above the field in both directions; only the\n * field's frame fades in and out around the value's text, and the field's\n * own text takes over in one frame once the switch is over. The part that\n * enters takes focus and keys at once, and a part that leaves is inert. With\n * reduced motion the sides switch at once (M5). `placeRef` keeps the part's\n * place where the Editable reads it.\n */\nfunction useSwapPart<T extends HTMLElement>(\n  context: EditableContextValue,\n  side: EditableSide,\n  kind: SwapPartKind,\n  placeRef?: React.RefObject<PanePlace | null>,\n  leaveAtOnce = false,\n): {\n  props: ReturnType<typeof swapAttributes> & {\n    className?: string;\n    style?: React.CSSProperties;\n  };\n  phase: ReturnType<typeof swapPhase>;\n  ref: React.RefObject<T | null>;\n} {\n  const ref = React.useRef<T | null>(null);\n  const ownPlace = React.useRef<PanePlace | null>(null);\n  const place = placeRef ?? ownPlace;\n  const turn = swapPhase(side, {\n    editing: context.editing,\n    swapping: context.swap.swapping,\n  });\n  const phase = leaveAtOnce && turn === \"leaving\" ? \"hidden\" : turn;\n  const inFlow = phase === \"shown\" || phase === \"entering\";\n  const root = context.refs.root;\n  // Where the part stands while it is in the flow, which is where it stays\n  // when it leaves: measured after each commit, as a new value moves the edit\n  // trigger, when the part or the Editable changes size, and when a web font\n  // finishes loading, which changes the width of the value beside it. After\n  // the commit, so that the Editable's ref is set on the first one too.\n  React.useEffect(() => {\n    const element = ref.current;\n    const container = root.current;\n    if (inFlow && element && container)\n      place.current = placeOf(element, container);\n  });\n  React.useEffect(() => {\n    const element = ref.current;\n    const container = root.current;\n    if (!inFlow || !element || !container) return undefined;\n    const measure = () => {\n      place.current = placeOf(element, container);\n    };\n    const stopFonts = onFontsLoaded(element.ownerDocument, measure);\n    if (typeof ResizeObserver === \"undefined\") return stopFonts;\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    observer.observe(container);\n    return () => {\n      observer.disconnect();\n      stopFonts();\n    };\n  }, [inFlow, place, root]);\n  // A pencil that returns beside a value of several lines stays where it\n  // stands at rest while the row follows the shorter height of the closing\n  // textarea: centred in the row, it would ride the height down. Its place\n  // is measured in the commit that returns it, before the row's height is\n  // held, so it is the place for the new value.\n  const pencil = side === \"rest\" && kind === \"fade\";\n  const [entryPlace, setEntryPlace] = React.useState<PanePlace | null>(null);\n  const returning = pencil && phase === \"entering\";\n  React.useLayoutEffect(() => {\n    if (!returning) {\n      setEntryPlace(null);\n      return;\n    }\n    const element = ref.current;\n    const container = root.current;\n    if (!element || !container) return;\n    if (context.refs.field.current?.localName !== \"textarea\") return;\n    setEntryPlace(placeOf(element, container));\n    // Once for each return; the field's kind comes through its ref.\n  }, [returning, root]);\n  const pin = phase === \"leaving\" ? place.current : entryPlace;\n  const pinned = pin !== null && (phase === \"leaving\" || returning);\n  // The returning pencil holds its top in the row rather than being pinned\n  // out of it, so the value beside it lays out its lines at once in the width\n  // they have at rest. A leaving part is pinned in its box against the\n  // Editable.\n  const inRow = returning;\n  const held = pinned && !inRow;\n  React.useLayoutEffect(() => {\n    const container = root.current;\n    if (!held || !container) return undefined;\n    return holdPanePlace(container);\n  }, [held, root]);\n  // The value's text paints above the field whose frame fades under it,\n  // pinned while it leaves and in its place at rest while it returns.\n  const above =\n    kind === \"text\" && (phase === \"entering\" || phase === \"leaving\");\n  return {\n    phase,\n    props: {\n      ...swapAttributes(phase, context.swap.starting),\n      ...(kind === \"fade\" ? { className: swapFadeClassName } : {}),\n      ...(pinned && pin\n        ? inRow\n          ? { style: { alignSelf: \"flex-start\", marginTop: pin.top } }\n          : { style: { ...panePin(pin), ...(above ? { zIndex: 1 } : {}) } }\n        : above\n          ? { style: { position: \"relative\", zIndex: 1 } }\n          : {}),\n    },\n    ref,\n  };\n}\n\n/** The preview's own style with the pin of the switch over it. */\nfunction withPin(\n  style: React.CSSProperties | undefined,\n  pin: React.CSSProperties | undefined,\n): React.CSSProperties | undefined {\n  return pin ? { ...style, ...pin } : style;\n}\n\n/**\n * The pane of a part that switches: the field's (`editable-field-pane`), whose\n * frame comes and goes around the text, and the pane of the pencil and of the\n * optional Save and Cancel (`editable-trigger-pane`), which fade with the\n * kit's swap fade. The fade sits on the pane rather than on the kit component\n * it holds, because a Button and a field surface keep transitions of their\n * own, for their states.\n */\nfunction EditableSwapPane({\n  children,\n  context,\n  leaveAtOnce = false,\n  placeRef,\n  side,\n  slot,\n}: {\n  children: React.ReactNode;\n  context: EditableContextValue;\n  /** Leaves at once instead of fading, as the pencil of an empty value. */\n  leaveAtOnce?: boolean;\n  /** Keeps the pane's place where the Editable reads it, as the pencil's. */\n  placeRef?: React.RefObject<PanePlace | null>;\n  side: EditableSide;\n  slot: \"editable-field-pane\" | \"editable-trigger-pane\";\n}): React.ReactElement {\n  const field = slot === \"editable-field-pane\";\n  const {\n    phase,\n    props: { className: swapClassName, style: pin, ...swapProps },\n    ref,\n  } = useSwapPart<HTMLSpanElement>(\n    context,\n    side,\n    field ? \"frame\" : \"fade\",\n    placeRef,\n    leaveAtOnce,\n  );\n  const align = useInPlaceAlignment(context, ref, field);\n  const setRef = useRefs<HTMLSpanElement>(\n    ref,\n    field ? context.refs.fieldPane : undefined,\n  );\n  return (\n    <span\n      className={cn(`sp-${slot}`, swapClassName)}\n      data-slot={slot}\n      // While the sides switch the field's own text waits, transparent, under\n      // the value's (editable.css).\n      data-switching={\n        field && (phase === \"entering\" || phase === \"leaving\") ? \"\" : undefined\n      }\n      ref={setRef}\n      style={pin ?? align}\n      {...swapProps}\n    >\n      {children}\n    </span>\n  );\n}\n\n/**\n * The margins that put the field's text where the value stands. The value at\n * rest already stands where the field's text stands (editable.css: the row\n * keeps the field's height and the value its insets), so the margins are\n * none; they only absorb a difference, such as a field a className changed,\n * so the text never moves. A textarea also keeps the pencil's room at its\n * end, the pencil's width and the row's gap, so its text wraps in the width\n * the value wraps in beside the pencil at rest and the lines break the same\n * way. Measured in the commit that shows the field and set on the pane at\n * once, before the Editable measures the height it follows and before\n * anything paints; React then keeps the same margins.\n */\nfunction useInPlaceAlignment(\n  context: EditableContextValue,\n  ref: React.RefObject<HTMLSpanElement | null>,\n  enabled: boolean,\n): React.CSSProperties | undefined {\n  const [margins, setMargins] = React.useState({ end: 0, x: 0, y: 0 });\n  const latest = React.useRef(margins);\n  const { editing, refs } = context;\n  React.useLayoutEffect(() => {\n    const pane = ref.current;\n    const root = refs.root.current;\n    const control = refs.field.current;\n    const place = refs.previewPlace.current;\n    const preview = refs.preview.current;\n    if (!enabled || !editing || !pane || !root || !control || !place) return;\n    const multiline = control.localName === \"textarea\";\n    const inset = preview ? getComputedStyle(preview) : null;\n    const value = inset\n      ? {\n          height: place.height - px(inset.paddingTop) - px(inset.paddingBottom),\n          left: place.left + px(inset.paddingLeft),\n          top: place.top + px(inset.paddingTop),\n          width: place.width - px(inset.paddingLeft) - px(inset.paddingRight),\n        }\n      : place;\n    const pencil = refs.pencilPlace.current;\n    const end =\n      multiline && pencil\n        ? pencil.width + px(getComputedStyle(root).columnGap)\n        : 0;\n    let next =\n      latest.current.end === end ? latest.current : { ...latest.current, end };\n    pane.style.marginRight = `${next.x + next.end}px`;\n    // Layout rounds to fractions of a pixel, so a second pass settles it.\n    for (let pass = 0; pass < 3; pass += 1) {\n      const rootBox = root.getBoundingClientRect();\n      const box = control.getBoundingClientRect();\n      const style = getComputedStyle(control);\n      const delta = inPlaceAlignment(value, {\n        multiline,\n        text: {\n          height: box.height,\n          left:\n            box.left -\n            rootBox.left -\n            root.clientLeft +\n            Number.parseFloat(style.paddingLeft) +\n            Number.parseFloat(style.borderLeftWidth),\n          top:\n            box.top -\n            rootBox.top -\n            root.clientTop +\n            (multiline\n              ? Number.parseFloat(style.paddingTop) +\n                Number.parseFloat(style.borderTopWidth)\n              : 0),\n          width: box.width,\n        },\n      });\n      if (Math.abs(delta.x) < 0.05 && Math.abs(delta.y) < 0.05) break;\n      next = { end: next.end, x: next.x + delta.x, y: next.y + delta.y };\n      Object.assign(pane.style, {\n        marginBottom: `${next.y}px`,\n        marginLeft: `${next.x}px`,\n        marginRight: `${next.x + next.end}px`,\n        marginTop: `${next.y}px`,\n      });\n    }\n    if (next !== latest.current) {\n      latest.current = next;\n      setMargins(next);\n    }\n  }, [editing, enabled, ref, refs]);\n  if (!enabled) return undefined;\n  return {\n    marginBottom: margins.y,\n    marginLeft: margins.x,\n    marginRight: margins.x + margins.end,\n    marginTop: margins.y,\n  };\n}\n\n/** A computed length in pixels. */\nfunction px(value: string): number {\n  return Number.parseFloat(value) || 0;\n}\n\n/**\n * The fade of the field's frame, in (`opening`) or out: the whole frame (its\n * surface, its border, its focus colour) fades at its full size, while its\n * own text waits and the value's text paints above it (Lukas, 29 October\n * 2026). On the kit's motion tokens (M1): the field's state change,\n * `--motion-duration-fast` on `--motion-ease-state`.\n */\nfunction playFrameFade(pane: HTMLElement, opening: boolean): Animation {\n  return playFromSecondFrame(\n    pane,\n    opening\n      ? [{ opacity: 0 }, { opacity: 1 }]\n      : [{ opacity: 1 }, { opacity: 0 }],\n    {\n      ...keyframeTiming(pane, \"--motion-duration-fast\", \"--motion-ease-state\"),\n      fill: \"forwards\",\n    },\n  );\n}\n\n/** Points every ref at `element` and returns what detaches them again. */\nfunction attachRefs<T>(\n  element: T,\n  refs: readonly (React.Ref<T> | undefined)[],\n): () => void {\n  const detach = refs.map((ref) => {\n    if (typeof ref === \"function\") {\n      const cleanup = ref(element);\n      return typeof cleanup === \"function\" ? cleanup : () => ref(null);\n    }\n    if (!ref) return () => {};\n    ref.current = element;\n    return () => {\n      ref.current = null;\n    };\n  });\n  return () => {\n    for (const cleanup of detach) cleanup();\n  };\n}\n\n/** One callback ref for a part's own ref and the refs it is given. */\nfunction useRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): (element: T | null) => (() => void) | undefined {\n  // The list holds the same refs in every render; it changes when the caller's\n  // ref does.\n  return React.useCallback(\n    (element: T | null) => (element ? attachRefs(element, refs) : undefined),\n    refs,\n  );\n}\n\n/**\n * A value shown as text that turns into its field in place: the preview\n * shows the value, the edit trigger (a pencil) or the preview itself opens the\n * field with the saved value selected, Enter or a press outside keeps the\n * change, and Escape puts the value back. Focus then returns to the edit\n * trigger. The field stays in the document while it is hidden, so its `name`\n * submits with a form.\n *\n * Inside a kit Field, Enter and leaving check the value first: the native\n * constraints of the field (`required`, `type=\"email\"`) and the Field's\n * `validate`. A value that fails stays in its field, and FieldError shows the\n * message. A cancel clears the check again.\n *\n * The parts are flat, as in the rest of the kit: EditableArea holds\n * EditablePreview and EditableInput (or EditableTextarea), and EditableControl\n * holds EditableEditTrigger, and the optional EditableCancelTrigger and\n * EditableSubmitTrigger, Buttons beside the field. The root carries\n * `data-editing` while the field shows.\n *\n * The text never moves (rulebook M8, its Editable note): the field's text\n * stands where the value stands, the value never fades, and only the field's\n * frame fades in and out around it. Focus and keys reach the field at once;\n * with reduced motion the sides switch at once.\n */\nexport function Editable({\n  activationMode = \"focus\",\n  children,\n  className,\n  defaultEdit = false,\n  defaultValue = \"\",\n  disabled: disabledProp = false,\n  edit: editProp,\n  finalFocusEl,\n  form,\n  invalid = false,\n  labels,\n  maxLength,\n  name,\n  onEditChange,\n  onValueChange,\n  onValueCommit,\n  onValueRevert,\n  placeholder,\n  readOnly = false,\n  ref,\n  required,\n  selectOnFocus = true,\n  size,\n  submitMode = \"both\",\n  value: valueProp,\n  ...props\n}: EditableProps): React.ReactElement {\n  const fieldCheck = useFieldValueCheck();\n  // The size the field takes, which the row keeps at rest (editable.css).\n  const resolvedSize = useResolvedControlSize(size);\n  const disabled = disabledProp || fieldCheck.disabled;\n  const interactive = !disabled && !readOnly;\n  const words = { ...defaultEditableLabels, ...labels };\n  const placeholders =\n    typeof placeholder === \"string\"\n      ? { edit: placeholder, preview: placeholder }\n      : { edit: placeholder?.edit, preview: placeholder?.preview };\n\n  const [uncontrolledValue, setUncontrolledValue] =\n    React.useState(defaultValue);\n  const valueControlled = valueProp !== undefined;\n  const value = valueControlled ? valueProp : uncontrolledValue;\n  const [uncontrolledEdit, setUncontrolledEdit] = React.useState(defaultEdit);\n  const editControlled = editProp !== undefined;\n  const editing = editControlled ? editProp : uncontrolledEdit;\n\n  // The swap between the two sides (rulebook M8) starts in the render that\n  // changes the edit state, so no frame shows either side in a wrong state;\n  // with reduced motion there is none (M5).\n  const reduced = useReducedMotionPreference();\n  const [swap, setSwap] = React.useState<EditableSwap>({\n    editing,\n    id: 0,\n    starting: false,\n    swapping: false,\n  });\n  const currentSwap = nextSwap(swap, editing, reduced);\n  if (currentSwap !== swap) setSwap(currentSwap);\n\n  const rootRef = React.useRef<HTMLDivElement | null>(null);\n  const fieldRef = React.useRef<FieldCheckedControl | null>(null);\n  const fieldPaneRef = React.useRef<HTMLSpanElement | null>(null);\n  const previewRef = React.useRef<HTMLElement | null>(null);\n  const previewPlaceRef = React.useRef<PanePlace | null>(null);\n  const pencilPlaceRef = React.useRef<PanePlace | null>(null);\n  const editTriggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const submitTriggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const cancelTriggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const setRootRef = useRefs<HTMLDivElement>(ref, rootRef);\n\n  // The latest value and edit state, for handlers that run before a render.\n  const valueRef = React.useRef(value);\n  const editingRef = React.useRef(editing);\n  // The value from when editing started, which a cancel puts back.\n  const startRef = React.useRef(value);\n  // A controlled edit ends once the parent says so; this is how it ends.\n  const endingRef = React.useRef<{ end: EditableEnd; restore: boolean }>(null);\n  // The field takes focus after the render that shows it; with `defaultEdit`\n  // after the first one.\n  const focusFieldRef = React.useRef(editing);\n  // A save waits for its check; a later save or a cancel replaces it.\n  const submissionRef = React.useRef(0);\n  // Focus that returns to the preview must not start editing again.\n  const quietPreviewRef = React.useRef(false);\n  const latest = React.useRef({\n    finalFocusEl,\n    onEditChange,\n    onValueChange,\n    onValueCommit,\n    onValueRevert,\n  });\n  React.useLayoutEffect(() => {\n    valueRef.current = value;\n    latest.current = {\n      finalFocusEl,\n      onEditChange,\n      onValueChange,\n      onValueCommit,\n      onValueRevert,\n    };\n  });\n\n  /** The editable and, when it has one, its Field with label and error. */\n  const scope = (): HTMLElement | null =>\n    rootRef.current?.closest<HTMLElement>('[data-slot=\"field\"]') ??\n    rootRef.current;\n\n  const setValue = (next: string) => {\n    const limited =\n      maxLength != null && maxLength >= 0 ? next.slice(0, maxLength) : next;\n    if (limited === valueRef.current) return;\n    valueRef.current = limited;\n    if (!valueControlled) setUncontrolledValue(limited);\n    latest.current.onValueChange?.(limited);\n  };\n\n  // Focus returns once the edit has ended; see the effect below.\n  const restoreFocusRef = React.useRef(false);\n  const restoreFocus = () => {\n    restoreFocusRef.current = true;\n  };\n\n  const begin = () => {\n    editingRef.current = true;\n    startRef.current = valueRef.current;\n    focusFieldRef.current = true;\n  };\n\n  const finish = (end: EditableEnd, restore: boolean) => {\n    const result = finishEdit(end, {\n      start: startRef.current,\n      value: valueRef.current,\n    });\n    startRef.current = result.start;\n    if (result.reverted !== undefined) {\n      setValue(result.reverted);\n      // No error of the change that was cancelled stays at rest.\n      const control = fieldRef.current;\n      if (control && fieldCheck.inField)\n        fieldCheck.clear(control, result.reverted);\n    }\n    if (restore) restoreFocus();\n    if (result.committed !== undefined)\n      latest.current.onValueCommit?.(result.committed);\n    if (result.reverted !== undefined)\n      latest.current.onValueRevert?.(result.reverted);\n  };\n\n  const startEdit = () => {\n    if (!interactive || editingRef.current) return;\n    if (editControlled) {\n      latest.current.onEditChange?.(true);\n      return;\n    }\n    begin();\n    setUncontrolledEdit(true);\n    latest.current.onEditChange?.(true);\n  };\n\n  const end = (how: EditableEnd, restore = true) => {\n    if (!editingRef.current) return;\n    if (editControlled) {\n      endingRef.current = { end: how, restore };\n      latest.current.onEditChange?.(false);\n      return;\n    }\n    editingRef.current = false;\n    setUncontrolledEdit(false);\n    finish(how, restore);\n    latest.current.onEditChange?.(false);\n  };\n\n  /** Whether the value may be saved; a failed check keeps the field open. */\n  const checkValue = async (leaving: boolean): Promise<boolean> => {\n    const control = fieldRef.current;\n    if (!control) return true;\n    if (fieldCheck.inField) return fieldCheck.check(control);\n    if (control.checkValidity()) return true;\n    // Without a Field the browser shows its own message, unless focus has\n    // moved on, which the message would pull back.\n    if (!leaving) control.reportValidity();\n    return false;\n  };\n\n  const submit = async (leaving = false) => {\n    if (!interactive || !editingRef.current) return;\n    const submission = ++submissionRef.current;\n    const valid = await checkValue(leaving);\n    if (submission !== submissionRef.current || !editingRef.current) return;\n    if (!valid) {\n      if (!leaving) fieldRef.current?.focus({ preventScroll: true });\n      return;\n    }\n    end(\"submit\");\n  };\n\n  const cancel = () => {\n    if (!editingRef.current) return;\n    submissionRef.current += 1;\n    end(\"cancel\");\n  };\n\n  const onFieldKeyDown = (\n    event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n  ) => {\n    if (event.defaultPrevented || !editingRef.current) return;\n    const action = editableKeyAction(\n      {\n        ctrlKey: event.ctrlKey,\n        isComposing: event.nativeEvent.isComposing || event.keyCode === 229,\n        key: event.key,\n        metaKey: event.metaKey,\n        shiftKey: event.shiftKey,\n      },\n      {\n        apple: isApplePlatform(),\n        multiline: event.currentTarget.localName === \"textarea\",\n        submitMode,\n      },\n    );\n    if (!action) return;\n    event.preventDefault();\n    if (action === \"cancel\") {\n      // The first Escape ends the edit; a surrounding overlay keeps its own.\n      event.stopPropagation();\n      cancel();\n    } else if (action === \"submit\") void submit();\n  };\n\n  // The handlers of the latest render, for the document listeners below.\n  const handlers = React.useRef({ cancel, submit, submitMode });\n  React.useLayoutEffect(() => {\n    handlers.current = { cancel, submit, submitMode };\n  });\n\n  // A controlled edit state starts and ends the edit when the parent changes\n  // it. A submit that the parent accepted saves; any other end cancels.\n  const previousEditing = React.useRef(editing);\n  React.useLayoutEffect(() => {\n    if (previousEditing.current === editing) return;\n    previousEditing.current = editing;\n    if (!editControlled) return;\n    if (editing) {\n      begin();\n      return;\n    }\n    editingRef.current = false;\n    const ending = endingRef.current ?? { end: \"cancel\", restore: true };\n    endingRef.current = null;\n    finish(ending.end, ending.restore);\n  });\n\n  // Focus returns in the frame after the commit that ends the edit: the edit\n  // trigger shows again by then, also while it still fades in, and a press\n  // outside has moved focus to what it pressed, which keeps it. Editing again\n  // before that frame leaves focus in the field.\n  React.useLayoutEffect(() => {\n    if (editing || !restoreFocusRef.current) return undefined;\n    restoreFocusRef.current = false;\n    const id = requestAnimationFrame(() => {\n      const area = scope();\n      if (!area) return;\n      const document = area.ownerDocument;\n      if (keepsFocusElsewhere(document.activeElement, area, document.body))\n        return;\n      const target = editableFocusReturn<HTMLElement>({\n        editTrigger: editTriggerRef.current,\n        finalFocusEl: latest.current.finalFocusEl,\n        preview: previewRef.current,\n      });\n      quietPreviewRef.current = target === previewRef.current;\n      target?.focus({ preventScroll: true });\n      quietPreviewRef.current = false;\n    });\n    return () => cancelAnimationFrame(id);\n    // The edit state decides; the rest comes through refs.\n  }, [editing]);\n\n  // The switch: the side that takes over lays out the row at once and the\n  // side that leaves stays pinned in its box, so the Editable's height follows\n  // from the start (the kit's height follow), and nothing clips the frame.\n  // The frame's fade plays meanwhile, and the switch ends once it and the\n  // pencil's fade have run, timed from the motion tokens.\n  const follow = useHeightFollower(rootRef);\n  const frameFade = React.useRef<Animation | null>(null);\n  React.useLayoutEffect(() => {\n    if (swap.swapping) follow({ clip: false });\n    frameFade.current?.cancel();\n    frameFade.current = null;\n    const pane = fieldPaneRef.current;\n    if (!swap.swapping || !pane) return;\n    frameFade.current = playFrameFade(pane, swap.editing);\n    // Each switch starts and ends once.\n  }, [follow, swap.id, swap.swapping]);\n  React.useEffect(() => () => frameFade.current?.cancel(), []);\n  React.useEffect(() => {\n    if (!swap.starting) return undefined;\n    const id = swap.id;\n    return inSecondFrame(() =>\n      setSwap((current) =>\n        current.id === id ? { ...current, starting: false } : current,\n      ),\n    );\n  }, [swap.id, swap.starting]);\n  React.useEffect(() => {\n    if (!swap.swapping) return undefined;\n    const id = swap.id;\n    const timer = setTimeout(\n      () =>\n        setSwap((current) =>\n          current.id === id\n            ? { ...current, starting: false, swapping: false }\n            : current,\n        ),\n      swapDuration(measureSwapTiming(rootRef.current)),\n    );\n    return () => clearTimeout(timer);\n  }, [swap.id, swap.swapping]);\n\n  // The field takes focus, with its text selected, once it shows.\n  React.useLayoutEffect(() => {\n    if (!editing || !focusFieldRef.current) return;\n    focusFieldRef.current = false;\n    const control = fieldRef.current;\n    if (!control) return;\n    control.focus({ preventScroll: true });\n    if (selectOnFocus) control.select();\n  });\n\n  // While editing, a press or a focus move outside the editable saves the\n  // change when leaving saves, and cancels it otherwise. The Save and Cancel\n  // triggers count as inside wherever they are placed. A touch counts once it\n  // is a tap: a touch that scrolls the page ends without a click.\n  React.useEffect(() => {\n    if (!editing) return undefined;\n    const document = rootRef.current?.ownerDocument;\n    if (!document) return undefined;\n    const outside = (target: EventTarget | null) =>\n      isOutsideEditable(target instanceof Node ? target : null, scope(), [\n        submitTriggerRef.current,\n        cancelTriggerRef.current,\n      ]);\n    const leave = () => {\n      if (!editingRef.current) return;\n      const { cancel: cancelEdit, submit: submitEdit } = handlers.current;\n      if (editableLeaveAction(handlers.current.submitMode) === \"submit\")\n        void submitEdit(true);\n      else cancelEdit();\n    };\n    let tap: ((event: Event) => void) | null = null;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!outside(event.target)) return;\n      if (event.pointerType !== \"touch\") {\n        leave();\n        return;\n      }\n      if (tap) document.removeEventListener(\"click\", tap, true);\n      tap = (click: Event) => {\n        tap = null;\n        if (outside(click.target)) leave();\n      };\n      document.addEventListener(\"click\", tap, { capture: true, once: true });\n    };\n    const onFocusIn = (event: FocusEvent) => {\n      if (outside(event.target)) leave();\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    document.addEventListener(\"focusin\", onFocusIn, true);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      document.removeEventListener(\"focusin\", onFocusIn, true);\n      if (tap) document.removeEventListener(\"click\", tap, true);\n    };\n    // The handlers of the latest render come through their ref.\n  }, [editing]);\n\n  const context: EditableContextValue = {\n    activationMode,\n    cancel,\n    disabled,\n    editing,\n    empty: value.trim() === \"\",\n    form,\n    interactive,\n    invalid,\n    labels: words,\n    maxLength,\n    name,\n    onFieldKeyDown,\n    placeholder: placeholders,\n    previewFocus: () => {\n      if (!quietPreviewRef.current && previewActivates(activationMode, \"focus\"))\n        startEdit();\n    },\n    readOnly,\n    refs: {\n      cancelTrigger: cancelTriggerRef,\n      editTrigger: editTriggerRef,\n      field: fieldRef,\n      fieldPane: fieldPaneRef,\n      pencilPlace: pencilPlaceRef,\n      preview: previewRef,\n      previewPlace: previewPlaceRef,\n      root: rootRef,\n      submitTrigger: submitTriggerRef,\n    },\n    required,\n    setValue,\n    size,\n    startEdit,\n    submit: () => void submit(),\n    swap: { starting: currentSwap.starting, swapping: currentSwap.swapping },\n    value,\n  };\n\n  return (\n    <EditableContext.Provider value={context}>\n      <div\n        className={cn(\n          slotClass(\"editable\", props),\n          swapContainerClassName,\n          className,\n        )}\n        data-disabled={disabled ? \"\" : undefined}\n        data-editing={editing ? \"\" : undefined}\n        data-invalid={invalid ? \"\" : undefined}\n        data-readonly={readOnly ? \"\" : undefined}\n        data-size={resolvedSize}\n        data-slot=\"editable\"\n        ref={setRootRef}\n        {...props}\n      >\n        {children}\n      </div>\n    </EditableContext.Provider>\n  );\n}\n\n/**\n * Holds the preview and the field, which take turns in one place. While\n * editing it takes the row beside the Save and Cancel triggers.\n */\nexport function EditableArea({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  const context = useEditableContext(\"EditableArea\");\n  return (\n    <div\n      className={cn(slotClass(\"editable-area\", props), className)}\n      data-disabled={context.disabled ? \"\" : undefined}\n      data-editing={context.editing ? \"\" : undefined}\n      data-placeholder-shown={context.empty ? \"\" : undefined}\n      data-slot=\"editable-area\"\n      {...props}\n    />\n  );\n}\n\n/**\n * The value at rest, on one line that ends with an ellipsis, with the full\n * value as its title while it is cut; beside an EditableTextarea it wraps.\n * An empty value shows the preview placeholder. Children replace the text it\n * shows, such as a formatted value. It is hidden while editing and paints\n * the text above the field while they switch, and takes focus only while it\n * can start editing.\n */\nexport function EditablePreview({\n  children,\n  className,\n  onClick,\n  onDoubleClick,\n  onFocus,\n  ref,\n  style,\n  ...props\n}: React.ComponentProps<\"span\">): React.ReactElement {\n  const context = useEditableContext(\"EditablePreview\");\n  const text = context.empty\n    ? (context.placeholder.preview ?? \"\")\n    : context.value;\n  const [truncationRef, truncated] = useTruncated<HTMLSpanElement>(\n    !context.editing,\n  );\n  const {\n    props: { className: swapClassName, style: pin, ...swapProps },\n    ref: swapRef,\n  } = useSwapPart<HTMLSpanElement>(\n    context,\n    \"rest\",\n    \"text\",\n    context.refs.previewPlace,\n  );\n  const setRef = useRefs<HTMLSpanElement>(\n    ref,\n    context.refs.preview as React.RefObject<HTMLSpanElement | null>,\n    truncationRef,\n    swapRef,\n  );\n  const activates = previewCanActivate(\n    context.activationMode,\n    context.interactive,\n  );\n  return (\n    <span\n      className={cn(\n        slotClass(\"editable-preview\", props),\n        focusRingRecipe(),\n        swapClassName,\n        className,\n      )}\n      data-activation={activates ? context.activationMode : undefined}\n      data-disabled={context.disabled ? \"\" : undefined}\n      data-placeholder-shown={context.empty ? \"\" : undefined}\n      data-readonly={context.readOnly ? \"\" : undefined}\n      data-slot=\"editable-preview\"\n      onClick={(event) => {\n        onClick?.(event);\n        if (\n          !event.defaultPrevented &&\n          context.interactive &&\n          previewActivates(context.activationMode, \"click\")\n        )\n          context.startEdit();\n      }}\n      onDoubleClick={(event) => {\n        onDoubleClick?.(event);\n        if (\n          !event.defaultPrevented &&\n          context.interactive &&\n          previewActivates(context.activationMode, \"dblclick\")\n        )\n          context.startEdit();\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        if (!event.defaultPrevented && context.interactive)\n          context.previewFocus();\n      }}\n      ref={setRef}\n      tabIndex={activates ? 0 : undefined}\n      title={truncated ? text : undefined}\n      {...props}\n      {...swapProps}\n      style={withPin(style, pin)}\n    >\n      {children ?? text}\n    </span>\n  );\n}\n\nexport type EditableInputProps = Omit<InputProps, \"defaultValue\" | \"value\">;\n\n/**\n * The single-line field: the kit Input, filled with the value. It stays in\n * the document while hidden, so its `name` submits with a form. Enter saves\n * and Escape cancels; Enter never submits a form around it.\n */\nexport function EditableInput({\n  onKeyDown,\n  onValueChange,\n  ref,\n  size,\n  ...props\n}: EditableInputProps): React.ReactElement {\n  const context = useEditableContext(\"EditableInput\");\n  const setRef = useRefs<HTMLInputElement>(\n    ref,\n    context.refs.field as React.RefObject<HTMLInputElement | null>,\n  );\n  const resolvedSize = size ?? context.size;\n  return (\n    <EditableSwapPane context={context} side=\"edit\" slot=\"editable-field-pane\">\n      <Input\n        disabled={context.disabled}\n        form={context.form}\n        maxLength={context.maxLength}\n        name={context.name}\n        placeholder={context.placeholder.edit}\n        readOnly={context.readOnly}\n        required={context.required}\n        {...(resolvedSize !== undefined ? { size: resolvedSize } : {})}\n        {...(context.invalid ? { \"aria-invalid\": true } : {})}\n        {...props}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          context.onFieldKeyDown(event);\n        }}\n        onValueChange={(value, details) => {\n          onValueChange?.(value, details);\n          context.setValue(value);\n        }}\n        ref={setRef}\n        value={context.value}\n      />\n    </EditableSwapPane>\n  );\n}\n\nexport type EditableTextareaProps = Omit<\n  TextareaProps,\n  \"defaultValue\" | \"value\"\n>;\n\n/**\n * The multi-line field: the kit Textarea, filled with the value. Enter starts\n * a new line; Cmd+Enter on Apple systems, or Ctrl+Enter elsewhere, saves, and\n * Escape cancels.\n */\nexport function EditableTextarea({\n  onChange,\n  onKeyDown,\n  ref,\n  size,\n  ...props\n}: EditableTextareaProps): React.ReactElement {\n  const context = useEditableContext(\"EditableTextarea\");\n  const setRef = useRefs<HTMLTextAreaElement>(\n    ref,\n    context.refs.field as React.RefObject<HTMLTextAreaElement | null>,\n  );\n  return (\n    <EditableSwapPane context={context} side=\"edit\" slot=\"editable-field-pane\">\n      <Textarea\n        disabled={context.disabled}\n        form={context.form}\n        maxLength={context.maxLength}\n        name={context.name}\n        placeholder={context.placeholder.edit}\n        readOnly={context.readOnly}\n        required={context.required}\n        size={size ?? context.size}\n        {...(context.invalid ? { \"aria-invalid\": true } : {})}\n        {...props}\n        onChange={(event) => {\n          onChange?.(event);\n          context.setValue(event.currentTarget.value);\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          context.onFieldKeyDown(event);\n        }}\n        ref={setRef}\n        value={context.value}\n      />\n    </EditableSwapPane>\n  );\n}\n\n/**\n * Holds the triggers: the edit trigger at rest, and the optional Cancel and\n * Save beside the field while editing. It lays out no box of its own, so its\n * triggers take the Editable's row and spacing.\n */\nexport function EditableControl({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  const context = useEditableContext(\"EditableControl\");\n  return (\n    <div\n      className={cn(slotClass(\"editable-control\", props), className)}\n      data-editing={context.editing ? \"\" : undefined}\n      data-slot=\"editable-control\"\n      {...props}\n    />\n  );\n}\n\n/**\n * Starts editing: a ghost icon Button with the pencil, named by `labels.edit`\n * or `aria-label`. It is hidden while editing, and disabled while the value\n * cannot be edited. Beside an empty value without a placeholder it stands\n * where the field's caret will be, so it leaves at once instead of fading.\n */\nexport function EditableEditTrigger({\n  \"aria-label\": ariaLabel,\n  children,\n  onClick,\n  ref,\n  ...props\n}: ButtonProps): React.ReactElement {\n  const context = useEditableContext(\"EditableEditTrigger\");\n  const setRef = useRefs<HTMLButtonElement>(ref, context.refs.editTrigger);\n  return (\n    <EditableSwapPane\n      context={context}\n      leaveAtOnce={context.empty && !context.placeholder.preview}\n      placeRef={context.refs.pencilPlace}\n      side=\"rest\"\n      slot=\"editable-trigger-pane\"\n    >\n      <Button\n        aria-label={\n          ariaLabel ?? (children == null ? context.labels.edit : undefined)\n        }\n        data-slot=\"editable-edit-trigger\"\n        disabled={!context.interactive}\n        size=\"icon-sm\"\n        variant=\"ghost\"\n        {...props}\n        onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.startEdit();\n        }}\n        ref={setRef}\n      >\n        {children ?? <PencilIcon aria-hidden=\"true\" />}\n      </Button>\n    </EditableSwapPane>\n  );\n}\n\n/**\n * The words of a Save or Cancel trigger: a Button of the Editable's size that\n * says its word, unless children replace it.\n */\nfunction triggerLook(\n  children: React.ReactNode,\n  {\n    size,\n    variant,\n    word,\n  }: {\n    size: SiteplaneControlSize | undefined;\n    variant: \"accent\" | \"ghost\";\n    word: string;\n  },\n): {\n  children: React.ReactNode;\n  size?: SiteplaneControlSize;\n  variant: \"accent\" | \"ghost\";\n} {\n  return {\n    children: children ?? word,\n    ...(size !== undefined ? { size } : {}),\n    variant,\n  };\n}\n\n/**\n * Optional: saves the change, an accent Button beside the field that says\n * `labels.submit`, shown while editing. Inside a Field it checks the value\n * first.\n */\nexport function EditableSubmitTrigger({\n  children,\n  onClick,\n  ref,\n  ...props\n}: ButtonProps): React.ReactElement {\n  const context = useEditableContext(\"EditableSubmitTrigger\");\n  const setRef = useRefs<HTMLButtonElement>(ref, context.refs.submitTrigger);\n  const { children: content, ...look } = triggerLook(children, {\n    size: context.size,\n    variant: \"accent\",\n    word: context.labels.submit,\n  });\n  return (\n    <EditableSwapPane\n      context={context}\n      side=\"edit\"\n      slot=\"editable-trigger-pane\"\n    >\n      <Button\n        data-slot=\"editable-submit-trigger\"\n        disabled={context.disabled}\n        {...look}\n        {...props}\n        onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.submit();\n        }}\n        ref={setRef}\n      >\n        {content}\n      </Button>\n    </EditableSwapPane>\n  );\n}\n\n/**\n * Optional: cancels the change and puts the value back, a ghost Button beside\n * the field that says `labels.cancel`, shown while editing.\n */\nexport function EditableCancelTrigger({\n  children,\n  onClick,\n  ref,\n  ...props\n}: ButtonProps): React.ReactElement {\n  const context = useEditableContext(\"EditableCancelTrigger\");\n  const setRef = useRefs<HTMLButtonElement>(ref, context.refs.cancelTrigger);\n  const { children: content, ...look } = triggerLook(children, {\n    size: context.size,\n    variant: \"ghost\",\n    word: context.labels.cancel,\n  });\n  return (\n    <EditableSwapPane\n      context={context}\n      side=\"edit\"\n      slot=\"editable-trigger-pane\"\n    >\n      <Button\n        data-slot=\"editable-cancel-trigger\"\n        disabled={context.disabled}\n        {...look}\n        {...props}\n        onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.cancel();\n        }}\n        ref={setRef}\n      >\n        {content}\n      </Button>\n    </EditableSwapPane>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/editable/editable.tsx"},{"path":"packages/ui/src/components/editable/editable-interaction.ts","content":"/*\n * Kit-internal: the decisions behind Editable's keys, presses and focus, kept\n * apart from the component so they can be checked on their own. Not public\n * API: the editable index does not export this module.\n */\n\n/**\n * What starts editing from the preview: focusing it, a click, a double click,\n * or nothing, so that only the edit trigger does.\n */\nexport type EditableActivationMode = \"focus\" | \"dblclick\" | \"click\" | \"none\";\n\n/**\n * What saves a change: Enter and leaving the editable (`both`), Enter alone\n * (`enter`), leaving alone (`blur`), or only the submit trigger (`none`).\n * Leaving an editable that does not save then cancels the change.\n */\nexport type EditableSubmitMode = \"both\" | \"enter\" | \"blur\" | \"none\";\n\n/** What an edit ends with. */\nexport type EditableEnd = \"submit\" | \"cancel\";\n\n/** Whether Enter saves in a submit mode. */\nexport function submitsOnEnter(mode: EditableSubmitMode): boolean {\n  return mode === \"both\" || mode === \"enter\";\n}\n\n/** Whether leaving the editable saves in a submit mode. */\nexport function submitsOnBlur(mode: EditableSubmitMode): boolean {\n  return mode === \"both\" || mode === \"blur\";\n}\n\n/** The parts of a key press that decide what it does. */\nexport type EditableKeyPress = {\n  ctrlKey: boolean;\n  /** True while an input method composes text, as for Japanese. */\n  isComposing: boolean;\n  key: string;\n  metaKey: boolean;\n  shiftKey: boolean;\n};\n\n/**\n * What a key press in the field does: `submit`, `cancel`, `block` (nothing,\n * but the browser must not act either) or nothing at all.\n *\n * Escape cancels. In a single-line field Enter saves when the submit mode\n * allows it and Shift and Meta are up; any other Enter is blocked, so that it\n * never submits a form around the editable with a value that is not saved. In\n * a textarea Enter starts a new line, and Cmd+Enter on Apple systems, or\n * Ctrl+Enter elsewhere, saves. An input method that composes text keeps its\n * keys.\n */\nexport function editableKeyAction(\n  press: EditableKeyPress,\n  {\n    apple,\n    multiline,\n    submitMode,\n  }: { apple: boolean; multiline: boolean; submitMode: EditableSubmitMode },\n): EditableEnd | \"block\" | undefined {\n  if (press.isComposing) return undefined;\n  if (press.key === \"Escape\") return \"cancel\";\n  if (press.key !== \"Enter\") return undefined;\n  if (multiline)\n    return submitsOnEnter(submitMode) && (apple ? press.metaKey : press.ctrlKey)\n      ? \"submit\"\n      : undefined;\n  return submitsOnEnter(submitMode) && !press.shiftKey && !press.metaKey\n    ? \"submit\"\n    : \"block\";\n}\n\n/** What leaving an editable that is editing does in a submit mode. */\nexport function editableLeaveAction(mode: EditableSubmitMode): EditableEnd {\n  return submitsOnBlur(mode) ? \"submit\" : \"cancel\";\n}\n\n/** The part of a DOM node that the outside check needs. */\ntype Container = { contains(other: Node | null): boolean };\n\n/**\n * Whether a press or a focus move lands outside the editable: on a connected\n * node outside `scope` (the editable, or the Field around it with its label,\n * description and error) that is not inside an `ignored` part, such as a Save\n * or Cancel trigger placed elsewhere.\n */\nexport function isOutsideEditable(\n  target: Node | null,\n  scope: Container | null,\n  ignored: readonly (Container | null | undefined)[],\n): boolean {\n  if (!target || !scope || !target.isConnected) return false;\n  if (scope.contains(target)) return false;\n  return !ignored.some((part) => part?.contains(target));\n}\n\n/**\n * Whether focus stays where it is when an edit ends instead of returning to\n * the editable: it went to an element outside the editable, such as the field\n * that was clicked or tabbed to. Focus on the page itself, or on a part of the\n * editable that just hid, returns.\n */\nexport function keepsFocusElsewhere(\n  active: Element | null,\n  scope: Container,\n  body: Element | null,\n): boolean {\n  return active !== null && active !== body && !scope.contains(active);\n}\n\n/** Whether the preview can start editing, so that it takes focus. */\nexport function previewCanActivate(\n  mode: EditableActivationMode,\n  interactive: boolean,\n): boolean {\n  return interactive && mode !== \"none\";\n}\n\n/** Whether an event on the preview starts editing in an activation mode. */\nexport function previewActivates(\n  mode: EditableActivationMode,\n  event: \"click\" | \"dblclick\" | \"focus\",\n): boolean {\n  return mode === event;\n}\n\n/**\n * Where focus returns once an edit ends: `finalFocusEl`, else the edit\n * trigger, else the preview.\n */\nexport function editableFocusReturn<Target>({\n  editTrigger,\n  finalFocusEl,\n  preview,\n}: {\n  editTrigger: Target | null;\n  finalFocusEl: (() => Target | null) | undefined;\n  preview: Target | null;\n}): Target | null {\n  return finalFocusEl?.() ?? editTrigger ?? preview;\n}\n\n/**\n * The values once an edit ends. A submit keeps the value and reports it as\n * committed; a cancel puts back the value from when editing started, the empty\n * value too, and reports that one as reverted.\n */\nexport function finishEdit(\n  end: EditableEnd,\n  { start, value }: { start: string; value: string },\n): { committed?: string; reverted?: string; start: string; value: string } {\n  return end === \"submit\"\n    ? { committed: value, start: value, value }\n    : { reverted: start, start, value: start };\n}\n\n/**\n * The two sides of the swap: what shows at rest (the preview and the pencil)\n * and what shows while editing (the field and its triggers).\n */\nexport type EditableSide = \"rest\" | \"edit\";\n\n/**\n * Where a part of one side is in the swap (rulebook M8): `shown` and `hidden`\n * at rest, `leaving` while it fades out in its old place, out of the flow,\n * and `entering` while it fades in at its new place.\n */\nexport type EditableSwapPhase = \"shown\" | \"entering\" | \"leaving\" | \"hidden\";\n\n/**\n * The swap between the sides: the edit state it shows, a count of the swaps,\n * whether it runs, and whether it is in its first frames.\n */\nexport type EditableSwap = {\n  editing: boolean;\n  id: number;\n  starting: boolean;\n  swapping: boolean;\n};\n\n/**\n * The swap once the edit state is `editing`: the same while it shows that\n * state, else a new swap, which with reduced motion switches at once (M5).\n */\nexport function nextSwap(\n  swap: EditableSwap,\n  editing: boolean,\n  reduced: boolean,\n): EditableSwap {\n  if (swap.editing === editing) return swap;\n  return { editing, id: swap.id + 1, starting: !reduced, swapping: !reduced };\n}\n\n/** The phase of a side's parts while the edit state is `editing`. */\nexport function swapPhase(\n  side: EditableSide,\n  { editing, swapping }: { editing: boolean; swapping: boolean },\n): EditableSwapPhase {\n  const current = (side === \"edit\") === editing;\n  if (current) return swapping ? \"entering\" : \"shown\";\n  return swapping ? \"leaving\" : \"hidden\";\n}\n\n/**\n * The attributes of a part in a phase. A part that leaves is inert and hidden\n * from assistive technology, so nothing can be pressed or focused in it while\n * it fades; a part that enters takes focus and keys at once, also while it is\n * still transparent. `starting` is true in the first frames of a swap, which\n * show where the fade in starts (`data-starting-style`, the kit's swap fade).\n */\nexport function swapAttributes(\n  phase: EditableSwapPhase,\n  starting: boolean,\n): {\n  \"aria-hidden\"?: true;\n  \"data-ending-style\"?: \"\";\n  \"data-starting-style\"?: \"\";\n  hidden?: true;\n  inert?: true;\n} {\n  if (phase === \"hidden\") return { hidden: true };\n  if (phase === \"leaving\")\n    return { \"aria-hidden\": true, \"data-ending-style\": \"\", inert: true };\n  if (phase === \"entering\" && starting) return { \"data-starting-style\": \"\" };\n  return {};\n}\n\n/**\n * How long a swap lasts in milliseconds, from its timing: the old side fades\n * out within the leave, the new side fades in after its delay. Durations are\n * in seconds, as the kit's measured transitions give them.\n */\nexport function swapDuration(timing: {\n  enter: { delay?: number; duration?: number };\n  leave: { duration?: number };\n}): number {\n  const enter = (timing.enter.delay ?? 0) + (timing.enter.duration ?? 0);\n  return Math.round(Math.max(enter, timing.leave.duration ?? 0) * 1000);\n}\n\n/** A box against the Editable, in pixels. */\nexport type EditableBox = {\n  height: number;\n  left: number;\n  top: number;\n  width: number;\n};\n\n/**\n * How far the field must move so that its text stands where the value stood:\n * the start of its text on the start of the value, and a single line centred\n * on the value's line, the first line of a textarea on the value's first line.\n * The value at rest stands where the field's text stands, so the distance is\n * none; a field that a className changed moves by it, as margins on every\n * side, so its text still never moves.\n */\nexport function inPlaceAlignment(\n  value: EditableBox,\n  field: { multiline: boolean; text: EditableBox },\n): { x: number; y: number } {\n  const x = value.left - field.text.left;\n  const y = field.multiline\n    ? value.top - field.text.top\n    : value.top + value.height / 2 - (field.text.top + field.text.height / 2);\n  return { x, y };\n}\n\n/**\n * Calls `measure` whenever a web font of the document finishes loading, until\n * the returned stop runs. A font that arrives late changes the width of the\n * text it sets, and with it the place of what stands beside the text. A\n * document without a font set listens to nothing.\n */\nexport function onFontsLoaded(\n  document: Document,\n  measure: () => void,\n): () => void {\n  const fonts = (document as { fonts?: FontFaceSet }).fonts;\n  if (!fonts?.addEventListener) return () => undefined;\n  fonts.addEventListener(\"loadingdone\", measure);\n  return () => fonts.removeEventListener(\"loadingdone\", measure);\n}\n\n/** Whether the system is an Apple one, where Cmd stands in for Ctrl. */\nexport function isApplePlatform(): boolean {\n  if (typeof navigator === \"undefined\") return false;\n  const platform =\n    (navigator as Navigator & { userAgentData?: { platform?: string } })\n      .userAgentData?.platform ?? navigator.platform;\n  return /mac|iphone|ipad|ipod/i.test(platform ?? \"\");\n}\n","type":"registry:ui","target":"~/src/siteplane/components/editable/editable-interaction.ts"},{"path":"packages/ui/src/components/editable/editable.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* At rest the value and its edit trigger share one row, centred on each\n   * other and 8 px apart, so the trigger's focus ring keeps 4 px clear of the\n   * text; the value takes what the row leaves. The value stands where the\n   * field's text stands (Lukas, 30 October 2026): the row is as tall as a\n   * one-line field of its size, so the frame that comes around the value\n   * stands where every field stands, flush with its label's start and 8 px\n   * under it. The defaults have no specificity, so the rules below and a\n   * className win. The Buttons and the field keep their own states and focus.\n   * While the sides switch, the parts at rest stay pinned against the row\n   * (holdPanePlace) while the field lays it out, and the row follows its\n   * height as a swap container does (motion.css). */\n  :where(.sp-editable) {\n    display: flex;\n    width: 100%;\n    min-width: 0;\n    min-height: var(--sp-rw-control-md);\n    align-items: center;\n    column-gap: var(--sp-rw-space-2);\n  }\n\n  /* The heights of the Input's sizes (design-rework.css, Input). */\n  .sp-editable[data-size=\"sm\"] {\n    min-height: var(--sp-rw-control-sm);\n  }\n\n  .sp-editable[data-size=\"lg\"] {\n    min-height: var(--sp-rw-control-lg);\n  }\n\n  .sp-editable[data-size=\"xl\"] {\n    min-height: var(--sp-rw-control-xl);\n  }\n\n  /* While editing the field takes the row, and the optional Cancel and Save\n   * stand beside it at its top, so a taller textarea, a hint or an error\n   * under the field moves nothing beside it. */\n  .sp-editable[data-editing] {\n    align-items: flex-start;\n  }\n\n  :where(.sp-editable-area) {\n    display: flex;\n    min-width: 0;\n    flex: 0 1 auto;\n  }\n\n  .sp-editable-area[data-editing] {\n    flex: 1 1 auto;\n  }\n\n  /* A value of several lines stays at the top of the row, also while the row\n   * follows the height of a textarea that closes, so its text never moves. */\n  .sp-editable-area:has(.sp-base-textarea) {\n    align-self: flex-start;\n  }\n\n  /* The panes of the switch: the field's takes the room of the area, and a\n   * trigger's is as large as its Button and, like a Button in a row, never\n   * shrinks. */\n  :where(.sp-editable-field-pane) {\n    display: flex;\n    min-width: 0;\n    flex: 1 1 auto;\n  }\n\n  :where(.sp-editable-trigger-pane) {\n    display: flex;\n    flex-shrink: 0;\n  }\n\n  /* The value at rest: body text in the field's text colour, so it reads the\n   * same as the field that edits it and the two texts take turns pixel for\n   * pixel, on one line that ends with an ellipsis, as a dense row's value\n   * does (K5). It starts where the Input's text starts, at every size\n   * (design-rework.css, `--sp-input-padding-inline`). */\n  :where(.sp-editable-preview) {\n    min-width: 0;\n    overflow: hidden;\n    padding-inline-start: var(--sp-rw-space-3);\n    color: var(--sp-rw-fg-primary);\n    font-size: var(--sp-rw-font-14);\n    font-weight: var(--sp-rw-font-weight-default);\n    line-height: var(--sp-rw-line-21);\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  /* The text of a multi-line value wraps and keeps its line breaks, inside\n   * the insets of the Textarea's size (design-rework.css, Textarea), so it\n   * stands where the textarea's text stands. */\n  .sp-editable-area:has(.sp-base-textarea) > .sp-editable-preview {\n    padding: var(--sp-rw-space-2) var(--sp-rw-space-3);\n    overflow-wrap: anywhere;\n    white-space: pre-wrap;\n  }\n\n  .sp-editable[data-size=\"sm\"]\n    .sp-editable-area:has(.sp-base-textarea)\n    > .sp-editable-preview {\n    padding: var(--sp-rw-space-1) var(--sp-rw-space-2-5);\n  }\n\n  .sp-editable[data-size=\"lg\"]\n    .sp-editable-area:has(.sp-base-textarea)\n    > .sp-editable-preview {\n    padding-block: var(--sp-rw-space-2-5);\n  }\n\n  .sp-editable[data-size=\"xl\"]\n    .sp-editable-area:has(.sp-base-textarea)\n    > .sp-editable-preview {\n    padding: var(--sp-rw-space-3) var(--sp-rw-space-4);\n  }\n\n  /* An empty value shows the preview placeholder in the placeholder colour\n   * of the field. */\n  .sp-editable-preview[data-placeholder-shown] {\n    color: var(--sp-rw-fg-tertiary);\n  }\n\n  /* A preview that starts editing on a press or on focus shows the text\n   * cursor; with activationMode=\"none\" it is plain text. */\n  .sp-editable-preview[data-activation] {\n    cursor: text;\n  }\n\n  /* While the sides switch only the value's text paints, above the frame\n   * that fades: the field's own text and placeholder wait, transparent, and\n   * take over in one frame once the switch is over. */\n  .sp-editable-field-pane[data-switching] .sp-input,\n  .sp-editable-field-pane[data-switching] .sp-textarea,\n  .sp-editable-field-pane[data-switching] .sp-input::placeholder,\n  .sp-editable-field-pane[data-switching] .sp-textarea::placeholder {\n    color: transparent;\n  }\n\n  /* The control lays out no box: the panes of its triggers are items of the\n   * row, spaced by its gap. */\n  :where(.sp-editable-control) {\n    display: contents;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/editable/editable.css"},{"path":"packages/ui/src/components/editable/index.ts","content":"export * from \"./editable\";\n","type":"registry:ui","target":"~/src/siteplane/components/editable/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/editable.md","categories":["Forms & selection"],"meta":{"avoidWhen":["Avoid for settings forms; keep their fields open in a Form with one Save for each section.","Avoid for a value with more than one part, such as an address; edit it in a Dialog with a form."],"description":"Show a value as text and edit it in place in its field.","knownLimitations":["The field takes the width of the row, not the width of its text; there is no auto-resizing field.","Inside a Field, an interaction outside the editable that would save an invalid value keeps the field open with its error instead of saving it."],"providerNotes":[],"releaseStatus":"stable","searchTerms":["editable","inline edit","edit in place","click to edit","editable text","rename"],"useWhen":["Use for a single value shown as text that people rename now and then, such as a project or page title or the name of a list item.","Use inside a Field with FieldError when the value must pass a check before it is saved."]}}