{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-field",
  "type": "registry:ui",
  "title": "Number field",
  "description": "A labelled number with − and + inside the field's end: steps by the arrow keys, a press or a hold, reads what is typed in the page's locale, keeps it between its bounds and on its step, and shows it formatted with Intl.NumberFormat.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/number-field.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/number-field 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * NumberField: a number someone sets, such as seats on a plan, a limit or a price (components.md, \"Number field\").\n * The Input's field with − and + inside its end, a label above and a hint or an error below, like TextField.\n *\n * - Field: the Input's cut and sizes (sm 32, md 44, lg 56; sm grows to 44 on a touch screen): the --litt-cut floor, the\n *   --litt-line-control line and the top wall's shade (R3), the focus line and ring while the text is focus-visible, the\n *   danger line with an error, flat on surface-2 when disabled. The number at the text weight with tabular figures,\n *   so it does not shift as it changes, and an optional unit after it in --litt-fg-subtle (\"seats\", \"GB\").\n * - Steppers (Press): − then +, the two halves of one raised block beside the cut, 8px from it, a 1px seam between\n *   (R3: --litt-block, --litt-block-lift), each the field's height square, its outer corners the control radius and the corners\n *   at the seam square. You press them, so they stand up; pressed, a half sinks --litt-press-depth\n *   and its cast tightens (--litt-block-press) at once while the other half lifts 1px, so the rocker tips toward the side\n *   pressed. At its bound a half is disabled and lies flat in a socket (--litt-slot, --litt-tray-shade, --litt-fg-disabled): nothing\n *   to push. A press steps once; held, it repeats after 400ms, every 70ms, until it lets go or reaches the bound.\n *   They are not Tab stops (the arrow keys do the same in the field), and pressing one leaves focus where it was.\n * - Flat: one grey pill around three words. The figure in ink at the title weight with its unit after it in grey, and\n *   − and + set as words either side, no fill until the pointer is over them. No bubble, no icon, no shade. At a bound\n *   its word goes grey and light. Focus is the ink ring on the pill, an error the danger ring.\n * - Typing is free: what is typed is read when the field is left or Enter is pressed, in the page's locale (group\n *   separators, a comma decimal). Then it is kept between min and max, put on the step, and shown formatted\n *   (Intl.NumberFormat, with `format` for a currency or a percent). Text that is not a number goes back to the last\n *   value. Empty is allowed unless `required`, and gives null.\n * - Keys: ↑ and ↓ step, Shift with them or Page Up and Page Down step ten times, Home and End go to min and max.\n * - Motion: none of its own. The line fades on the instant tier, as the Input's does; a stepper's sink and hover fill\n *   run on the instant tier; the value changes at once.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): a step by a stepper or a key kicks the number\n *   the way it went (up from below for more, down from above for less) and it lands on fun mode's pop spring. The\n *   label can be dragged sideways to scrub the value, a step every 8px, and past min or max the number gives like a\n *   rubber band and springs back when let go. The steppers and keys still do everything a scrub does. Transform only,\n *   and the field rests exactly as it does without fun mode. Reduced motion turns it off.\n * - hideLabel keeps the label for screen readers only, where the row around the field already says what it counts (a\n *   cart line's quantity). A hidden label is not a scrub handle.\n * - Bodies (proposals/worlds.md, Bodies): the component resolves the props, the value, the keys and the hold into\n *   NumberFieldBodyProps and renders the body the material picks (byWorld). Press is the cut with its rocker, above.\n *   Flat is the one grey pill with the figure and − and + set as words. Device is an LCD readout (glass in a bezel\n *   well, the number in the mono) with − and + as keys on their 2px skirts beside the glass, not inside it; a key sinks\n *   the whole --litt-press-depth, and a held one repeats as above. The label and the hint stay the component's own; each\n *   body carries data-body on its field and never changes a prop, a key or what a screen reader hears.\n * - Accessibility: the input is a spinbutton named by the label, with aria-valuenow, -valuemin, -valuemax and the\n *   formatted value and unit as aria-valuetext. The steppers are named \"Decrease seats\" and \"Increase seats\".\n */\nimport { useEffect, useId, useMemo, useRef, useState, type ComponentProps, type KeyboardEvent, type MouseEventHandler, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from \"react\";\nimport { animate } from \"motion/react\";\nimport { byWorld, useFun, useMaterial } from \"../../lib/litt/motion\";\nimport { MinusIcon, PlusIcon } from \"./icons\";\nimport { NumberFieldDeviceBody } from \"./bodies/number-field.device\";\nimport { NumberFieldFlatBody } from \"./bodies/number-field.flat\";\n\nexport type NumberFieldSize = \"sm\" | \"md\" | \"lg\";\n\n/* The Input's cut (floor, line, shade), on a wrapper that holds the text and the steppers. The ring follows the text only. */\nconst FIELD =\n  \"relative flex w-full min-w-0 items-center rounded-litt-control bg-litt-field font-litt-field font-normal text-litt-field-ink \" +\n  \"shadow-[inset_0_0_0_1px_var(--litt-field-line),var(--litt-field-shade)] \" +\n  \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[input:focus-visible]:shadow-[inset_0_0_0_1px_var(--litt-focus),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"data-[invalid]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-field-shade)] \" +\n  \"data-[invalid]:has-[input:focus-visible]:shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:text-litt-fg-disabled data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border forced-colors:has-[input:focus-visible]:outline-2 forced-colors:has-[input:focus-visible]:outline-solid forced-colors:has-[input:focus-visible]:outline-offset-2\";\n\n/* Height, the text's size and the air before it, and the steppers' size: the field's height less 8px, so each block\n   sits 4px inside the field. */\nconst SIZE: Record<NumberFieldSize, NumberFieldSizeClasses> = {\n  sm: { field: \"h-litt-control-sm pointer-coarse:h-litt-control-md\", type: \"text-litt-small\", pad: \"pl-litt-control-pad-sm\", step: \"size-6 pointer-coarse:size-9\" },\n  md: { field: \"h-litt-control-md\", type: \"text-litt-body\", pad: \"pl-litt-control-pad-md\", step: \"size-9\" },\n  lg: { field: \"h-litt-control-lg\", type: \"text-(length:--litt-control-text-lg)\", pad: \"pl-litt-control-pad-lg\", step: \"size-12\" },\n};\n\n/** The classes a size gives its body: the field's height, the text's size, the air before it and the steppers' size. */\nexport type NumberFieldSizeClasses = { field: string; type: string; pad: string; step: string };\n\n/**\n * What every body of the NumberField draws (proposals/worlds.md, Bodies): the field between the label and the hint. It\n * is resolved once by NumberField, which owns the value, the keys, the hold and the fun mode, so a body only lays out\n * and styles. `input` is the spinbutton's props without a class; the body adds its own and attaches `inputRef`.\n * `minus` and `plus` are the stepper buttons' props without a class or children. Each body carries data-body on its\n * root, the field.\n */\nexport type NumberFieldBodyProps = {\n  /** The size's classes. */\n  sz: NumberFieldSizeClasses;\n  /** An error is showing. */\n  invalid: boolean;\n  disabled: boolean;\n  /** The word after the number, drawn aria-hidden. */\n  unit?: string;\n  /** The spinbutton's attributes and handlers. */\n  input: ComponentProps<\"input\">;\n  inputRef: RefObject<HTMLInputElement | null>;\n  /** The − button's attributes and handlers. */\n  minus: ComponentProps<\"button\">;\n  /** The + button's attributes and handlers. */\n  plus: ComponentProps<\"button\">;\n  /** A press on the field's air focuses the text; a press on a stepper does not. */\n  onFieldClick: MouseEventHandler<HTMLDivElement>;\n};\n\n/* The cut is the Input's field with nothing inside its end: the figure alone, on the --litt-cut floor. */\nconst CUT = FIELD;\n\n/* The rocker: one raised block beside the cut, split in two halves with a seam between. Each half is its own button on\n   the block's material (--litt-block, --litt-block-lift) and the control radius on its outer corners; the corners at the seam are square.\n   Pressed, a half sinks --litt-press-depth and the cast tightens (--litt-block-press) while\n   its partner lifts a touch (the rocker tips toward the side pressed). The sink is transitioned as `translate`, which\n   Tailwind's translate-y-* sets; the cast swaps at once (R2). At its bound a half lies flat (no lift, no fill,\n   --litt-fg-disabled) in the socket the block leaves, so the end that cannot move reads as such. */\nconst HALF =\n  \"litt-target relative grid h-full aspect-square shrink-0 place-items-center select-none [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-[background-color,color,translate] duration-(--litt-dur-instant) ease-litt \" +\n  \"enabled:cursor-pointer enabled:bg-litt-block enabled:text-litt-fg enabled:shadow-(--litt-block-lift) enabled:forced-colors:border \" +\n  \"enabled:hover:bg-(--litt-block-hover) \" +\n  \"enabled:active:shadow-(--litt-block-press) enabled:active:translate-y-(--litt-press-depth) \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-slot disabled:text-litt-fg-disabled disabled:shadow-(--litt-tray-shade) forced-colors:border\";\nconst HALF_MINUS = \" rounded-l-litt-control rounded-r-none group-has-[[data-dir=plus]:enabled:active]/rocker:enabled:-translate-y-px\";\nconst HALF_PLUS = \" rounded-r-litt-control rounded-l-none group-has-[[data-dir=minus]:enabled:active]/rocker:enabled:-translate-y-px\";\n\n/** Press, the cut for the figure and a rocker block beside it, − and + its two halves. */\nfunction PressBody({ sz, invalid, disabled, unit, input, inputRef, minus, plus, onFieldClick }: NumberFieldBodyProps) {\n  return (\n    <div data-body=\"press\" className={\"flex min-w-0 items-stretch gap-2 \" + sz.field} onClick={onFieldClick}>\n      <div data-invalid={invalid ? \"\" : undefined} data-disabled={disabled ? \"\" : undefined} className={CUT + \" flex-1 pr-1\"}>\n        <input\n          ref={inputRef}\n          {...input}\n          className={\n            \"h-full min-w-0 flex-1 bg-transparent tabular-nums outline-none placeholder:text-litt-field-ink-subtle disabled:cursor-not-allowed \" +\n            \"pointer-coarse:text-(length:--litt-control-text-lg) \" +\n            sz.pad +\n            \" \" +\n            sz.type\n          }\n        />\n        {unit && (\n          <span aria-hidden=\"true\" className={\"shrink-0 pr-2 pl-1 pointer-coarse:text-(length:--litt-control-text-lg) \" + (disabled ? \"text-litt-fg-disabled \" : \"text-litt-field-ink-subtle \") + sz.type}>\n            {unit}\n          </span>\n        )}\n      </div>\n      <div className=\"group/rocker flex shrink-0 gap-px\">\n        <button {...minus} data-dir=\"minus\" className={HALF + HALF_MINUS}>\n          <MinusIcon />\n        </button>\n        <button {...plus} data-dir=\"plus\" className={HALF + HALF_PLUS}>\n          <PlusIcon />\n        </button>\n      </div>\n    </div>\n  );\n}\n\nconst HOLD_DELAY = 400; // ms before a held stepper starts repeating\nconst HOLD_EVERY = 70; // ms between repeats\nconst SCRUB_PX = 8; // fun mode: px of sideways drag on the label per step\nconst SCRUB_SLOP = 4; // px before a press on the label is a scrub, not a click\nconst KICK = 4; // fun mode: px a step starts the number from, the way it went\n\n/** The decimals a step has, so 0.1 + 0.2 lands on 0.3 and not 0.30000000000000004. */\nconst decimalsOf = (n: number) => {\n  const s = String(n);\n  if (s.includes(\"e-\")) return Number(s.split(\"e-\")[1]);\n  return s.includes(\".\") ? s.split(\".\")[1]!.length : 0;\n};\n\nexport type NumberFieldProps = {\n  /** The label above the field. Required: it names the field and its steppers (\"Increase seats\"). */\n  label: ReactNode;\n  /** Hides the label from sight and keeps it for screen readers, where the row already says what the number is, such as a cart line's quantity. */\n  hideLabel?: boolean;\n  /** The value, or null when the field is empty. Pair it with onValueChange. Leave it out and the field keeps its own. */\n  value?: number | null;\n  /** The value to start with when you do not control it. */\n  defaultValue?: number | null;\n  /** Called with the new value after a step, a key or a typed number is read. */\n  onValueChange?: (value: number | null) => void;\n  /** The lowest value. − is disabled at it, and Home goes to it. */\n  min?: number;\n  /** The highest value. + is disabled at it, and End goes to it. */\n  max?: number;\n  /** How much one step moves, and the grid a typed value is put on (from min, or from 0). */\n  step?: number;\n  /** How the value is shown, as Intl.NumberFormat takes it: { style: \"currency\", currency: \"EUR\" }, { style: \"percent\" }, { maximumFractionDigits: 1 }. */\n  format?: Intl.NumberFormatOptions;\n  /** A word after the number, in the grey: \"seats\", \"GB\", \"days\". */\n  unit?: string;\n  /** A hint under the field. An error replaces it. */\n  help?: ReactNode;\n  /** Shown in the danger color under the field and linked to it. Also sets aria-invalid. Say how to fix it. */\n  error?: ReactNode;\n  /** Empty is not allowed: leaving the field empty goes back to the last value. */\n  required?: boolean;\n  /** Height on the shared control scale: sm is 32, md is 44, lg is 56. */\n  size?: NumberFieldSize;\n  disabled?: boolean;\n  /** The field name a form sends the value under, as a plain number. */\n  name?: string;\n  /** The id of the input, when you need your own. */\n  id?: string;\n  /** Extra classes for the whole component, such as a max width. */\n  className?: string;\n};\n\n/** A labelled number with − and + in the field: steps by key, by press or by hold, and reads what is typed in the page's locale. */\nexport function NumberField({\n  label,\n  hideLabel = false,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  min,\n  max,\n  step = 1,\n  format,\n  unit,\n  help,\n  error,\n  required = false,\n  size = \"md\",\n  disabled = false,\n  name,\n  id: idProp,\n  className = \"\",\n}: NumberFieldProps) {\n  const auto = useId();\n  const id = idProp ?? auto;\n  const noteId = `${id}-note`;\n  const note = error ?? help;\n  const [inner, setInner] = useState<number | null>(defaultValue);\n  const value = valueProp !== undefined ? valueProp : inner;\n  const input = useRef<HTMLInputElement>(null);\n\n  const formatter = useMemo(() => new Intl.NumberFormat(undefined, format), [format]);\n  /* The page locale's own marks, so \"1.234,5\" reads as 1234.5 in German and \"1,234.5\" in English. */\n  const marks = useMemo(() => {\n    const parts = new Intl.NumberFormat(undefined).formatToParts(-12345.6);\n    const of = (type: string) => parts.find((p) => p.type === type)?.value ?? \"\";\n    return { group: of(\"group\"), decimal: of(\"decimal\") || \".\" };\n  }, []);\n  const percent = format?.style === \"percent\";\n  const show = (n: number | null) => (n === null ? \"\" : formatter.format(n));\n\n  /* While someone types, the field holds their text; otherwise it shows the value, formatted. */\n  const [draft, setDraft] = useState<string | null>(null);\n  const text = draft ?? show(value);\n\n  const fit = (n: number) => {\n    const base = min ?? 0;\n    const places = Math.max(decimalsOf(step), decimalsOf(base));\n    let v = Math.round((n - base) / step) * step + base;\n    v = Number(v.toFixed(places));\n    if (min !== undefined) v = Math.max(min, v);\n    if (max !== undefined) v = Math.min(max, v);\n    return v;\n  };\n\n  const set = (next: number | null) => {\n    if (valueProp === undefined) setInner(next);\n    if (next !== value) onValueChange?.(next);\n  };\n\n  const parse = (raw: string): number | null | undefined => {\n    let s = raw.trim();\n    if (!s) return null;\n    if (marks.group) s = s.split(marks.group).join(\"\");\n    s = s.replace(/[\\s  ]/g, \"\").split(marks.decimal).join(\".\");\n    s = s.replace(/[−‒–]/g, \"-\").replace(/[^\\d.-]/g, \"\");\n    if (!s || s === \"-\" || s === \".\") return undefined;\n    const n = Number(s);\n    if (!Number.isFinite(n)) return undefined;\n    return percent ? n / 100 : n;\n  };\n\n  const commit = () => {\n    if (draft === null) return;\n    const n = parse(draft);\n    setDraft(null);\n    if (n === undefined || (n === null && required)) return; // not a number: back to the last value\n    set(n === null ? null : fit(n));\n  };\n\n  /* Fun mode: the number lands from the way it went. */\n  const fun = useFun();\n  const kick = (dir: number) => {\n    if (fun && input.current && dir) void animate(input.current, { y: [dir > 0 ? KICK : -KICK, 0] }, fun.pop);\n  };\n\n  const stepBy = (times: number) => {\n    const from = value ?? (times > 0 ? (min ?? 0) - step : (max ?? 0) + step);\n    setDraft(null);\n    const next = fit(from + times * step);\n    set(next);\n    if (fun) kick(Math.sign(next - (value ?? next - times)));\n  };\n\n  const atMin = min !== undefined && value !== null && value <= min;\n  const atMax = max !== undefined && value !== null && value >= max;\n\n  const jumpTo = (bound: number) => {\n    setDraft(null);\n    set(bound);\n    if (fun) kick(Math.sign(bound - (value ?? bound)));\n  };\n\n  /* Fun mode: scrub by dragging the label sideways. Past a bound the number gives like a rubber band. */\n  const scrub = (e: ReactPointerEvent<HTMLLabelElement>) => {\n    if (!fun || disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    const el = e.currentTarget;\n    const id0 = e.pointerId;\n    const x0 = e.clientX;\n    const start = value ?? min ?? 0;\n    let last = value;\n    let claimed = false;\n    const move = (ev: PointerEvent) => {\n      if (ev.pointerId !== id0) return;\n      const dx = ev.clientX - x0;\n      if (!claimed) {\n        if (Math.abs(dx) < SCRUB_SLOP) return;\n        claimed = true;\n        try {\n          el.setPointerCapture(id0);\n        } catch {\n          /* the pointer is already gone */\n        }\n      }\n      const raw = start + Math.round(dx / SCRUB_PX) * step;\n      const next = fit(raw);\n      if (next !== last) {\n        /* Told directly, not through set(): set compares with the value this render saw, which a scrub has moved on from. */\n        last = next;\n        setDraft(null);\n        if (valueProp === undefined) setInner(next);\n        onValueChange?.(next);\n      }\n      const over = raw === next ? 0 : ((raw - next) / step) * SCRUB_PX;\n      if (input.current) input.current.style.translate = over ? `${fun.rubber(over)}px` : \"\";\n    };\n    const up = (ev: PointerEvent) => {\n      if (ev.pointerId !== id0) return;\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      if (!claimed) return;\n      /* The click that ends a scrub is not a press on the label, which would focus the text. */\n      const swallow = (c: Event) => {\n        c.stopPropagation();\n        c.preventDefault();\n      };\n      window.addEventListener(\"click\", swallow, { capture: true, once: true });\n      setTimeout(() => window.removeEventListener(\"click\", swallow, true), 0);\n      const field = input.current;\n      const pulled = field ? parseFloat(field.style.translate) || 0 : 0;\n      if (field && pulled) void animate(pulled, 0, { ...fun.throw, onUpdate: (v) => (field.style.translate = v ? `${v}px` : \"\") });\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    const times = e.shiftKey ? 10 : 1;\n    switch (e.key) {\n      case \"ArrowUp\":\n        stepBy(times);\n        break;\n      case \"ArrowDown\":\n        stepBy(-times);\n        break;\n      case \"PageUp\":\n        stepBy(10);\n        break;\n      case \"PageDown\":\n        stepBy(-10);\n        break;\n      case \"Home\":\n        if (min === undefined) return;\n        jumpTo(min);\n        break;\n      case \"End\":\n        if (max === undefined) return;\n        jumpTo(max);\n        break;\n      case \"Enter\":\n        commit();\n        return;\n      default:\n        return;\n    }\n    e.preventDefault();\n  };\n\n  /* Held steppers repeat. The latest stepBy is read through a ref, so a repeat sees the value the last one set. */\n  const latest = useRef(stepBy);\n  latest.current = stepBy;\n  const timers = useRef<{ delay?: number; every?: number }>({});\n  const stop = () => {\n    window.clearTimeout(timers.current.delay);\n    window.clearInterval(timers.current.every);\n    timers.current = {};\n  };\n  useEffect(() => stop, []);\n  useEffect(() => {\n    if (atMin || atMax) stop(); // the bound ends a hold\n  }, [atMin, atMax]);\n  /* A press with a pointer steps on pointerdown; the click that follows must not step again. */\n  const pointerStep = useRef(false);\n\n  const stepperProps = (dir: 1 | -1): ComponentProps<\"button\"> => {\n    const bound = dir < 0 ? atMin : atMax;\n    const word = dir < 0 ? \"Decrease\" : \"Increase\";\n    return {\n      type: \"button\",\n      tabIndex: -1,\n      \"aria-label\": typeof label === \"string\" ? `${word} ${label.toLowerCase()}` : word,\n      \"aria-controls\": id,\n      disabled: disabled || bound,\n      onPointerDown: (e: ReactPointerEvent<HTMLButtonElement>) => {\n        if (e.button !== 0) return;\n        e.preventDefault(); // focus stays where it was\n        pointerStep.current = true;\n        latest.current(dir);\n        timers.current.delay = window.setTimeout(() => {\n          timers.current.every = window.setInterval(() => latest.current(dir), HOLD_EVERY);\n        }, HOLD_DELAY);\n      },\n      onPointerUp: stop,\n      onPointerLeave: stop,\n      onPointerCancel: stop,\n      onClick: () => {\n        /* A screen reader or a keyboard activates without a pointer. */\n        if (pointerStep.current) pointerStep.current = false;\n        else stepBy(dir);\n      },\n    };\n  };\n\n  const Body = byWorld(useMaterial(), { press: PressBody, flat: NumberFieldFlatBody, device: NumberFieldDeviceBody });\n  const valueText = value === null ? undefined : `${show(value)}${unit ? ` ${unit}` : \"\"}`;\n\n  return (\n    <div className={\"grid gap-2 \" + className}>\n      <label\n        htmlFor={id}\n        onPointerDown={fun && !hideLabel ? scrub : undefined}\n        className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\") + (hideLabel ? \" sr-only\" : fun && !disabled ? \" w-fit cursor-ew-resize touch-pan-y select-none\" : \"\")}\n      >\n        {label}\n      </label>\n      <Body\n        sz={SIZE[size]}\n        invalid={!!error}\n        disabled={disabled}\n        unit={unit}\n        inputRef={input}\n        input={{\n          id,\n          type: \"text\",\n          inputMode: (format?.maximumFractionDigits ?? 0) > 0 || decimalsOf(step) > 0 || percent ? \"decimal\" : \"numeric\",\n          role: \"spinbutton\",\n          autoComplete: \"off\",\n          spellCheck: false,\n          \"aria-valuenow\": value ?? undefined,\n          \"aria-valuemin\": min,\n          \"aria-valuemax\": max,\n          \"aria-valuetext\": valueText,\n          \"aria-invalid\": error ? true : undefined,\n          \"aria-describedby\": note ? noteId : undefined,\n          \"aria-required\": required || undefined,\n          disabled,\n          value: text,\n          onChange: (e) => setDraft(e.target.value),\n          onBlur: commit,\n          onKeyDown,\n        }}\n        minus={stepperProps(-1)}\n        plus={stepperProps(1)}\n        onFieldClick={(e) => {\n          /* A press on the field's air focuses the text; a press on a stepper leaves focus where it was, so a phone's\n             keyboard does not open under a thumb that is only stepping. */\n          if (!(e.target as Element).closest(\"button\")) input.current?.focus();\n        }}\n      />\n      {name && <input type=\"hidden\" name={name} value={value ?? \"\"} />}\n      {note && (\n        <p id={noteId} className={\"text-litt-small \" + (error ? \"text-litt-danger\" : \"text-litt-fg-subtle\")}>\n          {note}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/number-field.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/number-field.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/number-field 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * NumberField, the Device body (proposals/worlds.md, Bodies; material.md, 8): an LCD readout with keys.\n *\n * - A bezel well (--litt-tray, --litt-tray-shade) the field's height holds three things in a row, 4px in from its walls: a key\n *   for −, the glass, a key for +. The keys are beside the glass, not inside it.\n * - The glass (--litt-field, --litt-field-shade, the mono through font-litt-field): the figure at the text size with tabular figures,\n *   right-aligned the way a readout is, its unit after it in --litt-field-ink-subtle. It is the one spinbutton; the text\n *   takes the focus ring on the well, and an error puts the danger line on the well.\n * - A key (--litt-block, --litt-key-lift: its face, a dark rim and a 2px skirt), square at the field's height less 8px, with\n *   corners the well's radius less its inset so the two stay concentric. Pressed, it sinks the whole --litt-press-depth\n *   (2px) and its cast steps down (--litt-block-press) on the instant tier, and a held key repeats as the contract says.\n *   Fun mode's deeper sink is not used: a key travels 2px. At its bound a key lies flat: --litt-surface-2 with a hairline\n *   and --litt-fg-disabled glyph.\n * - Motion: nothing glides. The value changes at once, the key's sink and fill step on the instant tier, and reduced\n *   motion zeroes the tier.\n * - Forced colors drops shadows and fills, so the well, the glass and the keys get real borders.\n */\nimport type { NumberFieldBodyProps } from \"../number-field\";\nimport { MinusIcon, PlusIcon } from \"../icons\";\n\n/* The bezel is cut into the body. It clips the keys' cast, so nothing spills past its walls. */\nconst well =\n  \"relative flex w-full min-w-0 items-center gap-1 overflow-hidden rounded-litt-control p-1 \" +\n  \"bg-litt-tray shadow-(--litt-tray-shade) \" +\n  \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-litt-focus \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border\";\nconst wellInvalid = \" shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-tray-shade)]\";\n\n/* The glass, concentric with the well: the same radius as a key. */\nconst glass =\n  \"flex min-w-0 flex-1 items-center self-stretch rounded-[max(calc(var(--litt-radius-control)-4px),min(calc(var(--litt-radius-control)-2px),2px))] \" +\n  \"bg-litt-field font-litt-field text-litt-field-ink shadow-(--litt-field-shade) \" +\n  \"has-[input:disabled]:bg-transparent has-[input:disabled]:text-litt-fg-disabled has-[input:disabled]:shadow-none \" +\n  \"forced-colors:border\";\n\n/* A key on its skirt. Its sink and fill step on the instant tier. */\nconst key =\n  \"litt-target relative grid shrink-0 place-items-center rounded-[max(calc(var(--litt-radius-control)-4px),min(calc(var(--litt-radius-control)-2px),2px))] select-none [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-[background-color,color,transform,box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"enabled:cursor-pointer enabled:bg-litt-block enabled:text-litt-fg enabled:shadow-(--litt-key-lift) enabled:forced-colors:border \" +\n  \"enabled:hover:bg-(--litt-block-hover) \" +\n  \"enabled:active:shadow-(--litt-block-press) enabled:active:[transform:translateY(var(--litt-press-depth))] \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:text-litt-fg-disabled disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] disabled:forced-colors:border\";\n\n/** Device: a readout on LCD glass in a bezel well, with − and + as keys beside the glass. */\nexport function NumberFieldDeviceBody({ sz, invalid, disabled, unit, input, inputRef, minus, plus, onFieldClick }: NumberFieldBodyProps) {\n  return (\n    <div\n      data-body=\"device\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={well + (invalid && !disabled ? wellInvalid : \"\") + \" \" + sz.field}\n      onClick={onFieldClick}\n    >\n      <button {...minus} className={key + \" \" + sz.step}>\n        <MinusIcon />\n      </button>\n      <span className={glass}>\n        <input\n          ref={inputRef}\n          {...input}\n          className={\n            \"h-full min-w-0 flex-1 bg-transparent text-right tabular-nums outline-none placeholder:text-litt-field-ink-subtle disabled:cursor-not-allowed \" +\n            \"pointer-coarse:text-(length:--litt-control-text-lg) \" +\n            \"pl-2 \" +\n            (unit ? \"pr-0 \" : \"pr-2 \") +\n            sz.type\n          }\n        />\n        {unit && (\n          <span aria-hidden=\"true\" className={\"shrink-0 pr-2 pl-1.5 pointer-coarse:text-(length:--litt-control-text-lg) \" + (disabled ? \"text-litt-fg-disabled \" : \"text-litt-field-ink-subtle \") + sz.type}>\n            {unit}\n          </span>\n        )}\n      </span>\n      <button {...plus} className={key + \" \" + sz.step}>\n        <PlusIcon />\n      </button>\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/number-field.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/number-field.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/number-field 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * NumberField, the Flat body (proposals/worlds.md, Bodies; material.md, 7): one grey pill around three words. The\n * figure sits in the middle in ink at the title weight (its unit after it in grey), and − and + are the words either\n * side, set in ink with no fill until a hand is over them. Nothing is nested, nothing is framed and nothing casts: no\n * bubble inside the pill, no icons, no seam. At a bound its word goes grey and light, so the end that cannot move is\n * the one in weaker ink. Focus is the ink ring on the pill, an error the danger ring.\n */\nimport type { NumberFieldBodyProps } from \"../number-field\";\n\n/* The pill: the Input's floor with no line, the ring and the danger ring as its only edges. */\nconst pill =\n  \"relative flex w-full min-w-0 items-center rounded-litt-pill bg-litt-field p-1 font-litt-field text-litt-field-ink \" +\n  \"transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"shadow-none has-[input:focus-visible]:shadow-(--litt-focus-ring) \" +\n  \"data-[disabled]:cursor-not-allowed data-[disabled]:bg-litt-surface-2 data-[disabled]:text-litt-fg-disabled \" +\n  \"forced-colors:border forced-colors:has-[input:focus-visible]:outline-2 forced-colors:has-[input:focus-visible]:outline-solid forced-colors:has-[input:focus-visible]:outline-offset-2\";\nconst pillInvalid = \" shadow-[0_0_0_2px_var(--litt-canvas),0_0_0_4px_var(--litt-danger)]\";\n\n/* A word on the pill: a round target, no fill until the pointer is over it. At its bound it is grey and light. */\nconst word =\n  \"litt-target relative grid shrink-0 place-items-center rounded-litt-pill font-litt-title select-none [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-[background-color,color] duration-(--litt-dur-instant) ease-litt \" +\n  \"enabled:cursor-pointer enabled:bg-transparent enabled:text-litt-fg enabled:hover:bg-(--litt-block-hover) \" +\n  \"disabled:cursor-not-allowed disabled:font-normal disabled:text-litt-fg-disabled\";\n\n/** Flat, the page: one grey pill, − the figure +, set as words. */\nexport function NumberFieldFlatBody({ sz, invalid, disabled, unit, input, inputRef, minus, plus, onFieldClick }: NumberFieldBodyProps) {\n  const type = sz.type + \" pointer-coarse:text-(length:--litt-control-text-lg)\";\n  return (\n    <div\n      data-body=\"flat\"\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={pill + (invalid && !disabled ? pillInvalid : \"\") + \" \" + sz.field}\n      onClick={onFieldClick}\n    >\n      <button {...minus} className={word + \" order-1 \" + sz.step + \" \" + type}>\n        <span aria-hidden=\"true\">−</span>\n      </button>\n      <span className=\"order-2 flex min-w-0 flex-1 items-baseline justify-center gap-1.5 px-1\">\n        <input\n          ref={inputRef}\n          {...input}\n          className={\"field-sizing-content min-w-[2ch] max-w-full bg-transparent text-center font-litt-title tabular-nums outline-none placeholder:text-litt-field-ink-subtle disabled:cursor-not-allowed disabled:font-normal \" + type}\n        />\n        {unit && (\n          <span aria-hidden=\"true\" className={\"shrink-0 \" + (disabled ? \"text-litt-fg-disabled \" : \"text-litt-field-ink-subtle \") + type}>\n            {unit}\n          </span>\n        )}\n      </span>\n      <button {...plus} className={word + \" order-3 \" + sz.step + \" \" + type}>\n        <span aria-hidden=\"true\">+</span>\n      </button>\n    </div>\n  );\n}\n",
      "target": "components/litt/bodies/number-field.flat.tsx"
    }
  ],
  "docs": "Import NumberField from components/litt/number-field. Pass a label, min, max and step, and a unit or a format for a currency or a percent. The value is a number, or null when empty.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
