{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "numeric-cue",
  "type": "registry:ui",
  "title": "Numeric cue",
  "description": "A bounded canonical quantity operated inline: vertical detent scrub, typed spinbutton and horizontal unit conversion. Explicit font footprint and one source transaction per gesture.",
  "dependencies": [
    "@base-ui/react@^1.8.0"
  ],
  "registryDependencies": [
    "https://metalui.dev/r/mark.json",
    "https://metalui.dev/r/motion.json",
    "https://metalui.dev/r/tokens.json",
    "https://metalui.dev/r/tooltip.json"
  ],
  "files": [
    {
      "path": "packages/metalui/src/components/numeric-cue/numeric-cue.tsx",
      "type": "registry:ui",
      "target": "components/metalui/components/numeric-cue/numeric-cue.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { NumberField as BaseNumberField } from '@base-ui/react/number-field';\nimport { Mark, useReadingLine, type MarkKind, type MarkMeaning } from '../mark/mark';\nimport { Tooltip, TooltipProvider } from '../tooltip/tooltip';\nimport { SwapText } from '../../motion/swap';\nimport { haptic } from '../../motion/haptic';\nimport { useReducedMotion } from '../../motion/reduced';\nimport { useIsoLayoutEffect } from '../../motion/layout-effect';\nimport './numeric-cue.css';\n\n/** Canonical quantity and a presentation unit. Conversion preserves the canonical quantity. */\nexport interface NumericCueValue { value: number; unit: string }\nexport interface NumericCueUnit {\n  id: string;\n  label: string;\n  /** Canonical quantity per displayed unit: minutes=1, hours=60. Host supplies all factors. */\n  factor: number;\n  step?: number;\n  smallStep?: number;\n  largeStep?: number;\n  /** Display and lossless source are deliberately independent host contracts. */\n  format: (displayed: number) => string;\n  source: (displayed: number) => string;\n}\nexport interface NumericCueProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onChange' | 'defaultValue'> {\n  value: NumericCueValue;\n  units: readonly NumericCueUnit[];\n  onValueChange: (value: NumericCueValue) => void;\n  label: string;\n  /** Include every possible raw/formatted face in every unit. Measured only at font/layout changes. */\n  footprint: readonly string[];\n  min: number;\n  max: number;\n  kind?: Extract<MarkKind, 'date' | 'duration' | 'amount' | 'measurement'>;\n  meaning?: MarkMeaning;\n  raw?: boolean;\n  /** Resolved semantic words, shown by the existing Mark chip and included in the spoken value. */\n  resolved?: string;\n  /** Suppress visual help when a source/provenance host supplies it; keyboard instructions remain. */\n  hint?: boolean;\n  locale?: string;\n  numberFormat?: Intl.NumberFormatOptions;\n  /** Keep formatted words while focused; numeric keys/scrub remain operable, text insertion is blocked. */\n  allowTyping?: boolean;\n  inputAria?: Pick<React.AriaAttributes, 'aria-haspopup' | 'aria-expanded' | 'aria-controls' | 'aria-describedby'>;\n  disabled?: boolean;\n  readOnly?: boolean;\n  name?: string;\n  onBegin?: (() => void) | (() => boolean);\n  onSourceChange?: ((words: string) => void) | ((words: string) => boolean);\n  onCommit?: () => void;\n  /** External controlled changes invalidate a gesture without restoring its obsolete value. */\n  onCancel?: (reason: 'escape' | 'external' | 'pointer') => void;\n}\ninterface Gesture { initial: NumericCueValue; source: string; expected: NumericCueValue; x: number; y: number; unitSteps: number; axis?: 'value' | 'unit'; cancelled?: boolean }\nconst same = (a: NumericCueValue, b: NumericCueValue) => a.unit === b.unit && a.value === b.value;\n\n/** An inline, host-controlled numeric cue. Base UI owns the spinbutton, typing and vertical scrub. */\nexport const NumericCue = React.forwardRef<HTMLSpanElement, NumericCueProps>(function NumericCue({\n  value, units, onValueChange, label, footprint, min, max, kind = 'measurement', meaning,\n  raw = false, resolved, hint = true, locale, numberFormat, allowTyping = true, inputAria, disabled = false, readOnly = false, name,\n  onBegin, onSourceChange, onCommit, onCancel, className, style, ...props\n}, forwardedRef) {\n  const root = React.useRef<HTMLSpanElement>(null);\n  const input = React.useRef<HTMLInputElement>(null);\n  const gesture = React.useRef<Gesture | null>(null);\n  const current = React.useRef(value);\n  const cancelledInput = React.useRef(false);\n  const callbacks = React.useRef({ onValueChange, onBegin, onSourceChange, onCommit, onCancel });\n  callbacks.current = { onValueChange, onBegin, onSourceChange, onCommit, onCancel };\n  current.current = value;\n  const [held, setHeld] = React.useState(false);\n  const reading = useReadingLine();\n  const footprintPlan = JSON.stringify(footprint);\n  const [typing, setTyping] = React.useState(false);\n  const [width, setWidth] = React.useState<number>();\n  const reduced = useReducedMotion(root.current);\n  const unit = units.find(u => u.id === value.unit) ?? units[0];\n  if (!unit || units.some(u => !Number.isFinite(u.factor) || u.factor <= 0) || min > max || !footprint.length) {\n    throw new Error('NumericCue requires valid positive unit factors, bounds and an explicit footprint.');\n  }\n  const begin = () => {\n    if (disabled || readOnly) return false;\n    if (gesture.current) return !gesture.current.cancelled;\n    const selected = units.find(u => u.id === current.current.unit)!;\n    if (callbacks.current.onBegin?.() === false) return false;\n    gesture.current = { initial: { ...current.current }, source: selected.source(current.current.value / selected.factor), expected: { ...current.current }, x: 0, y: 0, unitSteps: 0 };\n    return true;\n  };\n  const finish = () => {\n    const active = gesture.current;\n    gesture.current = null;\n    setHeld(false);\n    if (active && !active.cancelled) callbacks.current.onCommit?.();\n  };\n  const publish = (next: NumericCueValue, detent = false) => {\n    if (disabled || readOnly || cancelledInput.current || gesture.current?.cancelled || !Number.isFinite(next.value)) return;\n    const selected = units.find(u => u.id === next.unit);\n    if (!selected) return;\n    const bounded = { value: Math.max(min, Math.min(max, next.value)), unit: next.unit };\n    if (same(bounded, gesture.current?.expected ?? current.current)) return;\n    if (!begin()) return;\n    if (gesture.current) gesture.current.expected = bounded;\n    if (callbacks.current.onSourceChange?.(selected.source(bounded.value / selected.factor)) === false) { cancel('external'); return; }\n    current.current = bounded;\n    callbacks.current.onValueChange(bounded);\n    if (detent) haptic('detent');\n  };\n  const cancel = (reason: 'escape' | 'external' | 'pointer') => {\n    const active = gesture.current;\n    if (!active || active.cancelled) return;\n    active.cancelled = true;\n    cancelledInput.current = true;\n    if (reason !== 'external') {\n      current.current = active.initial;\n      callbacks.current.onValueChange(active.initial);\n      callbacks.current.onSourceChange?.(active.source);\n    }\n    callbacks.current.onCancel?.(reason);\n    setHeld(false);\n    setTyping(false);\n    gesture.current = null;\n    input.current?.blur();\n    try { document.exitPointerLock(); } catch { /* Pointer lock is optional. */ }\n  };\n  const convert = (direction: number) => {\n    const active = gesture.current;\n    if (disabled || readOnly || active?.cancelled) return;\n    if (!begin()) return;\n    const state = active?.expected ?? current.current;\n    const index = units.findIndex(u => u.id === state.unit);\n    const next = units[Math.max(0, Math.min(units.length - 1, index + direction))];\n    if (next) publish({ value: state.value, unit: next.id }, true);\n  };\n  useIsoLayoutEffect(() => {\n    const active = gesture.current;\n    if (active && !same(value, active.expected)) cancel('external');\n    if ((disabled || readOnly) && active) cancel('external');\n  }, [value.value, value.unit, disabled, readOnly]);\n  // One font measurement per footprint/font change. No layout reads during a gesture or drum frame.\n  useIsoLayoutEffect(() => {\n    const element = root.current;\n    if (!element) return;\n    let disposed = false;\n    const measure = () => {\n      if (disposed) return;\n      const font = getComputedStyle(element);\n      const canvas = document.createElement('canvas').getContext('2d');\n      if (!canvas) return;\n      canvas.font = `${font.fontStyle} ${font.fontWeight} ${font.fontSize} ${font.fontFamily}`;\n      const spacing = parseFloat(font.letterSpacing) || 0;\n      const glyphWidth = reading && meaning ? (parseFloat(font.getPropertyValue('--mu-r-button-compact-glyph')) || 0) + (parseFloat(font.getPropertyValue('--mu-space-4')) || 0) : 0;\n      setWidth(Math.ceil(Math.max(...footprint.map(words => canvas.measureText(words).width + Math.max(0, words.length - 1) * spacing))) + glyphWidth);\n    };\n    measure();\n    document.fonts.ready.then(measure);\n    document.fonts.addEventListener('loadingdone', measure);\n    return () => { disposed = true; document.fonts.removeEventListener('loadingdone', measure); };\n  }, [footprintPlan, reading, meaning]);\n  React.useEffect(() => {\n    if (!held) return;\n    const owner = root.current?.ownerDocument.defaultView ?? window;\n    const threshold = parseFloat(getComputedStyle(root.current!).getPropertyValue('--mu-space-16')) || 16;\n    const move = (event: PointerEvent) => {\n      const active = gesture.current;\n      if (!active || active.cancelled) return;\n      active.x += event.movementX;\n      active.y += event.movementY;\n      if (!active.axis && Math.max(Math.abs(active.x), Math.abs(active.y)) >= 2) active.axis = Math.abs(active.x) > Math.abs(active.y) ? 'unit' : 'value';\n      if (active.axis !== 'unit') return;\n      const stops = Math.trunc(active.x / threshold);\n      const direction = Math.sign(stops - active.unitSteps);\n      while (active.unitSteps !== stops) {\n        active.unitSteps += direction;\n        convert(direction);\n      }\n    };\n    const up = () => finish();\n    const abort = () => cancel('pointer');\n    owner.addEventListener('pointermove', move, true);\n    owner.addEventListener('pointerup', up, true);\n    owner.addEventListener('pointercancel', abort, true);\n    return () => { owner.removeEventListener('pointermove', move, true); owner.removeEventListener('pointerup', up, true); owner.removeEventListener('pointercancel', abort, true); };\n  }, [held, units, min, max, disabled, readOnly]);\n  const words = unit.source(value.value / unit.factor);\n  const formatted = unit.format(value.value / unit.factor);\n  const help = `${label} · Drag vertically to change; horizontally to convert. ↑/↓ step; Shift large, Alt small. Alt+←/→ converts. Escape cancels.`;\n  return <BaseNumberField.Root render={<span />} ref={node => { root.current = node; if (typeof forwardedRef === 'function') forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }}\n    value={value.value / unit.factor} min={min / unit.factor} max={max / unit.factor} step={unit.step ?? 1} smallStep={unit.smallStep ?? .1} largeStep={unit.largeStep ?? 10}\n    disabled={disabled} readOnly={readOnly} name={name} locale={locale} format={numberFormat}\n    className={`mu-numeric-cue numeric-cue relative inline-block has-[input:focus-visible]:focus-ring align-baseline${className ? ` ${className}` : ''}`}\n    style={{ ...style, width: reading && !held && !typing ? undefined : width }} data-held={held || undefined} data-typing={typing || undefined} data-reduced={reduced || undefined} data-unit={unit.id}\n    onValueChange={(amount, details) => {\n      if (amount === null || (!allowTyping && details.reason !== 'keyboard' && details.reason !== 'scrub')) return;\n      if (details.reason === 'scrub') {\n        const event = details.event as PointerEvent;\n        const active = gesture.current;\n        if (active && !active.axis && (event.movementX || event.movementY)) active.axis = Math.abs(event.movementX) > Math.abs(event.movementY) ? 'unit' : 'value';\n        if (!active || active.axis === 'unit') return;\n      }\n      publish({ value: amount * unit.factor, unit: unit.id }, details.reason === 'scrub' || details.reason === 'keyboard');\n    }} onValueCommitted={() => { if (!held) finish(); }} {...props}>\n    <TooltipProvider><Tooltip label={help} disabled={disabled || !!resolved || !hint} wrap><BaseNumberField.ScrubArea direction=\"vertical\"\n      className=\"mu-numeric-cue-face block cursor-ns-resize\"\n      onPointerDownCapture={() => {\n        if (disabled || readOnly) return;\n        cancelledInput.current = false;\n        // Finish the previous field's blur transaction before capturing this source range.\n        input.current?.focus({ preventScroll: true });\n        setTyping(false);\n        if (begin()) setHeld(true);\n      }}\n      onDoubleClick={() => { if (allowTyping && !readOnly && !disabled) { setTyping(true); input.current?.focus(); } }}>\n      <Mark kind={kind} meaning={meaning} meaningLabel={`${label}, ${formatted}`} raw={raw || disabled || readOnly} resolved={hint ? resolved : undefined} data-reveal={hint && held && resolved ? true : undefined} className=\"block\">\n        <SwapText value={raw ? words : formatted} />\n      </Mark>\n    </BaseNumberField.ScrubArea></Tooltip></TooltipProvider>\n    <BaseNumberField.Input {...inputAria} ref={input} role=\"spinbutton\" aria-label={label} aria-valuemin={min / unit.factor} aria-valuemax={max / unit.factor} aria-valuenow={value.value / unit.factor} aria-valuetext={resolved ? `${formatted}, ${resolved}` : `${formatted}, ${unit.label}`} title={hint ? help : undefined}\n      className=\"mu-numeric-cue-input text-entry absolute inset-0 w-full bg-transparent p-0 border-0 rounded-none text-inherit font-inherit focus-visible:focus-ring\"\n      onFocus={() => { if (!held) { cancelledInput.current = false; if (begin()) setTyping(allowTyping); } }} onBlur={() => { setTyping(false); finish(); }}\n\n      onBeforeInput={event => { if (!allowTyping) event.preventDefault(); }} onPaste={event => { if (!allowTyping) event.preventDefault(); }}\n      onKeyDown={event => {\n        if (disabled || readOnly) return;\n        if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancel('escape'); input.current?.blur(); }\n        else if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { event.preventDefault(); cancelledInput.current = false; convert(event.key === 'ArrowLeft' ? -1 : 1); finish(); }\n        else if (event.key === 'Enter') { event.preventDefault(); setTyping(false); finish(); input.current?.blur(); }\n        else if (!allowTyping && event.key.length === 1 && !event.ctrlKey && !event.metaKey) event.preventDefault();\n        else { cancelledInput.current = false; begin(); }\n      }} />\n    {held && <span aria-hidden className=\"mu-numeric-cue-scale absolute start-0 bottom-full mb-mu-space-2 rounded-tooltip-radius px-tooltip-pad-x py-tooltip-pad-y recipe-tooltip text-tooltip-ink type-tooltip tabular-nums whitespace-nowrap\">\n      <span className=\"inline-flex gap-mu-related\"><span>−</span><span>│</span><span>{formatted}</span><span>│</span><span>+</span></span>\n    </span>}\n  </BaseNumberField.Root>;\n});\n"
    },
    {
      "path": "packages/metalui/src/components/numeric-cue/numeric-cue.css",
      "type": "registry:file",
      "target": "components/metalui/components/numeric-cue/numeric-cue.css",
      "content": "/* NumericCue composes shared Mark, tooltip and drum recipes.\n * Structural interaction states are generated from tokens.json recipes.numeric-cue. */\n"
    }
  ],
  "docs": "Agent guide: https://metalui.dev/r/numeric-cue.md. SwiftUI: MetalNumericCue in the MetalUI Swift package."
}
