{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-number-field",
  "type": "registry:ui",
  "title": "Number Field",
  "description": "A compact numeric input on Base UI's NumberField with optional steppers and an optional Base UI Slider sharing its draft. One onValueCommitted per gesture (Enter, Cmd/Ctrl+Enter committed before any enclosing shortcut sees the key, blur, a stepper, a slider release, an arrow key), clamped or null when cleared; Escape reverts a pending draft and stops there. layout=\"row\" is one 28px inspector line (flexible slider, 44px value); ghostValue draws a default as a tick where an unset thumb parks. Heights through the control-height variables. The draft and commit rules sit in a pure core beside it.",
  "dependencies": [
    "@base-ui/react",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/number-field.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/number-field\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * NumberField — a compact numeric input with an optional slider and steppers:\n * a tempo, a transposition, a seed.\n *\n * Built on Base UI's NumberField, which owns parsing and formatting (`format`\n * takes `Intl.NumberFormatOptions`), clamping to `min`/`max`, ArrowUp/Down by\n * `step` (Shift for `largeStep`), Home/End to the bounds and the − / + stepper\n * buttons. The optional slider beside it is Base UI's Slider, so it has the\n * same keyboard and a native range input for screen readers.\n *\n * ## One commit per gesture\n *\n * `value` belongs to the consumer. Typing or dragging makes a draft that the\n * field shows (and reports through `onValueChange` for a live preview), and\n * the gesture ends in exactly one `onValueCommitted`: Enter or blur for typing,\n * release for the slider, the click for a stepper, each arrow key press. The\n * committed value is clamped to the bounds, and a cleared field commits null.\n * A commit that would not change `value` is not sent at all.\n *\n * Cmd+Enter or Ctrl+Enter commits the draft *before* any enclosing handler\n * sees the key (a window capture listener, active while the field has focus),\n * then lets the key go on: a face that binds that chord to play hears it with\n * the new value already committed. The field does not swallow the key.\n *\n * Escape while the draft differs from `value` drops the draft and swallows\n * the key, so a panel that closes on Escape stays open; with nothing to\n * revert, Escape passes through untouched.\n *\n * ## Layout\n *\n * `layout=\"stacked\"` (the default) is the field as it has always been: an\n * optional slider, then the field at `inputWidth`. `layout=\"row\"` is one\n * inspector line: the slider takes the free width and the value sits in a\n * 44px column (wider when it carries a label, unit or steppers), at the md\n * height (28px) unless `size` says otherwise. Heights are read through the\n * `--uikit-control-h-*` variables, so a touch face can raise them.\n *\n * `ghostValue` draws a default as a tick on the slider's track, and an unset\n * field parks its dimmed thumb there instead of at `min`: the default is\n * shown where it would be, not written out as text.\n *\n * ## Unset\n *\n * `value` may be null: the field shows `placeholder` and, with `slider`, the\n * thumb rests dimmed at `ghostValue` or `min` until the user picks a value.\n *\n * `label` is a short prefix drawn inside the field (\"BPM\") that also names the\n * input when no `aria-label` is given; `unit` is a suffix (\"st\") that is read\n * as the field's description. The rules live in `number-field-math.ts`.\n */\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type JSX,\n  type KeyboardEvent,\n  type ReactNode,\n  type RefObject,\n} from \"react\"\nimport { NumberField as BaseNumberField } from \"@base-ui/react/number-field\"\nimport { Slider } from \"@base-ui/react/slider\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { ConsoleIcon } from \"maruaudio/ui/shared/console-icons\"\nimport {\n  CONSOLE_FONT_SIZE,\n  CONSOLE_VALUE,\n  ConsoleStyles,\n  consoleHeight,\n  type ConsoleSize,\n} from \"maruaudio/ui/shared/console-styles\"\nimport {\n  NUMBER_FIELD_SLIDER_DEFAULT_RANGE,\n  isModEnter,\n  numberFieldCommit,\n  numberFieldEscape,\n  numberFieldGhostFraction,\n  numberFieldShown,\n  numberFieldSliderPosition,\n  type NumberFieldBounds,\n  type NumberFieldDraft,\n  type NumberFieldValue,\n} from \"maruaudio/ui/shared/number-field-math\"\n\n/** \"stacked\": slider then field at `inputWidth`. \"row\": one line, slider flexible, value in a 44px column. */\nexport type NumberFieldLayout = \"stacked\" | \"row\"\n\nexport interface NumberFieldProps {\n  /** Retain editing semantics while the surrounding face supplies typography and a field surface. */\n  readonly unstyled?: boolean\n  /** The value; null is unset and shows the placeholder. */\n  readonly value: number | null\n  /** Once per gesture: Enter, Cmd/Ctrl+Enter, blur, a stepper click, a slider release, an arrow key. */\n  readonly onValueCommitted: (value: number | null) => void\n  /** The live draft while typing or dragging. */\n  readonly onValueChange?: (value: number | null) => void\n  readonly min?: number\n  readonly max?: number\n  /** ArrowUp/Down and stepper step. Defaults to 1. */\n  readonly step?: number\n  /** Shift+Arrow and Page Up/Down step. Defaults to 10 steps. */\n  readonly largeStep?: number\n  readonly placeholder?: string\n  /** Draw a slider before the field that shares its draft. Its track is min..max (0..100 when unbounded). */\n  readonly slider?: boolean\n  /** Draw − and + buttons around the input. Defaults to false. */\n  readonly steppers?: boolean\n  /** A compact visible prefix inside the field, e.g. \"BPM\"; names the input when there is no aria-label. */\n  readonly label?: ReactNode\n  /** A compact suffix, e.g. \"st\"; read as the field's description. */\n  readonly unit?: ReactNode\n  /**\n   * \"sm\" 24px, \"md\" 28px, \"lg\" 32px, through the `--uikit-control-h-*`\n   * variables. Defaults to \"sm\", or \"md\" with `layout=\"row\"`.\n   */\n  readonly size?: ConsoleSize\n  /** \"stacked\" (the default) or \"row\": one inspector line with the value in a 44px column. */\n  readonly layout?: NumberFieldLayout\n  /** A default drawn as a tick on the slider's track; an unset field parks its thumb there. */\n  readonly ghostValue?: number\n  /** CSS width of the input. Defaults to \"5ch\" (\"100%\" of the value column with `layout=\"row\"`). */\n  readonly inputWidth?: string\n  readonly format?: Intl.NumberFormatOptions\n  readonly disabled?: boolean\n  /** Mark the value invalid: aria-invalid and a danger border. */\n  readonly invalid?: boolean\n  readonly \"aria-label\"?: string\n  readonly \"aria-labelledby\"?: string\n  readonly \"aria-describedby\"?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\n/** The value column's width with `layout=\"row\"` and a bare value. */\nconst ROW_VALUE_WIDTH = \"var(--uikit-number-field-value-w, 44px)\"\n\n/**\n * Restarting Base UI's field so its text is formatted from `value` again.\n *\n * Base UI 1.7's NumberField stops syncing its text from the controlled\n * `value` once the user types (its `allowInputSyncRef` stays false until\n * blur), and has no call to resync it. So after Enter or Escape, where the\n * field keeps focus but its text must show the committed (clamped, formatted)\n * or reverted value, the field is remounted with a new key and focus is put\n * back. Events still in flight from the field being replaced carry the old\n * generation and are dropped. Pinned by `number-field.dom-check.ts` (Enter\n * clamps and reformats with focus kept; Escape reverts the text).\n */\nfunction useFieldRestart(inputRef: RefObject<HTMLInputElement | null>) {\n  const [generation, setGeneration] = useState(0)\n  const current = useRef(0)\n  const refocus = useRef(false)\n\n  useLayoutEffect(() => {\n    if (!refocus.current) return\n    refocus.current = false\n    inputRef.current?.focus()\n  }, [generation, inputRef])\n\n  return {\n    generation,\n    /** Whether an event from the field of generation `seen` is still current. */\n    isCurrent: (seen: number) => seen === current.current,\n    restart: () => {\n      current.current += 1\n      const input = inputRef.current\n      refocus.current = input?.ownerDocument.activeElement === input\n      setGeneration(current.current)\n    },\n  }\n}\n\nexport function NumberField({\n  unstyled = false,\n  value,\n  onValueCommitted,\n  onValueChange,\n  min,\n  max,\n  step = 1,\n  largeStep,\n  placeholder,\n  slider = false,\n  steppers = false,\n  label,\n  unit,\n  size,\n  layout = \"stacked\",\n  ghostValue,\n  inputWidth,\n  format,\n  disabled = false,\n  invalid = false,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  \"aria-describedby\": ariaDescribedBy,\n  className,\n  style,\n}: NumberFieldProps): JSX.Element {\n  const inputId = useId()\n  const labelId = useId()\n  const unitId = useId()\n  const [draft, setDraftState] = useState<NumberFieldDraft>(undefined)\n  // The draft as of the last event, for handlers that run before a render\n  // (a second Enter, the Cmd+Enter capture listener).\n  const draftRef = useRef<NumberFieldDraft>(undefined)\n  const inputRef = useRef<HTMLInputElement | null>(null)\n  const { generation, isCurrent, restart } = useFieldRestart(inputRef)\n\n  const row = layout === \"row\"\n  const controlSize: ConsoleSize = size ?? (row ? \"md\" : \"sm\")\n  const height = consoleHeight(controlSize)\n  const bounds: NumberFieldBounds = { min, max }\n  const shown = numberFieldShown(draft, value)\n  const trackMin = min ?? NUMBER_FIELD_SLIDER_DEFAULT_RANGE.min\n  const trackMax = max ?? NUMBER_FIELD_SLIDER_DEFAULT_RANGE.max\n  const thumb = numberFieldSliderPosition(shown, trackMin, trackMax, ghostValue)\n  const ghost = numberFieldGhostFraction(ghostValue, trackMin, trackMax)\n  const hasLabel = label !== undefined && label !== null\n  const hasUnit = unit !== undefined && unit !== null\n  const bareValue = !hasLabel && !hasUnit && !steppers\n\n  const setDraft = (next: NumberFieldDraft) => {\n    draftRef.current = next\n    setDraftState(next)\n  }\n\n  const change = (next: NumberFieldValue) => {\n    setDraft(next)\n    onValueChange?.(next)\n  }\n\n  // The latest props, for the capture listener, which outlives a render.\n  const latest = useRef({ value, bounds, onValueCommitted })\n  useLayoutEffect(() => {\n    latest.current = { value, bounds, onValueCommitted }\n  })\n\n  const commit = (candidate: NumberFieldValue) => {\n    setDraft(undefined)\n    const now = latest.current\n    const outcome = numberFieldCommit(candidate, now.value, now.bounds)\n    if (outcome.commit) now.onValueCommitted(outcome.value)\n  }\n  const commitRef = useRef(commit)\n  useLayoutEffect(() => {\n    commitRef.current = commit\n  })\n\n  // Cmd/Ctrl+Enter: commit before any enclosing handler runs. A window\n  // capture listener is the only place that runs before an ancestor's own\n  // capture handler (React's included); it is attached while the field has\n  // focus.\n  const detachModEnter = useRef<(() => void) | null>(null)\n  const attachModEnter = () => {\n    if (detachModEnter.current) return\n    const view = inputRef.current?.ownerDocument.defaultView\n    if (!view) return\n    const onKeyDown = (event: globalThis.KeyboardEvent) => {\n      if (!isModEnter(event) || event.target !== inputRef.current) return\n      if (draftRef.current === undefined) return\n      commitRef.current(draftRef.current)\n      restart()\n    }\n    view.addEventListener(\"keydown\", onKeyDown, true)\n    detachModEnter.current = () =>\n      view.removeEventListener(\"keydown\", onKeyDown, true)\n  }\n  useEffect(\n    () => () => {\n      detachModEnter.current?.()\n      detachModEnter.current = null\n    },\n    []\n  )\n\n  const onInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Enter\") {\n      // Cmd/Ctrl+Enter has already committed in the capture listener.\n      if (draftRef.current === undefined || isModEnter(event)) return\n      event.preventDefault()\n      commit(draftRef.current)\n      restart()\n      return\n    }\n    if (\n      event.key === \"Escape\" &&\n      numberFieldEscape(draftRef.current, value) === \"revert\"\n    ) {\n      event.preventDefault()\n      event.stopPropagation()\n      setDraft(undefined)\n      onValueChange?.(value)\n      restart()\n    }\n  }\n\n  const describedBy =\n    [ariaDescribedBy, hasUnit ? unitId : undefined].filter(Boolean).join(\" \") ||\n    undefined\n  const inputLabelledBy =\n    ariaLabelledBy ??\n    (ariaLabel === undefined && hasLabel ? labelId : undefined)\n  const current = generation\n\n  const stepperStyle: CSSProperties = {\n    boxSizing: \"border-box\",\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    flex: \"none\",\n    width: consoleHeight(controlSize, 4),\n    height: consoleHeight(controlSize, 4),\n    padding: 0,\n    margin: 0,\n    border: 0,\n    borderRadius: 4,\n    cursor: disabled ? \"not-allowed\" : \"pointer\",\n  }\n\n  const resolvedInputWidth = inputWidth ?? (row ? \"100%\" : \"5ch\")\n\n  return (\n    <div\n      data-number-field=\"\"\n      data-size={controlSize}\n      data-layout={layout}\n      className={className}\n      onFocus={attachModEnter}\n      onBlur={(event) => {\n        if (event.currentTarget.contains(event.relatedTarget as Node | null))\n          return\n        detachModEnter.current?.()\n        detachModEnter.current = null\n      }}\n      style={{\n        boxSizing: \"border-box\",\n        ...(row\n          ? {\n              display: \"grid\",\n              gridTemplateColumns: slider\n                ? `minmax(0, 1fr) ${bareValue ? ROW_VALUE_WIDTH : \"auto\"}`\n                : bareValue\n                  ? ROW_VALUE_WIDTH\n                  : \"auto\",\n              justifyContent: slider ? undefined : \"end\",\n              columnGap: 8,\n              width: \"100%\",\n              minHeight: height,\n            }\n          : { display: \"inline-flex\", gap: 8, maxWidth: \"100%\" }),\n        alignItems: \"center\",\n        minWidth: 0,\n        fontFamily: unstyled ? \"inherit\" : token.font,\n        fontSize: unstyled ? \"inherit\" : CONSOLE_FONT_SIZE,\n        color: unstyled ? \"inherit\" : token.text,\n        opacity: disabled ? 0.5 : 1,\n        ...style,\n      }}\n    >\n      <ConsoleStyles />\n      {slider ? (\n        <Slider.Root\n          value={thumb.value}\n          min={trackMin}\n          max={trackMax}\n          step={step}\n          largeStep={largeStep ?? step * 10}\n          disabled={disabled}\n          onValueChange={(next) => change(next)}\n          onValueCommitted={(next) => commit(next)}\n          data-number-field-slider=\"\"\n          data-unset={thumb.unset ? \"\" : undefined}\n          style={row ? { minWidth: 0 } : { flex: \"1 1 4rem\", minWidth: \"3rem\" }}\n        >\n          <Slider.Control\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              height,\n              touchAction: \"none\",\n              userSelect: \"none\",\n              cursor: disabled ? \"not-allowed\" : \"pointer\",\n            }}\n          >\n            <Slider.Track\n              style={{\n                position: \"relative\",\n                width: \"100%\",\n                height: 4,\n                borderRadius: 999,\n                background: token.track,\n              }}\n            >\n              {ghost !== null ? (\n                <span\n                  aria-hidden=\"true\"\n                  data-number-field-ghost=\"\"\n                  style={{\n                    position: \"absolute\",\n                    left: `${(ghost * 100).toFixed(4)}%`,\n                    top: \"50%\",\n                    width: 2,\n                    height: 10,\n                    borderRadius: 1,\n                    background: token.textMuted,\n                    transform: \"translate(-50%, -50%)\",\n                    pointerEvents: \"none\",\n                  }}\n                />\n              ) : null}\n              <Slider.Indicator\n                style={{\n                  borderRadius: 999,\n                  background: CONSOLE_VALUE,\n                  opacity: thumb.unset ? 0 : 1,\n                }}\n              />\n              <Slider.Thumb\n                aria-label={ariaLabelledBy ? undefined : ariaLabel}\n                aria-labelledby={inputLabelledBy}\n                aria-describedby={describedBy}\n                data-number-field-thumb=\"\"\n                style={{\n                  boxSizing: \"border-box\",\n                  width: 12,\n                  height: 12,\n                  borderRadius: \"50%\",\n                  background: token.cap,\n                  border: `2px solid ${thumb.unset ? token.textMuted : CONSOLE_VALUE}`,\n                  opacity: thumb.unset ? 0.45 : 1,\n                  outline: \"none\",\n                  boxShadow: \"0 1px 3px rgba(0, 0, 0, 0.45)\",\n                }}\n              />\n            </Slider.Track>\n          </Slider.Control>\n        </Slider.Root>\n      ) : null}\n      <BaseNumberField.Root\n        key={current}\n        id={inputId}\n        value={shown}\n        min={min}\n        max={max}\n        step={step}\n        largeStep={largeStep ?? step * 10}\n        format={format}\n        disabled={disabled}\n        onValueChange={(next) => {\n          if (!isCurrent(current)) return\n          change(next)\n        }}\n        onValueCommitted={(next) => {\n          if (!isCurrent(current)) return\n          commit(next)\n        }}\n        style={\n          row\n            ? { minWidth: 0 }\n            : { flex: slider ? \"none\" : \"1 1 auto\", minWidth: 0 }\n        }\n      >\n        <BaseNumberField.Group\n          data-number-field-group=\"\"\n          data-invalid={invalid ? \"\" : undefined}\n          style={{\n            boxSizing: \"border-box\",\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 4,\n            height: unstyled ? \"100%\" : height,\n            padding: unstyled ? 0 : steppers ? \"0 2px\" : \"0 6px\",\n            borderWidth: unstyled ? 0 : 1,\n            borderStyle: \"solid\",\n            borderRadius: unstyled ? 0 : 6,\n            background: unstyled ? \"transparent\" : token.surface,\n          }}\n        >\n          {hasLabel ? (\n            <label\n              id={labelId}\n              htmlFor={inputId}\n              data-number-field-label=\"\"\n              style={{\n                flex: \"none\",\n                paddingLeft: steppers ? 4 : 0,\n                color: token.textMuted,\n                fontSize: 11,\n                fontWeight: 600,\n                whiteSpace: \"nowrap\",\n                cursor: \"text\",\n              }}\n            >\n              {label}\n            </label>\n          ) : null}\n          {steppers ? (\n            <BaseNumberField.Decrement\n              data-number-field-stepper=\"decrement\"\n              style={stepperStyle}\n            >\n              <ConsoleIcon name=\"minus\" size={12} />\n            </BaseNumberField.Decrement>\n          ) : null}\n          <BaseNumberField.Input\n            ref={inputRef}\n            placeholder={placeholder}\n            aria-label={ariaLabel}\n            aria-labelledby={inputLabelledBy}\n            aria-describedby={describedBy}\n            aria-invalid={invalid || undefined}\n            data-number-field-input=\"\"\n            onKeyDown={onInputKeyDown}\n            style={{\n              boxSizing: \"content-box\",\n              width: resolvedInputWidth,\n              minWidth: 0,\n              flex: resolvedInputWidth === \"100%\" ? \"1 1 auto\" : \"none\",\n              height: unstyled ? \"100%\" : consoleHeight(controlSize, 2),\n              padding: 0,\n              margin: 0,\n              border: 0,\n              background: \"transparent\",\n              color: unstyled ? \"inherit\" : token.text,\n              font: unstyled\n                ? \"inherit\"\n                : `500 ${CONSOLE_FONT_SIZE}px ${token.fontMono}`,\n              fontVariantNumeric: \"tabular-nums\",\n              textAlign: unstyled ? \"inherit\" : \"right\",\n            }}\n          />\n          {hasUnit ? (\n            <span\n              id={unitId}\n              data-number-field-unit=\"\"\n              style={{ flex: \"none\", color: token.textMuted, fontSize: 11 }}\n            >\n              {unit}\n            </span>\n          ) : null}\n          {steppers ? (\n            <BaseNumberField.Increment\n              data-number-field-stepper=\"increment\"\n              style={stepperStyle}\n            >\n              <ConsoleIcon name=\"plus\" size={12} />\n            </BaseNumberField.Increment>\n          ) : null}\n        </BaseNumberField.Group>\n      </BaseNumberField.Root>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/number-field.tsx"
    }
  ]
}
