{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "settings-row",
  "type": "registry:ui",
  "title": "Settings Row",
  "description": "Label, description and control per row, each showing its own save as it happens.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/settings-row.tsx",
      "content": "\"use client\";\nimport { Select as BaseSelect } from \"@base-ui/react/select\";\nimport { Switch as BaseSwitch } from \"@base-ui/react/switch\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronsUpDown, Loader, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type SaveStatus = \"idle\" | \"saving\" | \"saved\" | \"error\";\n\ntype Phase = \"idle\" | \"pending\" | \"saving\" | \"saved\" | \"error\";\n\nconst isThenable = (v: unknown): v is PromiseLike<unknown> => !!v && typeof (v as PromiseLike<unknown>).then === \"function\";\n\n/**\n * The save lifecycle of one setting. Pass it a promise; it reports saving,\n * then saved (for `savedFor` ms), or error with a retry. The spinner waits\n * 150ms before it shows and stays at least 300ms once it does, so fast saves\n * only ever show the tick. A newer save always wins over an older one.\n */\nexport function useSaveStatus({ savedFor = 1800 }: { savedFor?: number } = {}) {\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const seq = useRef(0);\n  const shownAt = useRef(0);\n  const timers = useRef<number[]>([]);\n  const retryRef = useRef<(() => void) | undefined>(undefined);\n  const [canRetry, setCanRetry] = useState(false);\n\n  const clear = () => {\n    timers.current.forEach((t) => window.clearTimeout(t));\n    timers.current = [];\n  };\n  useEffect(() => clear, []);\n\n  const track = useCallback(\n    (promise: PromiseLike<unknown>, retry?: () => void) => {\n      const id = ++seq.current;\n      clear();\n      retryRef.current = retry;\n      setCanRetry(!!retry);\n      setPhase(\"pending\");\n      shownAt.current = 0;\n      timers.current.push(\n        window.setTimeout(() => {\n          shownAt.current = performance.now();\n          setPhase(\"saving\");\n        }, 150),\n      );\n      const settle = (next: \"saved\" | \"error\") => {\n        if (id !== seq.current) return;\n        clear();\n        const wait = shownAt.current ? Math.max(0, 300 - (performance.now() - shownAt.current)) : 0;\n        const finish = () => {\n          if (id !== seq.current) return;\n          setPhase(next);\n          if (next === \"saved\") timers.current.push(window.setTimeout(() => id === seq.current && setPhase(\"idle\"), savedFor));\n        };\n        if (wait) timers.current.push(window.setTimeout(finish, wait));\n        else finish();\n      };\n      promise.then(\n        () => settle(\"saved\"),\n        () => settle(\"error\"),\n      );\n    },\n    [savedFor],\n  );\n\n  const retry = useCallback(() => retryRef.current?.(), []);\n  const reset = useCallback(() => {\n    seq.current++;\n    clear();\n    setPhase(\"idle\");\n  }, []);\n\n  const status: SaveStatus = phase === \"pending\" ? \"saving\" : phase;\n  return { status, visible: phase === \"pending\" ? \"idle\" : (phase as SaveStatus), track, retry, canRetry, reset };\n}\n\ntype RowCtx = {\n  controlId: string;\n  labelId: string;\n  descriptionId: string;\n  disabled: boolean;\n  /** Report a save in flight. The row shows saving, saved or the error. `retry` powers \"Try again\". */\n  track: (promise: PromiseLike<unknown>, retry?: () => void) => void;\n};\n\nconst RowContext = createContext<RowCtx | null>(null);\n\n/** The ids and save tracker of the enclosing row, for wiring your own control. */\nexport function useSettingsRow() {\n  return use(RowContext);\n}\n\nexport type SettingsGroupProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  /** A quiet line under the card: where the setting applies, or a link to learn more. */\n  footer?: React.ReactNode;\n  /** Red-tinted edge for irreversible settings. */\n  tone?: \"default\" | \"danger\";\n  /** Heading level for the title, to fit the page's outline. */\n  headingLevel?: 2 | 3 | 4;\n};\n\n/** A titled card of rows separated by hairlines. */\nexport function SettingsGroup({ title, description, footer, tone = \"default\", headingLevel = 2, className, children, ...rest }: SettingsGroupProps) {\n  const titleId = useId();\n  const Heading = `h${headingLevel}` as const;\n  return (\n    <section aria-labelledby={title ? titleId : undefined} data-tone={tone} className={cn(\"flex w-full min-w-0 flex-col gap-3\", className)} {...rest}>\n      {(title || description) && (\n        <header className=\"flex flex-col gap-0.5 px-0.5\">\n          {title && (\n            <Heading id={titleId} className={cn(\"text-[14px] font-medium leading-5 tracking-[-0.015em] text-balance\", tone === \"danger\" ? \"text-danger\" : \"text-fg\")}>\n              {title}\n            </Heading>\n          )}\n          {description && <p className=\"text-[12.5px] leading-[18px] text-fg-3 text-pretty\">{description}</p>}\n        </header>\n      )}\n      <div\n        className={cn(\n          \"flex flex-col divide-y overflow-hidden rounded-xl border bg-raised shadow-[var(--shadow)]\",\n          tone === \"danger\" ? \"divide-danger/15 border-danger/30\" : \"divide-line border-line\",\n        )}\n      >\n        {children}\n      </div>\n      {footer && <p className=\"px-0.5 text-[12px] leading-[18px] text-fg-3 text-pretty\">{footer}</p>}\n    </section>\n  );\n}\n\nexport type SettingsRowProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  label: React.ReactNode;\n  description?: React.ReactNode;\n  /** A small tag after the label: Beta, Pro, Admin only. */\n  badge?: React.ReactNode;\n  /** Drive the indicator yourself. Leave it out and the row follows whatever its control reports through `track`. */\n  status?: SaveStatus;\n  /** Shown under the description when a save fails. */\n  errorText?: React.ReactNode;\n  /** Called by \"Try again\" when you control `status`. */\n  onRetry?: () => void;\n  disabled?: boolean;\n  /** Milliseconds the Saved tick stays before fading. */\n  savedFor?: number;\n};\n\n/** One setting: what it is on the left, the control on the right, and how its last save went. */\nexport function SettingsRow({\n  label,\n  description,\n  badge,\n  status: statusProp,\n  errorText = \"Couldn’t save this change.\",\n  onRetry,\n  disabled = false,\n  savedFor,\n  className,\n  children,\n  ...rest\n}: SettingsRowProps) {\n  const id = useId();\n  const reduce = useReducedMotion();\n  const save = useSaveStatus({ savedFor });\n  const controlled = statusProp !== undefined;\n  const shown: SaveStatus = controlled ? statusProp : save.visible;\n  const busy = controlled ? statusProp === \"saving\" : save.status === \"saving\";\n  const retry = controlled ? onRetry : save.canRetry ? save.retry : undefined;\n\n  const ctx = useMemo<RowCtx>(\n    () => ({ controlId: `${id}-control`, labelId: `${id}-label`, descriptionId: `${id}-description`, disabled, track: save.track }),\n    [id, disabled, save.track],\n  );\n\n  return (\n    <RowContext value={ctx}>\n      <div\n        data-status={shown}\n        data-disabled={disabled || undefined}\n        aria-busy={busy || undefined}\n        className={cn(\"flex flex-col px-4 py-3\", className)}\n        {...rest}\n      >\n        <div className=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3\">\n          <div className={cn(\"flex min-w-0 flex-[1_1_13rem] flex-col gap-0.5\", disabled && \"opacity-50\")}>\n            <div className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5\">\n              {/* A real label: pointed at a switch, the whole text becomes a target. */}\n              <label id={ctx.labelId} htmlFor={ctx.controlId} className=\"text-[13px] font-medium leading-5 tracking-[-0.005em] text-fg\">\n                {label}\n              </label>\n              {badge != null && (\n                <span className=\"inline-flex h-[18px] items-center rounded-full border border-line-2 px-1.5 font-mono text-[10px] uppercase leading-none tracking-[0.06em] text-fg-3\">\n                  {badge}\n                </span>\n              )}\n              <StatusMark status={shown} reduce={!!reduce} />\n            </div>\n            {description != null && (\n              <p id={ctx.descriptionId} className=\"text-[12.5px] leading-[18px] text-fg-3 text-pretty\">\n                {description}\n              </p>\n            )}\n          </div>\n          <div className=\"ml-auto flex shrink-0 items-center gap-2\">{children}</div>\n        </div>\n        {/* Below the whole row, so the control stays exactly where the pointer left it. */}\n        <AnimatePresence initial={false}>\n          {shown === \"error\" && (\n            <motion.div\n              key=\"error\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.14, ease: ease.out } }}\n              transition={{ duration: 0.22, ease: ease.out }}\n              className=\"overflow-hidden\"\n            >\n              <p role=\"alert\" className=\"flex flex-wrap items-center gap-x-1.5 pt-1.5 text-[12.5px] leading-[18px] text-danger\">\n                <span>{errorText}</span>\n                {retry && (\n                  <button\n                    type=\"button\"\n                    onClick={retry}\n                    className={cn(\n                      \"relative rounded-sm font-medium underline decoration-danger/40 underline-offset-[3px] transition-[text-decoration-color,scale] duration-150 hover:decoration-danger active:scale-[0.97]\",\n                      \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                      \"after:absolute after:-inset-x-1 after:-inset-y-3 after:content-[''] pointer-fine:after:hidden\",\n                    )}\n                  >\n                    Try again\n                  </button>\n                )}\n              </p>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </RowContext>\n  );\n}\n\nconst statusText: Record<Exclude<SaveStatus, \"idle\">, string> = { saving: \"Saving\", saved: \"Saved\", error: \"Not saved\" };\n\n// Sits on the label line, so the news is next to the thing it's about and nothing below it moves.\nfunction StatusMark({ status, reduce }: { status: SaveStatus; reduce: boolean }) {\n  const live = status === \"idle\" ? \"\" : status === \"error\" ? \"\" : statusText[status];\n  return (\n    <>\n      <AnimatePresence initial={false}>\n        {status !== \"idle\" && (\n          <motion.span\n            key=\"mark\"\n            aria-hidden\n            initial={reduce ? { opacity: 0 } : { opacity: 0, x: -4, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: 0.2, ease: ease.out } }}\n            transition={{ duration: 0.2, ease: ease.out }}\n            className={cn(\"inline-flex h-5 items-center gap-1 text-[12px] leading-none\", status === \"error\" ? \"text-danger\" : \"text-fg-3\")}\n          >\n            <span className=\"relative grid size-3 place-items-center\">\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={status}\n                  className=\"absolute inset-0 grid place-items-center\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                  transition={reduce ? { duration: 0.12 } : spring.pop}\n                >\n                  {status === \"saving\" && <Loader size={12} className=\"animate-spin-slow\" />}\n                  {status === \"saved\" && <DrawnCheck reduce={reduce} />}\n                  {status === \"error\" && <X size={12} />}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            {/* Every word in one cell, so the mark is as wide as its longest word and never jitters. */}\n            <span className=\"grid\">\n              {Object.values(statusText).map((t) => (\n                <span key={t} className=\"invisible col-start-1 row-start-1\">\n                  {t}\n                </span>\n              ))}\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={status}\n                  className=\"col-start-1 row-start-1\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                  transition={{ duration: 0.18, ease: ease.out }}\n                >\n                  {statusText[status as Exclude<SaveStatus, \"idle\">]}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {live}\n      </span>\n    </>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.3, ease: ease.out, delay: 0.04 }}\n      />\n    </svg>\n  );\n}\n\nexport type SettingsSwitchProps = Omit<BaseSwitch.Root.Props, \"className\" | \"children\" | \"onCheckedChange\" | \"checked\" | \"defaultChecked\"> & {\n  checked?: boolean;\n  defaultChecked?: boolean;\n  /** Return a promise and the switch flips at once, the row shows the save, and it flips back if the promise rejects. */\n  onCheckedChange?: (checked: boolean) => void | PromiseLike<unknown>;\n  className?: string;\n};\n\n/** A switch wired to its row: labelled by the row's text, reporting its saves to the row's indicator. */\nexport function SettingsSwitch({ checked: checkedProp, defaultChecked = false, onCheckedChange, disabled, className, ...rest }: SettingsSwitchProps) {\n  const row = useSettingsRow();\n  const reduce = useReducedMotion();\n  const [checked, setChecked] = useControllableState({ value: checkedProp, defaultValue: defaultChecked });\n  const [optimistic, setOptimistic] = useState<boolean | null>(null);\n  const [pressed, setPressed] = useState(false);\n  const seq = useRef(0);\n  const on = optimistic ?? checked;\n  const off = disabled || row?.disabled;\n\n  const attempt = (next: boolean) => {\n    const result = onCheckedChange?.(next);\n    if (!isThenable(result)) {\n      setChecked(next);\n      return;\n    }\n    const id = ++seq.current;\n    setOptimistic(next);\n    row?.track(result, () => attempt(next));\n    result.then(\n      () => {\n        if (id !== seq.current) return;\n        setChecked(next);\n        setOptimistic(null);\n      },\n      // Failed: go back to what's actually saved. The row says why.\n      () => id === seq.current && setOptimistic(null),\n    );\n  };\n\n  // Pressing stretches the thumb toward the middle, the way a finger would squash it.\n  const width = 16 + (pressed && !off ? 4 : 0);\n  const release = () => setPressed(false);\n\n  return (\n    <BaseSwitch.Root\n      id={row?.controlId}\n      checked={on}\n      onCheckedChange={attempt}\n      disabled={off}\n      aria-describedby={row ? row.descriptionId : undefined}\n      onPointerDown={(e) => e.button === 0 && setPressed(true)}\n      onPointerUp={release}\n      onPointerLeave={release}\n      onPointerCancel={release}\n      onKeyDown={(e) => e.key === \" \" && setPressed(true)}\n      onKeyUp={release}\n      onBlur={release}\n      className={cn(\n        \"relative inline-block h-5 w-9 shrink-0 rounded-full outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color] duration-200 ease-out-expo motion-reduce:transition-none\",\n        on ? \"bg-fg hover:bg-fg/90\" : \"bg-fg-4 hover:bg-fg-3\",\n        \"data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-fg-4\",\n        \"after:absolute after:-inset-x-1 after:-inset-y-3 after:content-['']\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        aria-hidden\n        className={cn(\"absolute left-0 top-0.5 h-4 rounded-full transition-[background-color] duration-200 motion-reduce:transition-none\", on ? \"bg-frame\" : \"bg-raised dark:bg-fg-2\")}\n        initial={false}\n        animate={{ x: on ? 36 - 2 - width : 2, width }}\n        transition={reduce ? { duration: 0 } : spring.snappy}\n      />\n    </BaseSwitch.Root>\n  );\n}\n\nexport type SettingsSelectOption = { value: string; label: React.ReactNode; disabled?: boolean };\n\nexport type SettingsSelectProps = {\n  options: SettingsSelectOption[];\n  value?: string;\n  defaultValue?: string;\n  /** Return a promise to show the save on the row; the choice reverts if it rejects. */\n  onValueChange?: (value: string) => void | PromiseLike<unknown>;\n  disabled?: boolean;\n  name?: string;\n  className?: string;\n  /** Portal target for the list. Defaults to document.body. */\n  container?: BaseSelect.Portal.Props[\"container\"];\n};\n\n/** A compact select for the right side of a row, labelled by the row. */\nexport function SettingsSelect({ options, value: valueProp, defaultValue, onValueChange, disabled, name, className, container }: SettingsSelectProps) {\n  const row = useSettingsRow();\n  const [value, setValue] = useControllableState<string | undefined>({ value: valueProp, defaultValue: defaultValue ?? options[0]?.value });\n  const [optimistic, setOptimistic] = useState<string | null>(null);\n  const seq = useRef(0);\n  const current = optimistic ?? value ?? null;\n  const items = useMemo(() => options.map((o) => ({ value: o.value, label: o.label })), [options]);\n\n  const attempt = (next: string) => {\n    const result = onValueChange?.(next);\n    if (!isThenable(result)) {\n      setValue(next);\n      return;\n    }\n    const id = ++seq.current;\n    setOptimistic(next);\n    row?.track(result, () => attempt(next));\n    result.then(\n      () => {\n        if (id !== seq.current) return;\n        setValue(next);\n        setOptimistic(null);\n      },\n      () => id === seq.current && setOptimistic(null),\n    );\n  };\n\n  return (\n    <BaseSelect.Root items={items} value={current} onValueChange={(v) => v != null && attempt(v as string)} disabled={disabled || row?.disabled} name={name}>\n      <BaseSelect.Trigger\n        aria-labelledby={row?.labelId}\n        aria-describedby={row?.descriptionId}\n        className={cn(\n          \"group/trigger relative inline-flex h-8 min-w-36 max-w-full items-center justify-between gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-2.5 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.98] active:duration-75\",\n          \"hover:border-fg-4 hover:bg-hover data-popup-open:border-fg-4 data-popup-open:bg-hover\",\n          \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n          \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:content-['']\",\n          className,\n        )}\n      >\n        <BaseSelect.Value className=\"min-w-0 truncate\" />\n        <ChevronsUpDown size={14} className=\"shrink-0 text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2\" />\n      </BaseSelect.Trigger>\n      <BaseSelect.Portal container={container}>\n        <BaseSelect.Positioner alignItemWithTrigger={false} side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <BaseSelect.Popup\n            className={cn(\n              \"min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <BaseSelect.List className=\"max-h-[min(var(--available-height),16rem)] overflow-y-auto overscroll-contain outline-none\">\n              {options.map((o) => (\n                <BaseSelect.Item\n                  key={o.value}\n                  value={o.value}\n                  disabled={o.disabled}\n                  className=\"flex h-8 cursor-default items-center gap-2 rounded-lg pr-2 pl-2 text-[13px] outline-none select-none data-highlighted:bg-line data-disabled:text-fg-4 pointer-coarse:h-10\"\n                >\n                  <BaseSelect.ItemText className=\"min-w-0 flex-1 truncate\">{o.label}</BaseSelect.ItemText>\n                  <span className=\"grid size-4 shrink-0 place-items-center\">\n                    <BaseSelect.ItemIndicator>\n                      <Check size={14} />\n                    </BaseSelect.ItemIndicator>\n                  </span>\n                </BaseSelect.Item>\n              ))}\n            </BaseSelect.List>\n          </BaseSelect.Popup>\n        </BaseSelect.Positioner>\n      </BaseSelect.Portal>\n    </BaseSelect.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/settings-row"
}
