{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "enum-cue",
  "type": "registry:ui",
  "title": "Enum cue",
  "description": "A finite host-declared source state: cycle, keyboard step, focused wheel or held vertical drag with one source/history transaction and a fixed footprint.",
  "dependencies": [
    "@base-ui/react@^1.8.0"
  ],
  "registryDependencies": [
    "https://metalui.dev/r/icons.json",
    "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/enum-cue/enum-cue.tsx",
      "type": "registry:ui",
      "target": "components/metalui/components/enum-cue/enum-cue.tsx",
      "content": "'use client';\nimport * as React from 'react';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { Button as BaseButton } from '@base-ui/react/button';\nimport { Icon } from '../../icons/Icon';\nimport type { IconName } from '../../icons/catalog.generated';\nimport { haptic } from '../../motion/haptic';\nimport { SPRINGS } from '../../motion/springs.generated';\nimport { SwapText } from '../../motion/swap';\nimport { Tooltip } from '../tooltip/tooltip';\nimport { Mark, useReadingLine } from '../mark/mark';\nimport { MARK_GLYPH_SIZE } from '../mark/identity.generated';\nimport { ENUM_CUE_STOP } from './gesture.generated';\n\nexport interface EnumCueChoice {\n  /** Complete words written into the host source, such as #todo. */\n  value: string;\n  label?: string;\n  glyph?: IconName;\n  /** Explicit state tint; defaults to neutral ink. Identity hashing never chooses enum state colour. */\n  tint?: string;\n}\nexport interface EnumCueProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseButton>, 'children' | 'value' | 'onChange' | 'render' | 'nativeButton'> {\n  value: string;\n  choices: readonly EnumCueChoice[];\n  label: string;\n  disabled?: boolean;\n  readOnly?: boolean;\n  raw?: boolean;\n  /** Optional host transaction flag; closing it invalidates a gesture after unrelated source typing. */\n  editing?: boolean;\n  className?: string;\n  /** Keep the gesture hint off when an enclosing provenance tooltip owns help. */\n  hint?: boolean;\n  onBegin?: () => boolean | void;\n  onChange: (words: string) => boolean | void;\n  onCommit?: () => void;\n  /** Restores the host's captured source/selection. Without this, cancellation emits the original words. */\n  onCancel?: () => void;\n}\ntype Gesture = { kind: 'pointer' | 'keyboard' | 'wheel'; original: string; current: string; index: number; y: number; moved: boolean; pointer?: number; wheel: number; vocabulary: string };\n\n/* ENUM CUE STORYBOARD\n * rest    complete source words in the Mark tab; every permitted word reserves the same footprint\n * hold    adjacent host choices appear above/below, outside the source line; no continuous work\n * detent  a 24-space stop changes words once and requests one shared haptic; the drum turns\n * release one host history transaction ends; the instrument disappears\n * cancel  Escape, lost capture or unmount restores the host snapshot\n * Reduced motion uses the drum's existing crossfade; no travelling instrument or spin.\n */\nexport const EnumCue = React.forwardRef<HTMLElement, EnumCueProps>(function EnumCue(props, forwardedRef) {\n  const reading = useReadingLine();\n  const { value, choices, label, disabled = false, readOnly = false, raw = false, hint = true, className, editing: _editing, onBegin: _begin, onChange: _change, onCommit: _commit, onCancel: _cancel, ...triggerProps } = props;\n  const root = React.useRef<HTMLElement>(null);\n  React.useImperativeHandle(forwardedRef, () => root.current!, []);\n  const callbacks = React.useRef(props); callbacks.current = props;\n  const gesture = React.useRef<Gesture | null>(null);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const ignoreClick = React.useRef(false);\n  const [held, setHeld] = React.useState(false);\n  const index = choices.findIndex(choice => choice.value === value);\n  const choice = choices[index];\n  const vocabulary = JSON.stringify(choices.map(item => item.value));\n  const validChoices = choices.every(item => !!item.value) && new Set(choices.map(item => item.value)).size === choices.length;\n  const mutable = validChoices && !disabled && !readOnly && index >= 0 && choices.length > 1;\n  function clearTimer() { if (timer.current) clearTimeout(timer.current); timer.current = null; }\n  function begin(kind: Gesture['kind'], y = 0, pointer?: number) {\n    if (gesture.current) return gesture.current.kind === kind;\n    const p = callbacks.current, currentIndex = p.choices.findIndex(item => item.value === p.value);\n    if (p.disabled || p.readOnly || currentIndex < 0 || p.choices.length < 2 || new Set(p.choices.map(item => item.value)).size !== p.choices.length || p.onBegin?.() === false) return false;\n    gesture.current = { kind, original: p.value, current: p.value, index: currentIndex, y, pointer, moved: false, wheel: 0, vocabulary: JSON.stringify(p.choices.map(item => item.value)) };\n    setHeld(true); return true;\n  }\n  function land(at: number) {\n    const g = gesture.current, p = callbacks.current;\n    if (!g) return;\n    const next = p.choices[((at % p.choices.length) + p.choices.length) % p.choices.length]?.value;\n    if (!next || next === g.current) return;\n    g.current = next; if (p.onChange(next) === false) { cancel(false); return; } haptic('detent');\n  }\n  function step(amount: number) { const g = gesture.current; if (g) land(callbacks.current.choices.findIndex(item => item.value === g.current) + amount); }\n  function commit() { if (!gesture.current) return; clearTimer(); gesture.current = null; setHeld(false); callbacks.current.onCommit?.(); }\n  function cancel(restore = true, render = true) {\n    const g = gesture.current; if (!g) return;\n    clearTimer(); gesture.current = null; if (render) setHeld(false);\n    if (callbacks.current.onCancel) callbacks.current.onCancel(); else if (restore) callbacks.current.onChange(g.original);\n  }\n  const runtime = React.useRef({ begin, step, commit, cancel }); runtime.current = { begin, step, commit, cancel };\n  React.useEffect(() => {\n    const g = gesture.current;\n    if (g && (value !== g.current || !mutable || vocabulary !== g.vocabulary || props.editing === false)) runtime.current.cancel(false);\n  }, [value, mutable, vocabulary, props.editing]);\n  React.useEffect(() => {\n    const element = root.current;\n    if (!element) return;\n    const wheel = (event: WheelEvent) => {\n      // A normal scroll over text stays a page scroll. Focus explicitly gives this control the wheel.\n      if (document.activeElement !== element || !event.deltaY || !runtime.current.begin('wheel')) return;\n      event.preventDefault();\n      const g = gesture.current!;\n      g.wheel += event.deltaY * (event.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? 1 : ENUM_CUE_STOP);\n      const stops = Math.trunc(g.wheel / ENUM_CUE_STOP);\n      if (stops) { g.wheel -= stops * ENUM_CUE_STOP; runtime.current.step(stops); }\n      clearTimer(); timer.current = setTimeout(runtime.current.commit, SPRINGS.release.duration * 1000);\n    };\n    element.addEventListener('wheel', wheel, { passive: false });\n    return () => { element.removeEventListener('wheel', wheel); runtime.current.cancel(true, false); clearTimer(); };\n  }, []);\n  const current = choice ?? { value };\n  const own = 'mu-enum-cue relative inline-grid align-baseline border-0 bg-transparent p-0 type-content text-ink select-none touch-none cursor-ns-resize focus-visible:focus-ring data-disabled:opacity-field-state-disabled data-disabled:cursor-default';\n  const name = current.label && current.label !== current.value ? `${current.label} (${current.value})` : current.value;\n  const controlProps: React.ComponentPropsWithoutRef<typeof BaseButton> & Record<`data-${string}`, unknown> = {\n    disabled: disabled || readOnly || !choice || !validChoices, focusableWhenDisabled: readOnly,\n    'data-readonly': readOnly || undefined, 'data-held': held || undefined, 'data-value': value,\n    'aria-label': `${label}: ${name}`,\n    'aria-description': ['Click or Space cycles. Up and Down step. Focus to scroll, or hold and drag vertically. Escape cancels a held edit.', triggerProps['aria-description']].filter(Boolean).join(' '),\n    className: className ? `${own} ${className}` : own,\n    onClick: event => { if (ignoreClick.current && event.detail > 0) { ignoreClick.current = false; return; } ignoreClick.current = false; if (begin('keyboard')) { step(1); commit(); } },\n    onPointerDown: event => {\n      if (event.button !== 0 || !mutable) return;\n      event.currentTarget.focus({ preventScroll: true });\n      if (!begin('pointer', event.clientY, event.pointerId)) return;\n      ignoreClick.current = false; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId);\n    },\n    onPointerMove: event => {\n      const g = gesture.current; if (!g || g.kind !== 'pointer' || g.pointer !== event.pointerId) return;\n      const stops = Math.round((g.y - event.clientY) / ENUM_CUE_STOP);\n      if (stops) g.moved = true;\n      land(g.index + stops);\n    },\n    onPointerUp: event => {\n      const g = gesture.current; if (!g || g.kind !== 'pointer' || g.pointer !== event.pointerId) return;\n      if (!g.moved) step(1);\n      ignoreClick.current = true; commit(); event.currentTarget.releasePointerCapture(event.pointerId);\n    },\n    onPointerCancel: () => { ignoreClick.current = true; cancel(); },\n    onLostPointerCapture: () => { if (gesture.current?.kind === 'pointer') ignoreClick.current = true; cancel(); },\n    onBlur: () => { if (gesture.current?.kind === 'pointer') ignoreClick.current = true; cancel(); },\n    onKeyDown: event => {\n      if (event.key === 'Escape' && gesture.current) { event.preventDefault(); ignoreClick.current = gesture.current.kind === 'pointer'; cancel(); return; }\n      if (!mutable || ![' ', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;\n      event.preventDefault(); if (begin('keyboard')) step(event.key === 'ArrowUp' ? -1 : 1);\n    },\n    onKeyUp: event => { if ([' ', 'ArrowUp', 'ArrowDown'].includes(event.key) && gesture.current?.kind === 'keyboard') { event.preventDefault(); commit(); } },\n  };\n  const control = <BaseButton {...mergeProps(triggerProps, controlProps)} ref={root}>\n    {(!reading || held) && choices.map(item => <span key={item.value} aria-hidden className={`invisible col-start-1 row-start-1 whitespace-nowrap${reading ? ' inline-flex items-baseline gap-mu-space-4 px-cue-tag-pad-x' : ''}`}>{reading && choices.some(choice => choice.glyph) && <span className=\"w-button-compact-glyph flex-none\" />}{item.value}</span>)}\n    <span className=\"col-start-1 row-start-1 justify-self-start\"><Mark kind=\"tag\" raw={raw} meaningGlyph={reading && current.glyph ? <Icon name={current.glyph} size={MARK_GLYPH_SIZE} /> : undefined} style={{ '--mu-cue-identity': current.tint ?? 'var(--mu-ink3)' } as React.CSSProperties}>{value.startsWith('#') ? <><span className=\"mu-mark-hash\">#</span><SwapText value={value.slice(1)} /></> : <SwapText value={value} />}</Mark></span>\n    {!reading && !raw && !held && current.glyph && <span aria-hidden className=\"mark-semantic-glyph\"><Icon name={current.glyph} size={MARK_GLYPH_SIZE} /></span>}\n    {held && choices.length > 1 && <span data-enum-instrument aria-hidden className=\"pointer-events-none absolute inset-0 type-meta text-ink2 whitespace-nowrap\">\n      <span className=\"absolute start-0 bottom-full mb-mu-space-8\">{choices[(index - 1 + choices.length) % choices.length]?.label ?? choices[(index - 1 + choices.length) % choices.length]?.value}</span>\n      <span className=\"absolute start-0 top-full mt-mu-space-8\">{choices[(index + 1) % choices.length]?.label ?? choices[(index + 1) % choices.length]?.value}</span>\n    </span>}\n  </BaseButton>;\n  return <Tooltip label=\"Space cycles · Up/Down steps · Focus to scroll · Hold and drag\" disabled={!hint || held || !mutable} wrap>{control}</Tooltip>;\n});\n"
    },
    {
      "path": "packages/metalui/src/components/enum-cue/gesture.generated.ts",
      "type": "registry:ui",
      "target": "components/metalui/components/enum-cue/gesture.generated.ts",
      "content": "// Generated from tokens/tokens.json. Do not edit.\nexport const ENUM_CUE_STOP = 24;\n"
    },
    {
      "path": "packages/metalui/src/components/enum-cue/enum-cue.css",
      "type": "registry:file",
      "target": "components/metalui/components/enum-cue/enum-cue.css",
      "content": "/* EnumCue uses generated type-content, focus-ring and Mark tab utilities. The widest host choice\n   reserves the source footprint; its held instrument only arranges adjacent labels. The existing\n   SwapText drum owns compositor motion and its reduced crossfade. No separate material lives here. */\n"
    }
  ],
  "docs": "Agent guide: https://metalui.dev/r/enum-cue.md. SwiftUI: MetalEnumCue, MetalEnumCueChoice in the MetalUI Swift package."
}
