{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "json-input",
  "title": "JSON Input",
  "description": "A textarea for editing JSON by hand that says exactly where the mistake is — line, column and a caret you can jump to. Reach for it wherever a page has to let someone type a JSON value in: a setting or metadata field in an admin panel, a feature flag's payload, a webhook's test body, the request body in an API client or API playground, a job's input in a queue dashboard, a JSON/JSONB database column in an internal tool, an LLM tool-call's arguments, a connector or CI config form, a rules or mapping blob, seed data in a developer console. Common asks it answers: \"json editor react\", \"json input component\", \"json textarea react\", \"edit json in a textarea\", \"react json editor shadcn\", \"shadcn json input\", \"validate json as you type\", \"json linter component\", \"show the line number of a JSON syntax error\", \"JSON.parse error position\", \"get line and column from SyntaxError\", \"highlight json syntax error react\", \"pretty print json button\", \"format json textarea\", \"tab key indent textarea react\", \"textarea tab inserts spaces\", \"jsoneditor react alternative\", \"react-json-editor-ajrm alternative\", \"codemirror json too heavy\", \"monaco editor just for json\", \"can't type a comma in my json input\", \"json input rewinds what I type\", \"controlled textarea resets while typing json\", \"cursor jumps to end json textarea\", \"react json form field\". shadcn/ui has nothing for JSON: JSON.parse, JSON.stringify, SyntaxError, selectionStart, setSelectionRange and indentation each appear exactly zero times across the 223,287 bytes of component source its registry serves (62 of its 63 indexed items are actually served; questionnaire is listed but 404s on both style tracks), and its six mentions of textarea are the bare styled element itself, which knows nothing about what is typed into it. So this gets hand-written, and the hand-written version has one bug that makes the field unusable and one that makes it unhelpful. The first is the round trip. The obvious shape — parse the text, hand the object up, render JSON.stringify(object) back into the field — cannot be typed in, and not in a way a demo reveals: you have to reach a second key before it bites. The moment the text reads {\"a\": 1, it does not parse, there is no object to stringify, and whatever the parent hands back replaces what was being typed; even while it does parse, the round trip rewrites the text under the caret, so {\"a\": 1 becomes {\"a\":1} mid-keystroke and the comma can never be followed. Here the string is the value: `value` is text, parsing is a check that never touches it, and the parsed value is handed over as metadata through onParse. Keep the string in your own state — const [text, setText] = useState(…), not JSON.stringify(config) — and nothing fights the caret. The second is the error location, and the browser will not give it to you. No engine puts a position property on the SyntaxError that JSON.parse throws — the error carries message and stack and nothing else. The message is not an interface either: V8 writes \"Expected ',' or ']' after array element in JSON at position 5 (line 1 column 6)\" for some inputs and \"Unexpected token ']', \\\"[1,2,]\\\" is not valid JSON\" for others, with no position at all in the second form; Firefox writes \"at line 1 column 6 of the JSON data\"; JavaScriptCore writes \"JSON Parse error: Expected ']'\" and never includes a position. A component that scrapes the message therefore gets no location on Safari and no location on Chrome either for a whole class of mistakes — the trailing comma among them, which is the single most common thing wrong with hand-edited JSON. So this walks the RFC 8259 grammar itself, in one pass with an explicit stack rather than recursion so a deeply nested paste cannot overflow it, and reports the offset of the first offending character with a message written once and read the same way in every browser. JSON.parse stays the authority on whether the text is valid and the scanner is only ever asked where a failure is, so it can never reject a document the runtime would have accepted. Nothing is validated mid-keystroke: half-typed JSON is always broken, and a field that turns red on the second character is red the whole time it is in use, so the verdict waits for a pause (debounceMs, 200ms) and shows nothing in the meantime rather than a stale answer — which is also what keeps a few hundred kilobytes of payload from being re-parsed on every keypress. Formatting only ever happens when asked, through the Format button or formatOnBlur, never while typing, because reformatting replaces the whole value and throws the caret to the end of the document. Tab indents and Shift+Tab outdents every line the selection touches — and a selection that ends at the next line's first column stops short of it, so the closing brace you dragged past is not pushed over. A Tab that is always captured is a keyboard trap, so Escape releases it: Escape then Tab moves focus on, as WCAG 2.1.2 requires, and the release is announced in the field's own sr-only description instead of being left to be discovered. Escape is not swallowed, so it still reaches the dialog the editor sits in. The caret is restored after every indent through a layout effect rather than inside the key handler, because the value React commits replaces the whole contents of the textarea and a browser parks the caret at the end of a textarea whose value was replaced. Accessibility and form wiring: aria-invalid once the verdict is in and not before, the status line is aria-live=\"polite\" and only ever updated after the pause, a caller's own aria-describedby is kept alongside the status and the hint rather than overwritten, the error message carries \"Line 3, column 1\" as text rather than relying on colour, and name submits the text like any textarea. autoCapitalize, autoCorrect, autoComplete and spellCheck default off, because a mobile keyboard rewrites a key name between the keypress and the change event and spellcheck underlines every identifier in the document; all four are overridable. parseJsonInput, scanJson and formatJson are exported as pure functions for the places the same answer is wanted outside React — a route handler checking what arrived, a CLI validating a config file, a test asserting on a fixture. Styled with shadcn tokens (input, ring, destructive, muted-foreground) for light and dark; className merges rather than fights; one file, zero dependencies, no Radix, no editor library, no JSON parser of its own. Distinct from json-viewer, which renders a parsed value as a read-only collapsible tree: that one is for showing a payload the reader did not write, this one is for changing it. Pair them when a panel both displays and edits. Distinct from code-block, which shows JSON as static text to copy, and from autosize-textarea, which grows to its content but knows nothing about what is in it. Distinct from official shadcn/ui's textarea, which is the plain field this is built on and nothing more: it accepts any string, has no idea whether it holds JSON, and reports no error, no line and no column — so validating what someone typed into it, finding where the mistake is, and getting a parsed value back out are all still yours to write.",
  "files": [
    {
      "path": "registry/ui/json-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Where a document stops being valid JSON, and what is wrong there.\n *\n * `offset` is a character index into the text, so it can be handed straight to\n * `setSelectionRange` to put the caret on the problem. All three positions are nullable\n * for one case only: the runtime rejected the text and this file's own scanner could not\n * say where (see `parseJsonInput`). A null position means \"no location to jump to\" and is\n * never a guess — a caret sent to the wrong character is worse than one that stays put.\n */\nexport interface JsonError {\n  message: string\n  offset: number | null\n  /** 1-based. */\n  line: number | null\n  /** 1-based, counted in characters from the start of the line. */\n  column: number | null\n}\n\nexport type JsonParseResult =\n  | { valid: true; value: unknown; error: null }\n  | { valid: false; value: undefined; error: JsonError }\n\ntype ScanResult = { ok: true } | { ok: false; offset: number; message: string }\n\nconst END = \"Unexpected end of input\"\n\n/**\n * Validates JSON text and reports the offset of the first thing that is wrong.\n *\n * This exists because **the browser cannot tell you where the error is.** `JSON.parse`\n * throws a `SyntaxError` carrying nothing but `message` and `stack` — there is no\n * `position` property on it in any engine — and the message itself is not a stable\n * interface. V8 writes \"Expected ',' or ']' after array element in JSON at position 5\n * (line 1 column 6)\" for some inputs but \"Unexpected token ']', \\\"[1,2,]\\\" is not valid\n * JSON\" for others, with no position at all in the second form; Firefox writes \"at line 1\n * column 6 of the JSON data\"; JavaScriptCore writes \"JSON Parse error: Expected ']'\" and\n * never includes a position. So a component that scrapes the message gets no location on\n * Safari, and no location on Chrome either for a whole class of errors — including the\n * trailing comma, which is the single most common mistake someone makes editing JSON by\n * hand.\n *\n * The grammar is RFC 8259 and it is small enough to walk directly, which is what this\n * does: one pass, no allocation per token, an explicit stack rather than recursion so a\n * deeply nested document cannot overflow it. Messages are written here rather than\n * quoted from the engine, so the same mistake reads the same way in every browser.\n *\n * **`JSON.parse` stays the authority on whether text is valid** — this scanner is only\n * ever consulted to locate a failure that has already happened (see `parseJsonInput`).\n * That is deliberate: a bug here can then cost a worse error message, but it can never\n * make the component reject a document the runtime would have accepted.\n */\nexport function scanJson(text: string): ScanResult {\n  const n = text.length\n  let i = 0\n\n  const fail = (offset: number, message: string): ScanResult => ({\n    ok: false,\n    offset: Math.min(offset, n),\n    message,\n  })\n\n  // The four characters JSON counts as whitespace. Deliberately not `\\s`, which also\n  // matches a non-breaking space, a form feed and a vertical tab — none of which\n  // `JSON.parse` allows between tokens, so accepting them here would make the scanner\n  // disagree with the runtime about perfectly ordinary pasted-from-a-word-processor text.\n  const ws = () => {\n    while (i < n) {\n      const c = text.charCodeAt(i)\n      if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++\n      else break\n    }\n  }\n\n  const isDigit = (at: number) => {\n    const c = text.charCodeAt(at)\n    return c >= 0x30 && c <= 0x39\n  }\n\n  const scanString = (): ScanResult => {\n    i++ // the opening quote, already checked by the caller\n    for (;;) {\n      if (i >= n) return fail(n, \"Unterminated string — the closing quote is missing\")\n      const c = text.charCodeAt(i)\n      if (c === 0x22) {\n        i++\n        return { ok: true }\n      }\n      if (c === 0x5c) {\n        const escapeAt = i\n        i++\n        if (i >= n) return fail(n, \"Unterminated string — the closing quote is missing\")\n        const e = text[i]\n        if (e === \"u\") {\n          i++\n          for (let k = 0; k < 4; k++) {\n            if (i >= n) return fail(n, \"Unterminated string — the closing quote is missing\")\n            if (!/[0-9a-fA-F]/.test(text[i]))\n              return fail(i, \"A \\\\u escape needs exactly four hex digits\")\n            i++\n          }\n          continue\n        }\n        if ('\"\\\\/bfnrt'.includes(e)) {\n          i++\n          continue\n        }\n        return fail(escapeAt, `Unknown escape \\\\${e} — valid ones are \\\\\" \\\\\\\\ \\\\/ \\\\b \\\\f \\\\n \\\\r \\\\t \\\\uXXXX`)\n      }\n      // A raw newline, tab or other control character inside a string is the mistake\n      // behind most \"it worked in my editor\" reports: the text looks fine and JSON.parse\n      // refuses it, because JSON requires these to be escaped.\n      if (c < 0x20)\n        return fail(i, \"Control characters must be escaped inside a string (use \\\\n, \\\\t, \\\\uXXXX)\")\n      i++\n    }\n  }\n\n  const scanNumber = (): ScanResult => {\n    if (text[i] === \"-\") i++\n    if (i >= n || !isDigit(i)) return fail(i, \"Expected a digit\")\n    // A leading zero cannot be followed by more digits: 01 is not a JSON number. Accepting\n    // it here would be the scanner's own invention, not something the runtime allows.\n    if (text[i] === \"0\") i++\n    else while (i < n && isDigit(i)) i++\n    if (i < n && text[i] === \".\") {\n      i++\n      if (i >= n || !isDigit(i)) return fail(i, \"Expected a digit after the decimal point\")\n      while (i < n && isDigit(i)) i++\n    }\n    if (i < n && (text[i] === \"e\" || text[i] === \"E\")) {\n      i++\n      if (i < n && (text[i] === \"+\" || text[i] === \"-\")) i++\n      if (i >= n || !isDigit(i)) return fail(i, \"Expected a digit in the exponent\")\n      while (i < n && isDigit(i)) i++\n    }\n    return { ok: true }\n  }\n\n  const scanScalar = (): ScanResult => {\n    const c = text[i]\n    if (c === '\"') return scanString()\n    if (c === \"-\" || isDigit(i)) return scanNumber()\n    for (const word of [\"true\", \"false\", \"null\"]) {\n      if (text.startsWith(word, i)) {\n        i += word.length\n        return { ok: true }\n      }\n    }\n    return fail(i, \"Expected a value — an object, array, string, number, true, false or null\")\n  }\n\n  // The walk is a small state machine over an explicit stack of the containers currently\n  // open. Recursion would read more directly but would also turn a pathologically nested\n  // document into a crash, and this runs on whatever someone pastes in.\n  const stack: Array<\"object\" | \"array\"> = []\n  let mode: \"value\" | \"objectKeyOrEnd\" | \"objectKey\" | \"arrayValueOrEnd\" | \"afterValue\" = \"value\"\n\n  for (;;) {\n    if (mode === \"value\") {\n      ws()\n      if (i >= n) return fail(n, END)\n      if (text[i] === \"{\") {\n        i++\n        stack.push(\"object\")\n        mode = \"objectKeyOrEnd\"\n        continue\n      }\n      if (text[i] === \"[\") {\n        i++\n        stack.push(\"array\")\n        mode = \"arrayValueOrEnd\"\n        continue\n      }\n      const scalar = scanScalar()\n      if (!scalar.ok) return scalar\n      mode = \"afterValue\"\n      continue\n    }\n\n    if (mode === \"objectKeyOrEnd\") {\n      ws()\n      if (i >= n) return fail(n, END)\n      if (text[i] === \"}\") {\n        i++\n        stack.pop()\n        mode = \"afterValue\"\n        continue\n      }\n      mode = \"objectKey\"\n      continue\n    }\n\n    if (mode === \"objectKey\") {\n      ws()\n      if (i >= n) return fail(n, END)\n      // Reached after a comma as well as after `{`, so this is also where `{\"a\":1,}` and\n      // an unquoted key land — the two mistakes a hand-edited config file always has.\n      if (text[i] !== '\"')\n        return fail(i, 'Expected a double-quoted property name — JSON keys are always in quotes')\n      const key = scanString()\n      if (!key.ok) return key\n      ws()\n      if (i >= n) return fail(n, END)\n      if (text[i] !== \":\") return fail(i, \"Expected ':' after the property name\")\n      i++\n      mode = \"value\"\n      continue\n    }\n\n    if (mode === \"arrayValueOrEnd\") {\n      ws()\n      if (i >= n) return fail(n, END)\n      if (text[i] === \"]\") {\n        i++\n        stack.pop()\n        mode = \"afterValue\"\n        continue\n      }\n      mode = \"value\"\n      continue\n    }\n\n    // mode === \"afterValue\"\n    if (stack.length === 0) break\n    ws()\n    if (i >= n) return fail(n, END)\n    const open = stack[stack.length - 1]\n    const c = text[i]\n    if (c === \",\") {\n      i++\n      // After a comma an object owes a key and an array owes a value, which is how the\n      // trailing comma is caught at the character that follows it rather than at the\n      // comma itself: `[1,2,]` fails on the `]`, where the missing value should have been.\n      mode = open === \"object\" ? \"objectKey\" : \"value\"\n      continue\n    }\n    if (open === \"object\" && c === \"}\") {\n      i++\n      stack.pop()\n      mode = \"afterValue\"\n      continue\n    }\n    if (open === \"array\" && c === \"]\") {\n      i++\n      stack.pop()\n      mode = \"afterValue\"\n      continue\n    }\n    return fail(i, open === \"object\" ? \"Expected ',' or '}'\" : \"Expected ',' or ']'\")\n  }\n\n  ws()\n  // A second value after the first is its own mistake, and a common one: two objects\n  // pasted one after the other, or NDJSON dropped into a field that wants one document.\n  if (i < n) return fail(i, \"Unexpected text after the end of the value\")\n  return { ok: true }\n}\n\n/** 1-based line and column for a character offset. */\nfunction lineColumn(text: string, offset: number | null): { line: number | null; column: number | null } {\n  if (offset === null) return { line: null, column: null }\n  const upto = text.slice(0, offset)\n  const lastBreak = upto.lastIndexOf(\"\\n\")\n  return { line: upto.split(\"\\n\").length, column: offset - lastBreak }\n}\n\n/**\n * Last-resort position recovery from an engine's own message, used only when the scanner\n * above found nothing to complain about but the runtime still refused the text. Both\n * known shapes are tried; anything else yields null rather than a guess.\n */\nfunction offsetFromMessage(message: string, text: string): number | null {\n  const byPosition = /at position (\\d+)/.exec(message)\n  if (byPosition) return Math.min(Number(byPosition[1]), text.length)\n  const byLine = /at line (\\d+) column (\\d+)/.exec(message)\n  if (byLine) {\n    const line = Number(byLine[1])\n    const column = Number(byLine[2])\n    const lines = text.split(\"\\n\")\n    if (line < 1 || line > lines.length) return null\n    let offset = 0\n    for (let k = 0; k < line - 1; k++) offset += lines[k].length + 1\n    return Math.min(offset + column - 1, text.length)\n  }\n  return null\n}\n\n/**\n * Parses JSON text into a value, or into an error that knows where it is.\n *\n * `JSON.parse` decides validity, because its answer is the one the caller will live with:\n * whatever this says is valid is exactly what parses at runtime. The scanner is consulted\n * only to locate a failure, and only after one has occurred. If the two ever disagree —\n * the runtime refuses text the scanner is happy with, which is also what a `RangeError`\n * from a document nested thousands of levels deep looks like — the runtime's own message\n * is reported, with a position only if it happens to carry one.\n *\n * Exported as a pure function because the same answer is wanted outside React: a route\n * handler checking what arrived, a CLI validating a config file, a test asserting on a\n * fixture.\n */\nexport function parseJsonInput(text: string): JsonParseResult {\n  let value: unknown\n  try {\n    value = JSON.parse(text)\n  } catch (cause) {\n    const scan = scanJson(text)\n    if (!scan.ok) {\n      return {\n        valid: false,\n        value: undefined,\n        error: { message: scan.message, offset: scan.offset, ...lineColumn(text, scan.offset) },\n      }\n    }\n    const message = cause instanceof Error ? cause.message : String(cause)\n    const offset = offsetFromMessage(message, text)\n    return {\n      valid: false,\n      value: undefined,\n      error: { message, offset, ...lineColumn(text, offset) },\n    }\n  }\n  return { valid: true, value, error: null }\n}\n\n/**\n * Pretty-prints JSON text, leaving it alone if it does not parse.\n *\n * Returns the input unchanged rather than throwing, so a \"Format\" action is safe to call\n * on whatever is in the field at the time.\n */\nexport function formatJson(text: string, indent = 2): string {\n  const parsed = parseJsonInput(text)\n  if (!parsed.valid) return text\n  return JSON.stringify(parsed.value, null, indent)\n}\n\ninterface JsonInputProps\n  extends Omit<\n    React.TextareaHTMLAttributes<HTMLTextAreaElement>,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"aria-invalid\"\n  > {\n  /**\n   * The text in the field, as text. **Store the string, not the parsed object** — see the\n   * note on the component below, which is the one thing that makes this component work.\n   */\n  value?: string\n  /** Initial text when the field is uncontrolled. */\n  defaultValue?: string\n  /** Every keystroke, immediately, with the new text. */\n  onValueChange?: (text: string) => void\n  /**\n   * The parse result, after the reader pauses (`debounceMs`) rather than on every\n   * keystroke. Also fires once on mount for the initial text.\n   */\n  onParse?: (result: JsonParseResult) => void\n  /** Spaces per level, used by Tab and by \"Format\". */\n  indent?: number\n  /** How long the reader has to stop typing before the text is parsed again. */\n  debounceMs?: number\n  /** Tab inserts indentation instead of moving focus. Escape then Tab always moves focus. */\n  tabIndent?: boolean\n  /** Pretty-print on blur when the text is valid. */\n  formatOnBlur?: boolean\n  /** The status line and the Format / Go to error actions. */\n  showToolbar?: boolean\n  /**\n   * The screen-reader-only description of the keyboard behaviour. Pass your own string to\n   * translate it, or null to drop it.\n   */\n  hint?: string | null\n  /** On the wrapper. The field itself takes `textareaClassName`; other props pass to it. */\n  className?: string\n  textareaClassName?: string\n}\n\n/**\n * A textarea for editing JSON that says where the mistake is.\n *\n * For the places a page has to let someone edit a JSON value by hand: a setting in an\n * admin panel, a feature flag's payload, a webhook's test body, a metadata column, the\n * request body in an API client, a job's input in a queue dashboard, a CI or connector\n * config form. It validates as you pause, reports the line and column, puts the caret on\n * the error when you ask, indents with Tab and pretty-prints on demand.\n *\n * **The text is the value.** This is the whole design, and the reason the obvious version\n * of this component does not work. Write the obvious one — parse the text, hand the\n * object to the parent, render `JSON.stringify(object)` back into the field — and the\n * field becomes impossible to type in: the moment you have typed `{\"a\": 1,` the text does\n * not parse, there is no object to stringify, and whatever the parent hands back replaces\n * what you were typing. Even while it does parse, the round trip rewrites the text under\n * the caret, so `{\"a\": 1` becomes `{\"a\":1}` mid-keystroke and the second key can never be\n * reached. So `value` here is a string, parsing is a *check* that never touches the text,\n * and the parsed value is handed over as metadata through `onParse`. Keep the string in\n * your own state and parse it when you submit — `const [text, setText] = useState(...)`,\n * not `JSON.stringify(config)` — and nothing fights the caret.\n *\n * Errors are located by a JSON scanner in this file rather than by reading\n * `SyntaxError.message`, because the message is not an interface: no engine puts a\n * `position` property on the error, Safari's message never contains a position at all,\n * and V8's omits it for a whole class of mistakes including the trailing comma. The\n * scanner is only ever asked *where* a failure is — `JSON.parse` alone decides whether\n * the text is valid — so it can never reject a document the runtime would accept.\n *\n * Nothing is validated mid-keystroke. Text that is half-typed is always broken, and a\n * field that turns red on the second character is a field that is red the whole time it\n * is being used, so the verdict waits for a pause (`debounceMs`, 200ms) and shows nothing\n * in the meantime rather than a stale one. The same pause is what keeps a few hundred\n * kilobytes of payload from being re-parsed on every keypress.\n *\n * Formatting only ever happens when asked — the Format button, or `formatOnBlur` — never\n * while typing, because reformatting replaces the whole value and sends the caret to the\n * end of the document.\n *\n * Tab indents (and Shift+Tab outdents) the selected lines, which would otherwise trap a\n * keyboard user in the field with no way out: **Escape releases it**, so Escape then Tab\n * moves focus on, as WCAG 2.1.2 requires, and the release is announced in the field's\n * own description rather than left to be discovered. Escape is not swallowed, so it still\n * reaches the dialog this editor might be sitting in.\n */\nexport function JsonInput({\n  value,\n  defaultValue,\n  onValueChange,\n  onParse,\n  indent = 2,\n  debounceMs = 200,\n  tabIndent = true,\n  formatOnBlur = false,\n  showToolbar = true,\n  hint,\n  className,\n  textareaClassName,\n  rows = 8,\n  disabled,\n  readOnly,\n  spellCheck = false,\n  autoCapitalize = \"off\",\n  autoCorrect = \"off\",\n  autoComplete = \"off\",\n  id,\n  onKeyDown,\n  onBlur,\n  \"aria-describedby\": ariaDescribedby,\n  ...props\n}: JsonInputProps) {\n  const isControlled = value !== undefined\n  const [uncontrolledText, setUncontrolledText] = React.useState(defaultValue ?? \"\")\n  const text = isControlled ? value : uncontrolledText\n\n  // The verdict, together with the exact text it describes. Keeping the text alongside is\n  // what lets the status say nothing at all while the reader is mid-edit: comparing it to\n  // the current text is the difference between \"no opinion yet\" and \"valid\", which a bare\n  // result cannot express. Seeded synchronously so a field that opens with a document\n  // already in it shows the right answer on the first paint instead of flickering through\n  // a pending state.\n  const [checked, setChecked] = React.useState<{ text: string; result: JsonParseResult }>(() => {\n    const initial = value !== undefined ? value : (defaultValue ?? \"\")\n    return { text: initial, result: parseJsonInput(initial) }\n  })\n  const checkedTextRef = React.useRef(checked.text)\n\n  const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)\n  // A caret position owed to the text that is about to render. Set by the Tab handlers and\n  // spent in the layout effect below, never applied on the spot: the value React is about\n  // to commit replaces the whole contents of the textarea, and a browser puts the caret at\n  // the end of a textarea whose value was replaced — so a setSelectionRange call made in\n  // the key handler is overwritten a moment later by the commit it was meant to survive.\n  const pendingSelection = React.useRef<[number, number] | null>(null)\n  // Whether Escape has released the Tab trap for the next keypress.\n  const tabReleased = React.useRef(false)\n\n  const onParseRef = React.useRef(onParse)\n  React.useEffect(() => {\n    onParseRef.current = onParse\n  }, [onParse])\n\n  React.useEffect(() => {\n    if (text === checked.text) return\n    const id = setTimeout(() => {\n      // Re-checked inside the timer because the text may have come back to what was\n      // already parsed — typing a character and deleting it again — in which case the\n      // verdict on screen is already the right one.\n      if (checkedTextRef.current === text) return\n      checkedTextRef.current = text\n      setChecked({ text, result: parseJsonInput(text) })\n    }, debounceMs)\n    return () => clearTimeout(id)\n  }, [text, checked.text, debounceMs])\n\n  React.useEffect(() => {\n    onParseRef.current?.(checked.result)\n  }, [checked])\n\n  React.useLayoutEffect(() => {\n    const pending = pendingSelection.current\n    if (!pending) return\n    pendingSelection.current = null\n    textareaRef.current?.setSelectionRange(pending[0], pending[1])\n  })\n\n  const commit = (next: string) => {\n    if (!isControlled) setUncontrolledText(next)\n    onValueChange?.(next)\n  }\n\n  /** Settles the verdict immediately, for the explicit actions that already know it. */\n  const settle = (next: string, result: JsonParseResult) => {\n    checkedTextRef.current = next\n    setChecked({ text: next, result })\n  }\n\n  const format = () => {\n    const parsed = parseJsonInput(text)\n    if (!parsed.valid) return\n    const next = JSON.stringify(parsed.value, null, indent)\n    if (next === text) return\n    commit(next)\n    // Formatting cannot change what the document means, so the verdict is carried over\n    // rather than recomputed — and carrying it over is also what stops the Format button\n    // from blinking out and back while the debounce runs.\n    settle(next, parsed)\n    pendingSelection.current = [next.length, next.length]\n  }\n\n  const goToError = () => {\n    const error = checked.result.valid ? null : checked.result.error\n    if (!error || error.offset === null) return\n    const node = textareaRef.current\n    node?.focus()\n    node?.setSelectionRange(error.offset, error.offset)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    onKeyDown?.(event)\n    if (event.defaultPrevented) return\n\n    if (event.key === \"Escape\") {\n      // Not prevented: Escape belongs to whatever this editor sits inside — a dialog, a\n      // command palette — and swallowing it to serve the Tab trap would break that.\n      tabReleased.current = true\n      return\n    }\n    if (event.key !== \"Tab\") {\n      tabReleased.current = false\n      return\n    }\n    if (!tabIndent || readOnly || disabled) return\n    if (tabReleased.current) {\n      tabReleased.current = false\n      return\n    }\n\n    const node = event.currentTarget\n    const start = node.selectionStart ?? text.length\n    const end = node.selectionEnd ?? start\n    event.preventDefault()\n\n    const pad = \" \".repeat(indent)\n    const lineStart = text.lastIndexOf(\"\\n\", start - 1) + 1\n    // A selection that ends exactly at the start of a line stops short of that line. Dragging\n    // down to the next line's first column is how everyone selects \"these lines\", and the\n    // line below is usually the one holding the closing brace — indenting it along with the\n    // block pushes over a character the reader never selected, and does it again on every Tab.\n    const reach = end > start && text[end - 1] === \"\\n\" ? end - 1 : end\n    const breakAfter = text.indexOf(\"\\n\", reach)\n    const lineEnd = breakAfter === -1 ? text.length : breakAfter\n    const before = text.slice(0, lineStart)\n    const block = text.slice(lineStart, lineEnd)\n    const after = text.slice(lineEnd)\n    const lines = block.split(\"\\n\")\n\n    if (event.shiftKey) {\n      let firstRemoved = 0\n      let totalRemoved = 0\n      const outdented = lines.map((line, index) => {\n        const leading = /^ +/.exec(line)\n        const remove = Math.min(indent, leading ? leading[0].length : 0)\n        if (index === 0) firstRemoved = remove\n        totalRemoved += remove\n        return line.slice(remove)\n      })\n      if (totalRemoved === 0) return\n      commit(before + outdented.join(\"\\n\") + after)\n      pendingSelection.current = [\n        Math.max(lineStart, start - firstRemoved),\n        Math.max(lineStart, end - totalRemoved),\n      ]\n      return\n    }\n\n    // An empty selection inserts at the caret; any selection at all indents every line it\n    // touches, which is what makes Tab useful on a block that came back from the Format\n    // button one level too shallow.\n    if (start === end) {\n      commit(text.slice(0, start) + pad + text.slice(start))\n      pendingSelection.current = [start + indent, start + indent]\n      return\n    }\n    commit(before + lines.map((line) => pad + line).join(\"\\n\") + after)\n    pendingSelection.current = [start + indent, end + indent * lines.length]\n  }\n\n  const handleBlur = (event: React.FocusEvent<HTMLTextAreaElement>) => {\n    onBlur?.(event)\n    if (formatOnBlur && !readOnly && !disabled) format()\n  }\n\n  const reactId = React.useId()\n  const fieldId = id ?? `${reactId}-json-input`\n  const statusId = `${fieldId}-status`\n  const hintId = `${fieldId}-hint`\n\n  const pending = text !== checked.text\n  // An empty field has no verdict. \"Unexpected end of input\" is true of \"\" and useless to\n  // show: an optional field that is red before it has been touched teaches the reader to\n  // ignore the colour.\n  const empty = text.trim() === \"\"\n  const error = !pending && !empty && !checked.result.valid ? checked.result.error : null\n  const valid = !pending && !empty && checked.result.valid\n\n  const hintText =\n    hint === undefined\n      ? tabIndent\n        ? \"Tab indents, Shift+Tab outdents. Press Escape, then Tab, to move focus out of the editor.\"\n        : null\n      : hint\n\n  const describedBy =\n    [ariaDescribedby, showToolbar ? statusId : null, hintText ? hintId : null]\n      .filter(Boolean)\n      .join(\" \") || undefined\n\n  return (\n    <div className={cn(\"space-y-1.5\", className)}>\n      <textarea\n        {...props}\n        id={fieldId}\n        ref={textareaRef}\n        value={text}\n        rows={rows}\n        disabled={disabled}\n        readOnly={readOnly}\n        onChange={(event) => commit(event.target.value)}\n        onKeyDown={handleKeyDown}\n        onBlur={handleBlur}\n        // A mobile keyboard that autocapitalises and autocorrects rewrites a key name\n        // between the keypress and the change event, and spellcheck underlines every\n        // identifier in the document. All four are overridable for the rare field that\n        // does want them.\n        spellCheck={spellCheck}\n        autoCapitalize={autoCapitalize}\n        autoCorrect={autoCorrect}\n        autoComplete={autoComplete}\n        aria-invalid={error ? true : undefined}\n        aria-describedby={describedBy}\n        className={cn(\n          \"flex w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n          // Tabs are never inserted by this component, but a document pasted in can\n          // contain them, and the browser default of eight columns makes two levels of\n          // nesting unreadable.\n          \"[tab-size:2]\",\n          error && \"border-destructive focus-visible:ring-destructive\",\n          textareaClassName\n        )}\n      />\n\n      {showToolbar && (\n        <div className=\"flex items-start justify-between gap-3\">\n          {/* Polite rather than assertive, and only ever updated once the reader has\n              paused, so a screen reader reports the verdict instead of narrating every\n              half-finished state on the way to it. */}\n          <p\n            id={statusId}\n            aria-live=\"polite\"\n            className={cn(\n              \"min-h-5 text-xs\",\n              error ? \"text-destructive\" : \"text-muted-foreground\"\n            )}\n          >\n            {error ? (\n              <>\n                {error.line !== null && (\n                  <span className=\"font-medium\">\n                    Line {error.line}, column {error.column}:{\" \"}\n                  </span>\n                )}\n                {error.message}\n              </>\n            ) : valid ? (\n              \"Valid JSON\"\n            ) : (\n              \"\"\n            )}\n          </p>\n\n          <div className=\"flex shrink-0 gap-1\">\n            {error !== null && error.offset !== null && (\n              <button\n                type=\"button\"\n                onClick={goToError}\n                className=\"inline-flex h-6 items-center rounded-md border border-input bg-transparent px-2 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n              >\n                Go to error\n              </button>\n            )}\n            {valid && !readOnly && !disabled && (\n              <button\n                type=\"button\"\n                onClick={format}\n                className=\"inline-flex h-6 items-center rounded-md border border-input bg-transparent px-2 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n              >\n                Format\n              </button>\n            )}\n          </div>\n        </div>\n      )}\n\n      {hintText && (\n        <span id={hintId} className=\"sr-only\">\n          {hintText}\n        </span>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
