{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"settings-form","type":"registry:component","title":"Settings form","description":"A settings page that saves the way people expect: switches save themselves, text edits gather into one bar that rises with \"2 unsaved changes\".","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/settings-form.tsx","type":"registry:component","content":"// Hairline UI · settings-form · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * SETTINGS FORM: a settings page that saves the way people\n * expect it to\n *\n *   switch    toggles and choices save the moment they change:\n *             the thumb is thrown across, \"Saving\" opens beside\n *             the label, a check draws and fades. If the server\n *             says no, the thumb is thrown back with a shake\n *             and the reason opens under the field\n *   edit      text edits wait for you: a dot swells beside each\n *             changed label and one bar rises at the foot of\n *             the form, \"2 unsaved changes\", rolling as you go\n *   check     a field with an async check (is this address\n *             free?) asks a moment after you stop typing:\n *             \"Checking\", then \"Available\" or why not\n *   save      Save (or ⌘S) sends just what changed; the bar says\n *             Saving, then Saved, and folds away. Errors your\n *             server sends per field open under those fields\n *             and focus goes to the first\n *   reset     the edits blur back to what's saved\n *\n * Fields are plain objects; onSave gets only the changed values\n * and returns what was stored.\n * ───────────────────────────────────────────────────────── */\n\nexport type SettingsValue = string | boolean;\nexport type SettingsValues = Record<string, SettingsValue>;\n\ntype Base = { name: string; label: string; description?: string };\n\nexport type SettingsField =\n  | (Base & { type: \"toggle\" })\n  | (Base & { type: \"choice\"; options: { value: string; label: string }[] })\n  | (Base & {\n      type: \"text\" | \"textarea\";\n      placeholder?: string;\n      /** Shown inside the field before the value, e.g. \"relay.app/\". */\n      prefix?: string;\n      /** Textarea height in lines. */\n      rows?: number;\n      maxLength?: number;\n      inputMode?: React.HTMLAttributes<HTMLInputElement>[\"inputMode\"];\n      autoComplete?: string;\n      /** Return a message to refuse the value before it's sent. */\n      validate?: (value: string, values: SettingsValues) => string | null | undefined | void;\n      /** Asked a moment after typing stops: return null when the value is fine, or why it isn't. */\n      check?: (value: string, options: { signal: AbortSignal }) => Promise<string | null | undefined | void>;\n      /** What a passed check says. */\n      checkPassed?: string;\n    });\n\nexport interface SettingsSection {\n  title: string;\n  description?: string;\n  fields: SettingsField[];\n}\n\n/** Throw this from onSave to put messages under particular fields. */\nexport class SettingsFormError extends Error {\n  fields: Record<string, string>;\n  constructor(message: string, fields: Record<string, string> = {}) {\n    super(message);\n    this.name = \"SettingsFormError\";\n    this.fields = fields;\n  }\n}\n\nexport interface SettingsFormProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  sections: SettingsSection[];\n  /** Saved values, controlled. */\n  value?: SettingsValues;\n  defaultValue?: SettingsValues;\n  /** The saved values after every successful save. */\n  onValueChange?: (values: SettingsValues) => void;\n  /** Saves the changed values. Return what was stored (or nothing); throw to keep the edits and say why. */\n  onSave: (patch: SettingsValues, values: SettingsValues) => Promise<SettingsValues | void> | SettingsValues | void;\n  /** Toggles and choices save the moment they change. */\n  autosave?: boolean;\n  /** Ask before leaving the page with unsaved edits. */\n  warnOnLeave?: boolean;\n}\n\ntype Check = { state: \"checking\" | \"ok\" | \"no\"; message: string };\ntype TextField = Extract<SettingsField, { type: \"text\" | \"textarea\" }>;\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst EMPTY: SettingsValues = {};\nconst SHAKE = [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst without = <T,>(o: Record<string, T>, keys: string[]) => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.includes(k))) as Record<string, T>;\nconst message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\n\nfunction DrawnCheck({ drawn, reduced, className = \"size-3.5\" }: { drawn: boolean; reduced: boolean; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 380ms ${EASE} 100ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nfunction Spinner({ on }: { on: boolean }) {\n  return <span className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none ${on ? \"animate-spin\" : \"\"}`} />;\n}\n\n/** Text that changes by lifting the old line out through a blur and the new one in (sentences don't morph letter by letter). */\nfunction Line({ text, className = \"\", reduced }: { text: string; className?: string; reduced: boolean }) {\n  const [shown, setShown] = React.useState(text);\n  const [leaving, setLeaving] = React.useState<{ key: number; text: string }[]>([]);\n  const key = React.useRef(0);\n  const ref = React.useRef<HTMLSpanElement>(null);\n  if (text !== shown) {\n    if (!reduced && shown) setLeaving((l) => [...l, { key: ++key.current, text: shown }]);\n    setShown(text);\n  }\n  React.useLayoutEffect(() => {\n    if (reduced || !ref.current || !leaving.length) return;\n    ref.current.animate([{ opacity: 0, transform: \"translateY(4px)\", filter: \"blur(3px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 320, delay: 60, easing: EASE, fill: \"backwards\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [shown]);\n  return (\n    <span className={`relative block ${className}`}>\n      <span ref={ref} className=\"block\">\n        {shown}\n      </span>\n      {leaving.map((l) => (\n        <LeavingLine key={l.key} text={l.text} onDone={() => setLeaving((all) => all.filter((x) => x.key !== l.key))} />\n      ))}\n    </span>\n  );\n}\n\nfunction LeavingLine({ text, onDone }: { text: string; onDone: () => void }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const a = ref.current?.animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, transform: \"translateY(-4px)\", filter: \"blur(3px)\" }], { duration: 200, easing: EASE, fill: \"forwards\" });\n    if (a) a.onfinish = onDone;\n    return () => a?.cancel();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  return (\n    <span ref={ref} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 top-0 block\">\n      {text}\n    </span>\n  );\n}\n\n/** A block that opens and closes by height, keeping its last children while it closes. */\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  const kept = React.useRef(children);\n  if (open) kept.current = children;\n  return (\n    <div className=\"grid\" inert={!open} aria-hidden={!open || undefined} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}, opacity 260ms ${EASE}` }}>\n      <div className=\"min-h-0 overflow-clip\">{open ? children : kept.current}</div>\n    </div>\n  );\n}\n\n/** \"Saving\" → \"Saved\" beside a label that saved itself; it fades once it's said it. */\nfunction SavedMark({ status, reduced }: { status: ActionStatus; reduced: boolean }) {\n  const open = status !== \"idle\";\n  const [label, setLabel] = React.useState(\"Saving\");\n  const next = status === \"pending\" ? \"Saving\" : status === \"success\" ? \"Saved\" : status === \"error\" ? \"Not saved\" : label;\n  if (next !== label) setLabel(next);\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`inline-grid align-baseline text-[11.5px] font-normal ${status === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}\n      style={{\n        gridTemplateColumns: open ? \"1fr\" : \"0fr\",\n        opacity: open ? 1 : 0,\n        transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity ${open ? 240 : 600}ms ${EASE}`,\n      }}\n    >\n      <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n        <span className=\"inline-flex items-center gap-1 pl-2 whitespace-nowrap\">\n          <span className=\"relative grid size-3 place-items-center\">\n            <span className=\"absolute inset-0 grid place-items-center\" style={{ opacity: status === \"pending\" ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${EASE}` }}>\n              <Spinner on={status === \"pending\"} />\n            </span>\n            <span className=\"absolute inset-0 grid place-items-center\" style={{ opacity: status === \"success\" ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${EASE}` }}>\n              <DrawnCheck drawn={status === \"success\"} reduced={reduced} className=\"size-3\" />\n            </span>\n          </span>\n          <TextMorph>{label}</TextMorph>\n        </span>\n      </span>\n    </span>\n  );\n}\n\nfunction Label({ field, htmlFor, dirty, status, reduced, id }: { field: SettingsField; htmlFor?: string; dirty: boolean; status: ActionStatus; reduced: boolean; id: string }) {\n  const Tag = htmlFor ? \"label\" : \"span\";\n  return (\n    <span className=\"block min-w-0\">\n      <Tag {...(htmlFor ? { htmlFor } : {})} id={`${id}-label`} className=\"flex items-center whitespace-nowrap text-[13px] font-medium tracking-[-0.01em]\">\n        {field.label}\n        {/* A dot that swells in beside a label whose value has changed but isn't saved. */}\n        <span\n          aria-hidden=\"true\"\n          className=\"ml-1.5 size-1.5 shrink-0 rounded-full bg-primary\"\n          style={{ transform: dirty ? \"scale(1)\" : \"scale(0)\", opacity: dirty ? 1 : 0, transition: reduced ? \"none\" : `transform 420ms ${dirty ? THROW : EASE}, opacity 200ms ${EASE}` }}\n        />\n        <SavedMark status={status} reduced={reduced} />\n      </Tag>\n      {field.description && (\n        <span id={`${id}-desc`} className=\"mt-0.5 block text-[12px] leading-[1.45] text-muted-foreground\">\n          {field.description}\n        </span>\n      )}\n    </span>\n  );\n}\n\nfunction Switch({ on, onToggle, labelledBy, describedBy, shake, reduced }: { on: boolean; onToggle: () => void; labelledBy: string; describedBy?: string; shake: number; reduced: boolean }) {\n  const ref = React.useRef<HTMLButtonElement>(null);\n  React.useEffect(() => {\n    if (shake && !reduced) ref.current?.animate(SHAKE, { duration: 380, easing: \"ease-out\" });\n  }, [shake, reduced]);\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={on}\n      aria-labelledby={labelledBy}\n      aria-describedby={describedBy}\n      onClick={onToggle}\n      className={`relative h-5 w-9 shrink-0 cursor-pointer rounded-full transition-[background-color,box-shadow] duration-300 ${FOCUS} ${on ? \"bg-primary\" : \"bg-muted shadow-[inset_0_0_0_1px_var(--border)]\"}`}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute top-0.5 left-0.5 size-4 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n        style={{ transform: on ? \"translateX(16px)\" : \"none\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }}\n      />\n    </button>\n  );\n}\n\nfunction Choice({ field, value, onChange, labelledBy, shake, reduced }: { field: Extract<SettingsField, { type: \"choice\" }>; value: string; onChange: (v: string) => void; labelledBy: string; shake: number; reduced: boolean }) {\n  const wrap = React.useRef<HTMLDivElement>(null);\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [thumb, setThumb] = React.useState({ left: 0, width: 0, ready: false });\n  const index = Math.max(0, field.options.findIndex((o) => o.value === value));\n\n  React.useLayoutEffect(() => {\n    const measure = () => {\n      const b = refs.current[index];\n      if (!b) return;\n      setThumb((t) => (t.left === b.offsetLeft && t.width === b.offsetWidth && t.ready ? t : { left: b.offsetLeft, width: b.offsetWidth, ready: t.width > 0 }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (wrap.current) observer.observe(wrap.current);\n    return () => observer.disconnect();\n  }, [index]);\n\n  React.useEffect(() => {\n    if (shake && !reduced) wrap.current?.animate(SHAKE, { duration: 380, easing: \"ease-out\" });\n  }, [shake, reduced]);\n\n  return (\n    <div ref={wrap} role=\"radiogroup\" aria-labelledby={labelledBy} className=\"relative inline-flex shrink-0 rounded-full p-0.5 shadow-[inset_0_0_0_1px_var(--border)]\">\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute top-0.5 bottom-0.5 left-0 rounded-full bg-accent shadow-[inset_0_0_0_1px_var(--border)]\"\n        style={{\n          width: thumb.width,\n          transform: `translateX(${thumb.left}px)`,\n          transition: thumb.ready && !reduced ? `transform 460ms ${THROW}, width 360ms ${EASE}` : \"none\",\n        }}\n      />\n      {field.options.map((o, i) => {\n        const on = i === index;\n        return (\n          <button\n            key={o.value}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={on}\n            tabIndex={on ? 0 : -1}\n            onClick={() => onChange(o.value)}\n            onKeyDown={(e) => {\n              const step = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0;\n              if (!step) return;\n              e.preventDefault();\n              const next = (i + step + field.options.length) % field.options.length;\n              onChange(field.options[next].value);\n              refs.current[next]?.focus();\n            }}\n            className={`relative h-7 cursor-pointer rounded-full px-3 text-[12px] font-medium transition-colors duration-300 ${FOCUS} ${on ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"}`}\n          >\n            {o.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction TextInput({\n  field,\n  id,\n  value,\n  error,\n  check,\n  onChange,\n  reduced,\n  register,\n}: {\n  field: TextField;\n  id: string;\n  value: string;\n  error?: string;\n  check?: Check;\n  onChange: (v: string) => void;\n  reduced: boolean;\n  register: (name: string, el: HTMLInputElement | HTMLTextAreaElement | null) => void;\n}) {\n  const ring = error || check?.state === \"no\" ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_45%,transparent)]\" : \"shadow-[inset_0_0_0_1px_var(--border)] focus-within:shadow-[inset_0_0_0_1px_var(--ring)]\";\n  const common = {\n    id,\n    name: field.name,\n    value,\n    placeholder: field.placeholder,\n    maxLength: field.maxLength,\n    \"aria-invalid\": Boolean(error || check?.state === \"no\") || undefined,\n    \"aria-describedby\": `${id}-desc ${id}-note`,\n    onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange(e.target.value),\n  };\n  const text = \"bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground/70 [@media(pointer:fine)]:text-[13.5px]\";\n  return (\n    <div data-field-ring=\"\" className={`mt-2 rounded-[10px] transition-shadow duration-200 ${ring}`}>\n      {field.type === \"textarea\" ? (\n        <textarea ref={(el) => register(field.name, el)} rows={field.rows ?? 3} {...common} className={`block w-full resize-none px-3 py-2 leading-[1.5] ${text}`} />\n      ) : (\n        <div className=\"flex h-9 items-center px-3\">\n          {field.prefix && (\n            <span aria-hidden=\"true\" className=\"shrink-0 text-[13.5px] text-muted-foreground select-none\">\n              {field.prefix}\n            </span>\n          )}\n          <input ref={(el) => register(field.name, el)} type=\"text\" inputMode={field.inputMode} autoComplete={field.autoComplete ?? \"off\"} {...common} className={`h-full min-w-0 flex-1 ${text}`} />\n          {field.check && (\n            <span aria-hidden=\"true\" className=\"relative ml-2 grid size-3.5 shrink-0 place-items-center\">\n              <span className=\"absolute inset-0 grid place-items-center text-muted-foreground\" style={{ opacity: check?.state === \"checking\" ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${EASE}` }}>\n                <Spinner on={check?.state === \"checking\"} />\n              </span>\n              <span className=\"absolute inset-0 grid place-items-center text-emerald-600 dark:text-emerald-400\" style={{ opacity: check?.state === \"ok\" && !error ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${EASE}` }}>\n                <DrawnCheck drawn={check?.state === \"ok\" && !error} reduced={reduced} />\n              </span>\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport function SettingsForm({\n  sections,\n  value,\n  defaultValue = EMPTY,\n  onValueChange,\n  onSave,\n  autosave = true,\n  warnOnLeave = true,\n  className = \"\",\n  ...props\n}: SettingsFormProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [inner, setInner] = React.useState(defaultValue);\n  const saved = value ?? inner;\n  const [edits, setEdits] = React.useState<SettingsValues>({});\n  const [status, setStatus] = React.useState<Record<string, ActionStatus>>({});\n  const [errors, setErrors] = React.useState<Record<string, string>>({});\n  const [shakes, setShakes] = React.useState<Record<string, number>>({});\n  const [checks, setChecks] = React.useState<Record<string, Check>>({});\n  const [bar, setBar] = React.useState<ActionStatus>(\"idle\");\n  const [barNote, setBarNote] = React.useState<string | null>(null);\n  const [lastCount, setLastCount] = React.useState(1);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const inputs = React.useRef(new Map<string, HTMLInputElement | HTMLTextAreaElement>());\n  const runs = React.useRef(new Map<string, number>());\n  const checkRuns = React.useRef(new Map<string, { timer: number; ctrl?: AbortController }>());\n  const latest = React.useRef({ saved, edits });\n  latest.current = { saved, edits };\n\n  const fields = React.useMemo(() => sections.flatMap((s) => s.fields), [sections]);\n  const instant = (f: SettingsField) => autosave && (f.type === \"toggle\" || f.type === \"choice\");\n  const values = { ...saved, ...edits };\n  const dirty = fields.filter((f) => !instant(f) && f.name in edits && edits[f.name] !== (saved[f.name] ?? (f.type === \"toggle\" ? false : \"\")));\n  const count = dirty.length;\n  if (count && count !== lastCount) setLastCount(count);\n  const barOpen = count > 0 || bar === \"pending\" || bar === \"success\";\n\n  const commitSaved = (next: SettingsValues) => {\n    if (value === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const register = React.useCallback((name: string, el: HTMLInputElement | HTMLTextAreaElement | null) => {\n    if (el) inputs.current.set(name, el);\n    else inputs.current.delete(name);\n  }, []);\n\n  // Toggles and choices: saved the moment they change; a newer change wins over an older answer.\n  const saveNow = async (f: SettingsField, v: SettingsValue) => {\n    const run = (runs.current.get(f.name) ?? 0) + 1;\n    runs.current.set(f.name, run);\n    setEdits((e) => ({ ...e, [f.name]: v }));\n    setStatus((s) => ({ ...s, [f.name]: \"pending\" }));\n    setErrors((e) => without(e, [f.name]));\n    try {\n      const patch = { [f.name]: v };\n      const stored = await onSave(patch, { ...latest.current.saved, ...patch });\n      if (runs.current.get(f.name) !== run) return;\n      commitSaved({ ...latest.current.saved, ...patch, ...(stored ?? {}) });\n      setEdits((e) => without(e, [f.name]));\n      setStatus((s) => ({ ...s, [f.name]: \"success\" }));\n      setAnnounce(`${f.label} saved.`);\n      window.setTimeout(() => setStatus((s) => (runs.current.get(f.name) === run && s[f.name] === \"success\" ? { ...s, [f.name]: \"idle\" } : s)), 1600);\n    } catch (e) {\n      if (runs.current.get(f.name) !== run) return;\n      const why = e instanceof SettingsFormError ? (e.fields[f.name] ?? e.message) : message(e, `${f.label} couldn't be saved.`);\n      setEdits((x) => without(x, [f.name]));\n      setStatus((s) => ({ ...s, [f.name]: \"error\" }));\n      setErrors((x) => ({ ...x, [f.name]: why }));\n      setShakes((x) => ({ ...x, [f.name]: (x[f.name] ?? 0) + 1 }));\n      setAnnounce(why);\n      window.setTimeout(() => setStatus((s) => (runs.current.get(f.name) === run && s[f.name] === \"error\" ? { ...s, [f.name]: \"idle\" } : s)), 2400);\n    }\n  };\n\n  const edit = (f: TextField, v: string) => {\n    setEdits((e) => ({ ...e, [f.name]: v }));\n    setErrors((e) => without(e, [f.name]));\n    setBarNote(null);\n    if (bar === \"error\" || bar === \"success\") setBar(\"idle\");\n    if (!f.check) return;\n    const prev = checkRuns.current.get(f.name);\n    if (prev) {\n      window.clearTimeout(prev.timer);\n      prev.ctrl?.abort();\n    }\n    if (!v || v === saved[f.name]) {\n      setChecks((c) => without(c, [f.name]));\n      return;\n    }\n    const entry: { timer: number; ctrl?: AbortController } = { timer: 0 };\n    entry.timer = window.setTimeout(async () => {\n      const ctrl = new AbortController();\n      entry.ctrl = ctrl;\n      setChecks((c) => ({ ...c, [f.name]: { state: \"checking\", message: \"Checking\" } }));\n      try {\n        const why = await f.check!(v, { signal: ctrl.signal });\n        if (ctrl.signal.aborted) return;\n        setChecks((c) => ({ ...c, [f.name]: why ? { state: \"no\", message: why } : { state: \"ok\", message: f.checkPassed ?? \"Available\" } }));\n      } catch {\n        if (!ctrl.signal.aborted) setChecks((c) => without(c, [f.name]));\n      }\n    }, 450);\n    checkRuns.current.set(f.name, entry);\n  };\n\n  const focusFirst = (names: string[]) => {\n    const first = fields.find((f) => names.includes(f.name));\n    const el = first && inputs.current.get(first.name);\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.scrollIntoView({ block: \"nearest\", behavior: reduced ? \"auto\" : \"smooth\" });\n  };\n\n  const save = async () => {\n    if (!count || bar === \"pending\") return;\n    const problems: Record<string, string> = {};\n    for (const f of dirty) {\n      if (f.type !== \"text\" && f.type !== \"textarea\") continue;\n      const v = String(values[f.name] ?? \"\");\n      const why = f.validate?.(v, values) || (checks[f.name]?.state === \"no\" ? checks[f.name].message : null);\n      if (why) problems[f.name] = why;\n    }\n    if (Object.keys(problems).length) {\n      setErrors((e) => ({ ...e, ...problems }));\n      setShakes((s) => ({ ...s, ...Object.fromEntries(Object.keys(problems).map((k) => [k, (s[k] ?? 0) + 1])) }));\n      setBar(\"error\");\n      setBarNote(Object.keys(problems).length === 1 ? \"One field needs a look.\" : `${Object.keys(problems).length} fields need a look.`);\n      focusFirst(Object.keys(problems));\n      return;\n    }\n    const patch = Object.fromEntries(dirty.map((f) => [f.name, values[f.name]]));\n    setBar(\"pending\");\n    setBarNote(null);\n    try {\n      const stored = await onSave(patch, { ...latest.current.saved, ...patch });\n      commitSaved({ ...latest.current.saved, ...patch, ...(stored ?? {}) });\n      // Keep edits made while it was saving.\n      setEdits((e) => Object.fromEntries(Object.entries(e).filter(([k, v]) => !(k in patch) || v !== patch[k])));\n      setChecks((c) => without(c, Object.keys(patch)));\n      setBar(\"success\");\n      setAnnounce(\"Changes saved.\");\n    } catch (e) {\n      const fieldErrors = e instanceof SettingsFormError ? e.fields : {};\n      setErrors((x) => ({ ...x, ...fieldErrors }));\n      setShakes((s) => ({ ...s, ...Object.fromEntries(Object.keys(fieldErrors).map((k) => [k, (s[k] ?? 0) + 1])) }));\n      setBar(\"error\");\n      const why = message(e, \"Your changes couldn't be saved.\");\n      setBarNote(why);\n      setAnnounce(why);\n      focusFirst(Object.keys(fieldErrors));\n    }\n  };\n\n  const reset = () => {\n    const names = dirty.map((f) => f.name);\n    setEdits((e) => without(e, names));\n    setErrors((e) => without(e, names));\n    setChecks((c) => without(c, names));\n    setBar(\"idle\");\n    setBarNote(null);\n    if (!reduced)\n      for (const n of names)\n        inputs.current.get(n)?.animate([{ opacity: 0.2, filter: \"blur(3px)\" }, { opacity: 1, filter: \"blur(0px)\" }], { duration: 420, easing: EASE });\n    setAnnounce(\"Changes discarded.\");\n  };\n\n  // Saved: the bar says so for a moment, then folds away.\n  React.useEffect(() => {\n    if (bar !== \"success\") return;\n    const timer = window.setTimeout(() => setBar(\"idle\"), 1100);\n    return () => window.clearTimeout(timer);\n  }, [bar]);\n\n  React.useEffect(() => {\n    if (!warnOnLeave || !count) return;\n    const onLeave = (e: BeforeUnloadEvent) => {\n      e.preventDefault();\n      e.returnValue = \"\";\n    };\n    window.addEventListener(\"beforeunload\", onLeave);\n    return () => window.removeEventListener(\"beforeunload\", onLeave);\n  }, [warnOnLeave, count]);\n\n  React.useEffect(() => () => checkRuns.current.forEach((c) => (window.clearTimeout(c.timer), c.ctrl?.abort())), []);\n\n  const shown = count || lastCount;\n  const left =\n    bar === \"success\" ? \"Changes saved\" : barNote ?? `${shown} unsaved ${shown === 1 ? \"change\" : \"changes\"}`;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`@container relative w-full rounded-[22px] border border-border bg-background text-foreground ${className}`}\n      onKeyDown={(e) => {\n        if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"s\") {\n          e.preventDefault();\n          save();\n        }\n      }}\n      {...props}\n    >\n      {sections.map((section, si) => (\n        <section key={section.title} aria-labelledby={`${id}-s${si}`} className=\"grid gap-4 border-t border-border px-4 py-4 first:border-t-0 @xl:grid-cols-[minmax(0,180px)_1fr] @xl:gap-8 @md:px-5 @md:py-5\">\n          <div className=\"min-w-0\">\n            <h3 id={`${id}-s${si}`} className=\"text-[14px] font-medium tracking-[-0.01em]\">\n              {section.title}\n            </h3>\n            {section.description && <p className=\"mt-0.5 text-[12px] leading-[1.45] text-muted-foreground\">{section.description}</p>}\n          </div>\n          {/* Its own container: a choice sits beside its label only when this column has room. */}\n          <div className=\"@container/fields min-w-0 space-y-4\">\n            {section.fields.map((f) => {\n              const fid = `${id}-${f.name}`;\n              const st = status[f.name] ?? \"idle\";\n              const err = errors[f.name];\n              const isDirty = dirty.includes(f);\n              const check = checks[f.name];\n              const note = err ?? (check ? check.message : null);\n              const noteTone = err || check?.state === \"no\" ? \"text-red-600 dark:text-red-400\" : check?.state === \"ok\" ? \"text-emerald-600 dark:text-emerald-400\" : \"text-muted-foreground\";\n              return (\n                <div key={f.name}>\n                  {f.type === \"toggle\" ? (\n                    <div className=\"flex items-start justify-between gap-4\">\n                      <Label field={f} dirty={isDirty} status={st} reduced={reduced} id={fid} />\n                      <span className=\"pt-px\">\n                        <Switch\n                          on={Boolean(values[f.name])}\n                          onToggle={() => (instant(f) ? saveNow(f, !values[f.name]) : setEdits((e) => ({ ...e, [f.name]: !values[f.name] })))}\n                          labelledBy={`${fid}-label`}\n                          describedBy={f.description ? `${fid}-desc` : undefined}\n                          shake={shakes[f.name] ?? 0}\n                          reduced={reduced}\n                        />\n                      </span>\n                    </div>\n                  ) : f.type === \"choice\" ? (\n                    <div className=\"flex flex-col items-start gap-2 @min-[26rem]/fields:flex-row @min-[26rem]/fields:justify-between @min-[26rem]/fields:gap-4\">\n                      <Label field={f} dirty={isDirty} status={st} reduced={reduced} id={fid} />\n                      <Choice\n                        field={f}\n                        value={String(values[f.name] ?? f.options[0]?.value ?? \"\")}\n                        onChange={(v) => (v === values[f.name] ? undefined : instant(f) ? saveNow(f, v) : setEdits((e) => ({ ...e, [f.name]: v })))}\n                        labelledBy={`${fid}-label`}\n                        shake={shakes[f.name] ?? 0}\n                        reduced={reduced}\n                      />\n                    </div>\n                  ) : (\n                    <>\n                      <Label field={f} htmlFor={fid} dirty={isDirty} status={st} reduced={reduced} id={fid} />\n                      <TextInput\n                        field={f}\n                        id={fid}\n                        value={String(values[f.name] ?? \"\")}\n                        error={err}\n                        check={check}\n                        onChange={(v) => edit(f, v)}\n                        reduced={reduced}\n                        register={register}\n                      />\n                      {f.maxLength && f.type === \"textarea\" && (\n                        <p aria-hidden=\"true\" className=\"mt-1 flex justify-end text-[11px] text-muted-foreground tabular-nums\">\n                          <NumberRoll value={String(values[f.name] ?? \"\").length} />\n                          &nbsp;/ {f.maxLength}\n                        </p>\n                      )}\n                    </>\n                  )}\n                  <div id={`${fid}-note`}>\n                    <Fold open={Boolean(note)} reduced={reduced}>\n                      <p className={`flex items-center gap-1 pt-1.5 text-[11.5px] ${noteTone}`}>\n                        <Line text={note ?? \"\"} reduced={reduced} />\n                      </p>\n                    </Fold>\n                  </div>\n                  {f.type !== \"toggle\" && f.type !== \"choice\" && <ShakeOn target={inputs.current.get(f.name)} count={shakes[f.name] ?? 0} reduced={reduced} />}\n                </div>\n              );\n            })}\n          </div>\n        </section>\n      ))}\n\n      {/* One bar for every unsaved edit; it rises from the foot of the form and stays in view while you scroll. */}\n      <div className=\"sticky bottom-0 z-10\">\n        <Fold open={barOpen} reduced={reduced}>\n          <div className=\"flex items-center gap-3 rounded-b-[21px] border-t border-border bg-background px-4 py-3 @md:px-5\" role=\"group\" aria-label=\"Unsaved changes\">\n            <p className={`min-w-0 flex-1 text-[12.5px] ${bar === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n              {bar !== \"success\" && !barNote ? (\n                <span className=\"flex items-baseline tabular-nums\">\n                  <NumberRoll value={shown} />\n                  &nbsp;<TextMorph>{shown === 1 ? \"unsaved change\" : \"unsaved changes\"}</TextMorph>\n                </span>\n              ) : (\n                <Line text={left} reduced={reduced} />\n              )}\n            </p>\n            <button\n              type=\"button\"\n              onClick={reset}\n              disabled={bar === \"pending\" || !count}\n              className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${FOCUS}`}\n            >\n              Reset\n            </button>\n            <StatusButton size=\"sm\" status={bar} labels={{ idle: \"Save\", pending: \"Saving\", success: \"Saved\", error: \"Try again\" }} onClick={save} />\n          </div>\n        </Fold>\n      </div>\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n/** Shakes an input when its count goes up (a refused value). */\nfunction ShakeOn({ target, count, reduced }: { target?: HTMLElement; count: number; reduced: boolean }) {\n  React.useEffect(() => {\n    if (count && !reduced) target?.closest(\"[data-field-ring]\")?.animate(SHAKE, { duration: 380, easing: \"ease-out\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [count]);\n  return null;\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/settings-form. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}