{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-input",
  "title": "Number input",
  "description": "A number on a baseline, yours in italic and formatted; less and more step it, and each step turns the changed figures over like a counter. Or it stands over a small ruler you can drag.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/button.json",
    "https://thedirectors.agency/ui/r/field.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/number-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useDigitRoll } from \"@/lib/0nlytype/use-digit-roll\"\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { reader, raw } from \"@/lib/0nlytype/number\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { useFieldControl, useLineOrigin } from \"@/components/ui/field\"\n\ntype Unit = string | (Partial<Record<Intl.LDMLPluralRule, string>> & { other: string })\n\ntype NumberInputProps = Omit<React.ComponentProps<\"input\">, \"type\" | \"value\" | \"defaultValue\" | \"min\" | \"max\" | \"step\" | \"size\" | \"onChange\"> & {\n  value?: number | null\n  defaultValue?: number | null\n  onValueChange?: (value: number | null) => void\n  min?: number\n  max?: number\n  step?: number\n  /** How the number reads when you're not typing it, passed to Intl.NumberFormat: { style: \"currency\", currency: \"EUR\" }. */\n  format?: Intl.NumberFormatOptions\n  /** The locale for reading and writing it. Fixed by default so the server and the browser agree on the first paint. */\n  locale?: string\n  /** Ours, after your figure, upright in pencil. Forms by plural category agree with the number: { one: \"guest\", other: \"guests\" }. */\n  unit?: Unit\n  /**\n   * line: your figure on a field's baseline, less and more in brackets at its end. scale: your figure stands over a\n   * ruler that hangs from the baseline; drag the ruler to turn the number, or step it and the ruler slides a tick.\n   */\n  variant?: \"line\" | \"scale\"\n  /** Pins a state for documentation (\"focus\"); set on the root. */\n  \"data-force\"?: string\n}\n\nconst decimals = (n: number) => (String(n).split(\".\")[1] ?? \"\").length\n\n/**\n * The formatted number, turned over like a counter's wheels: if it keeps its length only the figures that changed\n * turn, the units first and each place one arpeggio later; otherwise it turns over whole. `instant` skips the turn.\n */\nfunction Figures({ text, value, instant }: { text: string; value: number | null; instant: boolean }) {\n  const { shown: valueShown, whole, figs } = useDigitRoll(text, { order: value ?? 0, instant })\n  const shown = String(valueShown)\n\n  return (\n    <span ref={whole} className=\"ot-number-figures\" aria-hidden=\"true\">\n      {[...shown].map((c, k) => (\n        <span key={shown.length - k} ref={(el) => void (figs.current[k] = el)} className=\"ot-number-figure\">\n          {c}\n        </span>\n      ))}\n    </span>\n  )\n}\n\n/**\n * A number on a baseline. Your figure is italic and reads formatted; type into it and you edit the plain figures.\n * Up and Down step it, Page Up and Page Down step ten, Home and End go to the ends; less and more do it by hand.\n * Each step turns the changed figures over like a counter.\n */\nfunction NumberInput({\n  value,\n  defaultValue = null,\n  onValueChange,\n  min,\n  max,\n  step = 1,\n  format,\n  locale = \"en\",\n  unit,\n  variant = \"line\",\n  name,\n  disabled,\n  readOnly,\n  placeholder,\n  className,\n  onFocus,\n  onBlur,\n  onKeyDown,\n  onPointerDown,\n  \"data-force\": force,\n  ref: forwardedRef, ...props\n}: NumberInputProps) {\n  const field = useFieldControl()\n  const [own, setOwn] = React.useState<number | null>(defaultValue)\n  const current = value !== undefined ? value : own\n  const [typed, setDraft] = React.useState(\"\")\n  const [editing, setEditing] = React.useState(false)\n  const [held, setHeld] = React.useState(false)\n  const input = React.useRef<HTMLInputElement>(null)\n  const composedRef = useComposedRefs(input, forwardedRef)\n  const read = React.useMemo(() => reader(locale), [locale])\n  const origin = useLineOrigin<HTMLInputElement>((el) => el.closest<HTMLElement>(\".ot-number\") ?? el, { onPointerDown, onFocus } as React.ComponentProps<\"input\">)\n\n  // While you type, the input holds what you typed; otherwise the plain figures of the value, whoever changed it.\n  const draft = editing ? typed : raw(current, locale)\n\n  const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n))\n  const commit = (n: number | null) => {\n    if (value === undefined) setOwn(n)\n    if (!Object.is(n, current)) onValueChange?.(n)\n  }\n  /** Moves by whole steps from min (or zero), so 0.1 + 0.2 lands on 0.3 and not beside it. */\n  const stepBy = (steps: number, from = current) => {\n    if (disabled || readOnly) return\n    const base = min ?? 0\n    const places = Math.max(decimals(step), decimals(base))\n    const at = from === null ? base : base + Math.round((from - base) / step + steps) * step\n    commit(clamp(Number(at.toFixed(places))))\n    setEditing(false)\n  }\n\n  // The figures you read take a true minus sign, not the hyphen the formatter gives.\n  const text = current === null ? \"\" : new Intl.NumberFormat(locale, format).format(current).replace(/-/g, \"\\u2212\")\n  const forms = new Intl.PluralRules(locale)\n  const said = unit === undefined ? \"\" : typeof unit === \"string\" ? unit : (unit[forms.select(current ?? 0)] ?? unit.other)\n  const atMin = current !== null && min !== undefined && current <= min\n  const atMax = current !== null && max !== undefined && current >= max\n\n  const press = (steps: number) => ({\n    tabIndex: -1,\n    disabled: disabled || readOnly || (steps < 0 ? atMin : atMax),\n    // Keeps focus (and the caret) where it was: the buttons are the hand's way in, the keys are the keyboard's.\n    onPointerDown: (e: React.PointerEvent) => e.preventDefault(),\n    onClick: () => stepBy(steps, editing ? (read(draft) ?? current) : current),\n  })\n\n  // scale: the ruler follows the hand, one step per tick, and the number with it.\n  const drag = React.useRef<{ x: number; from: number | null } | null>(null)\n  const pitch = 10 // px per step, as --ot-number-pitch\n  const tape = {\n    onPointerDown(e: React.PointerEvent<HTMLElement>) {\n      if (disabled || readOnly || e.button !== 0) return\n      e.currentTarget.setPointerCapture(e.pointerId)\n      drag.current = { x: e.clientX, from: current }\n      setHeld(true)\n    },\n    onPointerMove(e: React.PointerEvent<HTMLElement>) {\n      const d = drag.current\n      if (!d) return\n      stepBy(Math.round((d.x - e.clientX) / pitch), d.from)\n    },\n    onPointerUp() {\n      drag.current = null\n      setHeld(false)\n    },\n    onPointerCancel() {\n      drag.current = null\n      setHeld(false)\n    },\n  }\n\n  return (\n    <div\n      data-slot=\"number-input\"\n      data-variant={variant}\n      data-editing={editing ? \"\" : undefined}\n      data-held={held ? \"\" : undefined}\n      data-empty={current === null ? \"\" : undefined}\n      data-force={force}\n      className={cn(\"ot-number\", className)}\n      style={{ \"--at\": ((current ?? 0) / step) % 100000 } as React.CSSProperties}\n    >\n      <span className=\"ot-number-reading\">\n        <span className=\"ot-number-cell\">\n          <input\n            data-slot=\"number-input-control\"\n            {...props}\n            ref={composedRef}\n            className=\"ot-number-input\"\n            type=\"text\"\n            inputMode={(min ?? -1) >= 0 && Number.isInteger(step) ? \"numeric\" : \"decimal\"}\n            role=\"spinbutton\"\n            autoComplete=\"off\"\n            id={props.id ?? field.id}\n            aria-invalid={props[\"aria-invalid\"] ?? field[\"aria-invalid\"]}\n            aria-describedby={props[\"aria-describedby\"] ?? field[\"aria-describedby\"]}\n            aria-valuenow={current ?? undefined}\n            aria-valuemin={min}\n            aria-valuemax={max}\n            aria-valuetext={current === null ? undefined : `${text}${said ? ` ${said}` : \"\"}`}\n            disabled={disabled}\n            readOnly={readOnly}\n            placeholder={placeholder}\n            value={draft}\n            onChange={(e) => {\n              setEditing(true)\n              setDraft(e.target.value)\n              const n = read(e.target.value)\n              if (n !== undefined) commit(n)\n            }}\n            onPointerDown={(e) => {\n              origin.onPointerDown(e)\n              if (disabled || readOnly || editing) return\n              setDraft(draft) // a touch in the figures puts the caret among the plain ones\n              setEditing(true)\n            }}\n            onFocus={origin.onFocus}\n            onBlur={(e) => {\n              onBlur?.(e)\n              const n = read(draft)\n              commit(n === undefined ? current : n === null ? null : clamp(n))\n              setEditing(false)\n            }}\n            onKeyDown={(e) => {\n              onKeyDown?.(e)\n              if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.nativeEvent.isComposing) return\n              const from = editing ? (read(draft) ?? current) : current\n              const keys: Record<string, () => void> = {\n                ArrowUp: () => stepBy(1, from),\n                ArrowDown: () => stepBy(-1, from),\n                PageUp: () => stepBy(10, from),\n                PageDown: () => stepBy(-10, from),\n                ...(min !== undefined ? { Home: () => stepBy(0, min) } : {}),\n                ...(max !== undefined ? { End: () => stepBy(0, max) } : {}),\n                Enter: () => {\n                  const n = read(draft)\n                  commit(n === undefined ? current : n === null ? null : clamp(n))\n                  setEditing(false)\n                },\n                Escape: () => setEditing(false),\n              }\n              const act = keys[e.key]\n              if (!act || readOnly || disabled) return\n              if (e.key !== \"Enter\") e.preventDefault()\n              act()\n            }}\n          />\n          <Figures text={text} value={current} instant={held} />\n        </span>\n        {said ? (\n          <span className=\"ot-number-unit\" aria-hidden=\"true\">\n            {said}\n          </span>\n        ) : null}\n      </span>\n      <Button variant=\"bracket\" className=\"ot-number-less\" aria-label=\"Less\" {...press(-1)}>\n        less\n      </Button>\n      <Button variant=\"bracket\" className=\"ot-number-more\" aria-label=\"More\" {...press(1)}>\n        more\n      </Button>\n      {variant === \"scale\" ? <span className=\"ot-number-tape\" aria-hidden=\"true\" {...tape} /> : null}\n      {name ? <input type=\"hidden\" name={name} value={current ?? \"\"} disabled={disabled} /> : null}\n    </div>\n  )\n}\n\nexport { NumberInput, type NumberInputProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/number-input.css",
      "content": "@layer components {\n  /* ── ot-number ── a number on a field's baseline: your figure in italic, formatted, turning over like a counter as it\n     steps; less and more in brackets at the line's end. Typing shows the plain figures you edit. */\n  .ot-number {\n    --ot-number-pitch: 10px; /* one step of the scale's ruler; the script steps by the same */\n    position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: baseline; column-gap: var(--ot-space-1);\n    font-size: var(--ot-mp); line-height: 1.3;\n    background: linear-gradient(var(--ot-rule-strong), var(--ot-rule-strong)) no-repeat 0 100% / 100% var(--ot-hairline);\n  }\n  .ot-field > .ot-number { grid-column: 1 / -1; }\n  .ot-number::after {\n    content: \"\"; position: absolute; inset-inline: 0; bottom: 0; height: var(--ot-stroke); background: var(--ot-accent);\n    scale: 0 1; transform-origin: var(--o, 0%) 50%; pointer-events: none; transition: scale var(--ot-moderato) var(--ot-exhale);\n  }\n  .ot-number:focus-within::after, .ot-number[data-force~=\"focus\"]::after { scale: 1 1; }\n  .ot-number[data-force~=\"focus\"] { outline: 0; }\n  .ot-number:has(.ot-number-input[aria-invalid=\"true\"]) { background-image: linear-gradient(var(--ot-signal), var(--ot-signal)); }\n  .ot-number:has(.ot-number-input[aria-invalid=\"true\"])::after { background: var(--ot-signal); }\n  .ot-number:has(.ot-number-input:disabled) { background-image: repeating-linear-gradient(90deg, var(--ot-rule-strong) 0 2px, transparent 2px 6px); }\n\n  .ot-number-reading { display: flex; align-items: baseline; column-gap: 0.28em; min-width: 0; }\n  /* The input and the figures share one cell: the figures are what you see, the input is what you edit. */\n  .ot-number-cell { display: grid; flex: 1; min-width: 0; direction: ltr; unicode-bidi: isolate; } /* figures read left to right on a right-to-left page too */\n  .ot-number-input, .ot-number-figures {\n    grid-area: 1 / 1; margin: 0; padding: 0.3em 0 0.42em;\n    font-family: var(--ot-expression); font-style: italic; font-weight: 400; font-size: calc(var(--ot-expression-scale) * 1em); line-height: 1.3;\n    font-variant-numeric: lining-nums tabular-nums; color: var(--ot-ink);\n  }\n  .ot-number-input {\n    appearance: none; width: 100%; min-width: 0; border: 0; border-radius: 0; outline: 0; background: none;\n    color: transparent; caret-color: transparent;\n  }\n  .ot-number-input::placeholder { font-family: var(--ot-voice); font-style: normal; font-size: calc(1em / var(--ot-expression-scale)); color: var(--ot-pencil); opacity: 1; }\n  .ot-number:not([data-editing]) .ot-number-input::selection { background: transparent; }\n  .ot-number[data-editing] .ot-number-input { color: var(--ot-ink); caret-color: var(--ot-accent); }\n  .ot-number[data-editing] .ot-number-figures { visibility: hidden; }\n  .ot-number-figures { pointer-events: none; white-space: pre; overflow: hidden; }\n  .ot-number-figure { display: inline-block; }\n  .ot-number:has(.ot-number-input:disabled) .ot-number-figures { color: var(--ot-pencil); }\n  .ot-number-input:disabled { cursor: not-allowed; }\n  /* Your figure is as wide as it is, so the unit follows it and the line reads as one phrase: 12 guests. */\n  @supports (field-sizing: content) {\n    .ot-number-cell { flex: none; } /* your figure never gives way; the unit does */\n    .ot-number-input { field-sizing: content; min-width: 1ch; }\n  }\n  .ot-number-unit { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--ot-pencil); white-space: nowrap; }\n  .ot-number > .ot-btn { font-size: var(--ot-p); }\n\n  /* ── scale ── the dial's tuner, the ruler drawn small: your figure stands over the index, and the baseline is the edge of\n     a ruler whose ticks hang from it, one per step, a longer one every ten. Stepping slides it a tick; drag it and it\n     follows the hand. Figures and ruler read left to right in a right-to-left page too. */\n  .ot-number[data-variant=\"scale\"] {\n    direction: ltr; grid-template-columns: auto minmax(0, 1fr) auto; row-gap: var(--ot-space-1); background: none;\n  }\n  .ot-number[data-variant=\"scale\"]::after { display: none; }\n  .ot-number[data-variant=\"scale\"] > .ot-number-less { grid-column: 1; grid-row: 1; }\n  .ot-number[data-variant=\"scale\"] > .ot-number-reading { grid-column: 2; grid-row: 1; justify-content: center; font-size: var(--ot-f); line-height: 1; }\n  .ot-number[data-variant=\"scale\"] :is(.ot-number-input, .ot-number-figures) { padding: 0; line-height: 1.1; text-align: center; letter-spacing: var(--ot-f-tr); }\n  .ot-number[data-variant=\"scale\"] .ot-number-unit { font-size: var(--ot-mp); }\n  .ot-number[data-variant=\"scale\"] > .ot-number-more { grid-column: 3; grid-row: 1; }\n  .ot-number-tape {\n    --p: var(--ot-number-pitch); --x: calc(50% - var(--at, 0) * var(--p) - 0.5px);\n    grid-column: 1 / -1; grid-row: 2; position: relative; height: var(--ot-space-5); cursor: ew-resize; touch-action: pan-y; user-select: none;\n    background:\n      linear-gradient(var(--ot-rule-strong), var(--ot-rule-strong)) no-repeat 0 0 / 100% var(--ot-hairline),\n      repeating-linear-gradient(90deg, var(--ot-pencil) 0 1px, transparent 1px calc(var(--p) * 10)) repeat-x calc(var(--x) + var(--p) * 5) 0 / calc(var(--p) * 10) 16px,\n      repeating-linear-gradient(90deg, var(--ot-rule-strong) 0 1px, transparent 1px var(--p)) repeat-x calc(var(--x) + var(--p) / 2) 0 / var(--p) 7px;\n    mask-image: linear-gradient(90deg, transparent, #000 22%, #000 78%, transparent);\n    transition: background-position var(--ot-moderato) var(--ot-spiccato);\n  }\n  .ot-number[data-held] .ot-number-tape { transition: none; cursor: grabbing; }\n  /* The index: an ink stroke standing through the ruler under your figure. Focus draws the accent along the edge. */\n  .ot-number-tape::before { content: \"\"; position: absolute; inset-inline: 22%; top: 0; height: var(--ot-stroke); background: var(--ot-accent); scale: 0 1; transform-origin: 50% 50%; transition: scale var(--ot-moderato) var(--ot-exhale); }\n  .ot-number:is(:focus-within, [data-force~=\"focus\"]) .ot-number-tape::before { scale: 1 1; }\n  .ot-number:has(.ot-number-input[aria-invalid=\"true\"]) .ot-number-tape::before { scale: 1 1; background: var(--ot-signal); }\n  .ot-number-tape::after { content: \"\"; position: absolute; left: calc(50% - var(--ot-stroke) / 2); top: calc(var(--ot-space-1) * -1); width: var(--ot-stroke); height: calc(16px + var(--ot-space-2)); background: var(--ot-ink); }\n  .ot-number:has(.ot-number-input:disabled) .ot-number-tape { cursor: not-allowed; opacity: 0.45; }\n\n  @media (forced-colors: active) {\n    .ot-number { forced-color-adjust: none; color: CanvasText; --ot-paper: Canvas; --ot-ink: CanvasText; --ot-pencil: GrayText; --ot-rule-strong: GrayText; --ot-accent: Highlight; --ot-signal: CanvasText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/number-input.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/number-input.css\"": {}
  },
  "meta": {
    "movement": "VI",
    "underneath": "hook",
    "contract": "ot-number"
  },
  "type": "registry:ui"
}
