{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select-53",
  "title": "Settings as a sentence",
  "description": "A setting written as one sentence where only the changeable words are dark: tap a word to pick another, drag a number sideways to dial it, and the line below rolls to say what it means.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/badge.json",
    "https://ui.ndekocode.dev/r/card.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/recipes.json",
    "https://ui.ndekocode.dev/r/selected-mark.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/select/select-53.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport {\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent,\n} from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll, type WordRollWord } from '@/components/core/word-roll';\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { hold } from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { nounOf } from '@/registry/ndeko/lib/words';\nimport { Badge } from '@/registry/ndeko/ui/badge';\nimport { Card } from '@/registry/ndeko/ui/card';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\n\nimport { InlineListbox, type ListOption } from './_parts/inline-listbox';\nimport {\n  useInlineListbox,\n  type InlineListboxControl,\n} from './_parts/use-inline-listbox';\n\nconst WORD = [\n  'text-foreground relative inline-flex cursor-pointer items-center rounded-md px-1 font-medium select-none',\n  'decoration-foreground/30 underline decoration-dotted underline-offset-[6px] outline-none',\n  'transition-colors duration-150 hover:bg-foreground/5',\n  'focus-visible:ring-ring/25 focus-visible:ring-[3px]',\n].join(' ');\n\nconst HELD_WORD = 'bg-foreground/10';\n\nconst VIEWPORT_MARGIN_PX = 12;\n\nfunction heldOf(isHeld: boolean) {\n  if (isHeld) {\n    return HELD_WORD;\n  }\n  return undefined;\n}\n\nfunction labelOfValue(options: readonly ListOption[], value: string) {\n  const option = options.find((candidate) => candidate.value === value);\n  if (option) {\n    return option.label;\n  }\n  return '';\n}\n\nfunction enterShiftOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 0;\n  }\n  return -4;\n}\n\ntype ChoiceWordProps = InlineListboxControl & {\n  label: string;\n};\n\nfunction ChoiceWord({ label, ...control }: ChoiceWordProps) {\n  const { options, value } = control;\n  const popupRef = useRef<HTMLSpanElement>(null);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const {\n    listId,\n    rootRef,\n    isOpen,\n    activeValue,\n    controlsId,\n    activeId,\n    openAround,\n    close,\n    pick,\n    changeHighlight,\n    handleKey,\n  } = useInlineListbox(control);\n  const currentLabel = labelOfValue(options, value);\n\n  useLayoutEffect(() => {\n    const popup = popupRef.current;\n    if (!popup) {\n      return;\n    }\n    popup.style.left = '0px';\n    const limit = document.documentElement.clientWidth - VIEWPORT_MARGIN_PX;\n    const overflow = popup.getBoundingClientRect().right - limit;\n    if (overflow > 0) {\n      popup.style.left = `${-overflow}px`;\n    }\n  }, [isOpen]);\n\n  function toggle() {\n    if (isOpen) {\n      close();\n      return;\n    }\n    openAround();\n  }\n\n  const enter = { opacity: 0, y: enterShiftOf(shouldReduceMotion) };\n\n  return (\n    <span ref={rootRef} className='relative inline-block'>\n      <button\n        type='button'\n        role='combobox'\n        aria-label={`${label}: ${currentLabel}`}\n        aria-haspopup='listbox'\n        aria-expanded={isOpen}\n        aria-controls={controlsId}\n        aria-activedescendant={activeId}\n        onClick={toggle}\n        onKeyDown={handleKey}\n        className={cn(WORD, heldOf(isOpen))}\n      >\n        {currentLabel}\n      </button>\n      <AnimatePresence>\n        {isOpen && (\n          <motion.span\n            ref={popupRef}\n            initial={enter}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}\n            className='absolute top-full left-0 z-30 mt-1.5 block min-w-40'\n          >\n            <InlineListbox\n              id={listId}\n              label={label}\n              options={options}\n              value={value}\n              highlighted={activeValue}\n              onHighlight={changeHighlight}\n              onPick={pick}\n            />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\ntype Dial = {\n  startX: number;\n  startValue: number;\n  hasMoved: boolean;\n};\n\nconst DIAL_THRESHOLD_PX = 3;\nconst PAGE_STEPS = 10;\n\nfunction clamped(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\ntype ScrubWordProps = {\n  label: string;\n  value: number;\n  min: number;\n  max: number;\n  pixelsPerStep: number;\n  valueText: string;\n  onValueChange: (value: number) => void;\n  isDialing?: boolean;\n  children: React.ReactNode;\n};\n\nfunction ScrubWord({\n  label,\n  value,\n  min,\n  max,\n  pixelsPerStep,\n  valueText,\n  onValueChange,\n  isDialing = false,\n  children,\n}: ScrubWordProps) {\n  const dial = useRef<Dial | null>(null);\n  const [isDragging, setIsDragging] = useState(false);\n  const isHeld = isDragging || isDialing;\n\n  function change(next: number) {\n    const bounded = clamped(next, min, max);\n    if (bounded !== value) {\n      onValueChange(bounded);\n    }\n  }\n\n  function startDial(event: PointerEvent<HTMLSpanElement>) {\n    if (event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    dial.current = {\n      startX: event.clientX,\n      startValue: value,\n      hasMoved: false,\n    };\n    setIsDragging(true);\n  }\n\n  function moveDial(event: PointerEvent<HTMLSpanElement>) {\n    const start = dial.current;\n    if (!start) {\n      return;\n    }\n    const travel = event.clientX - start.startX;\n    if (Math.abs(travel) >= DIAL_THRESHOLD_PX) {\n      start.hasMoved = true;\n    }\n    if (!start.hasMoved) {\n      return;\n    }\n    const steps = Math.round(travel / pixelsPerStep);\n    change(start.startValue + steps);\n  }\n\n  function endDial() {\n    dial.current = null;\n    setIsDragging(false);\n  }\n\n  function stepWithKeys(event: KeyboardEvent<HTMLSpanElement>) {\n    const decrease = event.key === 'ArrowLeft' || event.key === 'ArrowDown';\n    const increase = event.key === 'ArrowRight' || event.key === 'ArrowUp';\n    if (decrease) {\n      change(value - 1);\n    }\n    if (increase) {\n      change(value + 1);\n    }\n    if (event.key === 'PageDown') {\n      change(value - PAGE_STEPS);\n    }\n    if (event.key === 'PageUp') {\n      change(value + PAGE_STEPS);\n    }\n    if (event.key === 'Home') {\n      change(min);\n    }\n    if (event.key === 'End') {\n      change(max);\n    }\n    const isHandled = [\n      'ArrowLeft',\n      'ArrowDown',\n      'ArrowRight',\n      'ArrowUp',\n      'PageDown',\n      'PageUp',\n      'Home',\n      'End',\n    ].includes(event.key);\n    if (isHandled) {\n      event.preventDefault();\n    }\n  }\n\n  return (\n    <span\n      role='spinbutton'\n      tabIndex={0}\n      aria-label={label}\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuenow={value}\n      aria-valuetext={valueText}\n      onPointerDown={startDial}\n      onPointerMove={moveDial}\n      onPointerUp={endDial}\n      onPointerCancel={endDial}\n      onKeyDown={stepWithKeys}\n      className={cn(WORD, 'cursor-ew-resize touch-none', heldOf(isHeld))}\n    >\n      {children}\n    </span>\n  );\n}\n\ntype Frequency = 'day' | 'week' | 'month';\n\ntype Scope = 'all' | 'active' | 'one';\n\ntype Rule = {\n  frequency: Frequency;\n  hour: number;\n  copies: number;\n  scope: Scope;\n};\n\nconst FREQUENCIES: ListOption[] = [\n  { value: 'day', label: 'day' },\n  { value: 'week', label: 'week' },\n  { value: 'month', label: 'month' },\n];\n\nconst SCOPES: ListOption[] = [\n  { value: 'all', label: 'all projects' },\n  { value: 'active', label: 'active projects' },\n  { value: 'one', label: 'this project' },\n];\n\nconst GIGABYTES_PER_COPY: Record<Scope, number> = {\n  all: 4.2,\n  active: 2.4,\n  one: 0.8,\n};\n\nconst MIN_COPIES = 1;\nconst MAX_COPIES = 90;\nconst LAST_HOUR = 23;\n\nconst RESTING_RULE: Rule = {\n  frequency: 'day',\n  hour: 2,\n  copies: 30,\n  scope: 'all',\n};\n\nfunction isFrequency(value: string): value is Frequency {\n  return FREQUENCIES.some((option) => option.value === value);\n}\n\nfunction isScope(value: string): value is Scope {\n  return SCOPES.some((option) => option.value === value);\n}\n\nfunction hourTextOf(hour: number) {\n  const padded = String(hour).padStart(2, '0');\n  return `${padded}:00`;\n}\n\nfunction storedGigabytesOf(rule: Rule) {\n  const perCopy = GIGABYTES_PER_COPY[rule.scope];\n  return Math.round(perCopy * rule.copies);\n}\n\n/** The numbers stay the same words while they change, so only the digits roll. */\nfunction explanationOf(rule: Rule): WordRollWord[] {\n  const stored = storedGigabytesOf(rule);\n  const unit = nounOf(rule.copies, rule.frequency);\n  const copiesWord = {\n    key: 'copies',\n    text: String(rule.copies),\n    node: <MotionNumber value={rule.copies} />,\n  };\n  const storedWord = {\n    key: 'stored',\n    text: `${stored} GB`,\n    node: <MotionNumber value={stored} suffix=' GB' />,\n  };\n  return [\n    'Restores',\n    'the',\n    'last',\n    copiesWord,\n    unit,\n    '·',\n    'about',\n    storedWord,\n  ];\n}\n\nfunction spokenRuleOf(rule: Rule) {\n  const frequency = rule.frequency;\n  const time = hourTextOf(rule.hour);\n  const copies = rule.copies;\n  const copyNoun = nounOf(copies, 'copy', 'copies');\n  const scope = labelOfValue(SCOPES, rule.scope);\n  return `Back up every ${frequency} at ${time} and keep ${copies} ${copyNoun} for ${scope}`;\n}\n\ntype WordId = 'frequency' | 'scope';\n\nconst NO_HIGHLIGHTS: Record<WordId, string | null> = {\n  frequency: null,\n  scope: null,\n};\n\ntype DialId = 'hour' | 'copies';\n\nexport default function Select53() {\n  const [rule, setRule] = useState(RESTING_RULE);\n  const [openId, setOpenId] = useState<WordId | null>(null);\n  const [highlights, setHighlights] = useState(NO_HIGHLIGHTS);\n  const [dialing, setDialing] = useState<DialId | null>(null);\n  const [announcement, setAnnouncement] = useState('');\n\n  function highlightFor(id: WordId, value: string | null) {\n    setHighlights((current) => ({ ...current, [id]: value }));\n  }\n\n  function openList(id: WordId) {\n    setOpenId(id);\n    highlightFor(id, rule[id]);\n  }\n\n  function changeOpen(id: WordId, isOpen: boolean) {\n    if (isOpen) {\n      setOpenId(id);\n      return;\n    }\n    setOpenId((current) => {\n      if (current === id) {\n        return null;\n      }\n      return current;\n    });\n  }\n\n  function setFrequency(value: string) {\n    if (isFrequency(value)) {\n      setRule((current) => ({ ...current, frequency: value }));\n    }\n  }\n\n  function setScope(value: string) {\n    if (isScope(value)) {\n      setRule((current) => ({ ...current, scope: value }));\n    }\n  }\n\n  function setHour(hour: number) {\n    setRule((current) => ({ ...current, hour }));\n  }\n\n  function setCopies(copies: number) {\n    setRule((current) => ({ ...current, copies }));\n  }\n\n  function pickFromDemo(id: WordId, value: string) {\n    if (id === 'frequency') {\n      setFrequency(value);\n    }\n    if (id === 'scope') {\n      setScope(value);\n    }\n    setOpenId(null);\n    highlightFor(id, null);\n  }\n\n  function announceFor(next: Rule) {\n    setAnnouncement(spokenRuleOf(next));\n  }\n\n  function chooseFrequency(value: string) {\n    if (!isFrequency(value)) {\n      return;\n    }\n    setFrequency(value);\n    announceFor({ ...rule, frequency: value });\n  }\n\n  function chooseScope(value: string) {\n    if (!isScope(value)) {\n      return;\n    }\n    setScope(value);\n    announceFor({ ...rule, scope: value });\n  }\n\n  function dialSteps(values: number[], write: (value: number) => void) {\n    return values.map((value) => ({ after: 130, run: () => write(value) }));\n  }\n\n  function startOver() {\n    setRule(RESTING_RULE);\n    setOpenId(null);\n    setHighlights(NO_HIGHLIGHTS);\n    setDialing(null);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 2400, run: () => openList('frequency') },\n    { after: 900, run: () => highlightFor('frequency', 'week') },\n    { after: 700, run: () => pickFromDemo('frequency', 'week') },\n    { after: 1500, run: () => setDialing('copies') },\n    ...dialSteps([33, 36, 39, 42, 45, 48], setCopies),\n    { after: 500, run: () => setDialing(null) },\n    { after: 1300, run: () => setDialing('hour') },\n    ...dialSteps([3, 4, 5], setHour),\n    { after: 500, run: () => setDialing(null) },\n    { after: 1300, run: () => openList('scope') },\n    { after: 900, run: () => highlightFor('scope', 'active') },\n    { after: 700, run: () => pickFromDemo('scope', 'active') },\n    { after: 1800, run: hold },\n  ];\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 2400,\n    onReset: startOver,\n  });\n\n  useKeptHeight(ref);\n\n  const copyNoun = nounOf(rule.copies, 'copy', 'copies');\n  const hourText = hourTextOf(rule.hour);\n\n  return (\n    <div ref={ref} className='min-h-[18rem] w-full max-w-md pb-10'>\n      <Card className='gap-4 px-5'>\n        <div className='flex items-center justify-between gap-2'>\n          <span className='text-muted-foreground text-xs font-medium'>\n            Backup rule\n          </span>\n          <Badge variant='secondary'>Illustrative</Badge>\n        </div>\n        <div\n          role='group'\n          aria-label='Backup rule'\n          className='text-muted-foreground text-lg leading-10'\n        >\n          Back up every{' '}\n          <ChoiceWord\n            label='Frequency'\n            options={FREQUENCIES}\n            value={rule.frequency}\n            onValueChange={chooseFrequency}\n            open={openId === 'frequency'}\n            onOpenChange={(isOpen) => changeOpen('frequency', isOpen)}\n            highlighted={highlights.frequency}\n            onHighlightedChange={(value) => highlightFor('frequency', value)}\n          />{' '}\n          at{' '}\n          <ScrubWord\n            label='Time of day'\n            value={rule.hour}\n            min={0}\n            max={LAST_HOUR}\n            pixelsPerStep={14}\n            valueText={hourText}\n            onValueChange={setHour}\n            isDialing={dialing === 'hour'}\n          >\n            <MotionNumber\n              value={rule.hour}\n              format={{ minimumIntegerDigits: 2 }}\n              suffix=':00'\n            />\n          </ScrubWord>{' '}\n          and keep{' '}\n          <ScrubWord\n            label='Copies to keep'\n            value={rule.copies}\n            min={MIN_COPIES}\n            max={MAX_COPIES}\n            pixelsPerStep={4}\n            valueText={`${rule.copies} ${copyNoun}`}\n            onValueChange={setCopies}\n            isDialing={dialing === 'copies'}\n          >\n            <MotionNumber value={rule.copies} />\n          </ScrubWord>{' '}\n          {copyNoun} for{' '}\n          <ChoiceWord\n            label='Projects'\n            options={SCOPES}\n            value={rule.scope}\n            onValueChange={chooseScope}\n            open={openId === 'scope'}\n            onOpenChange={(isOpen) => changeOpen('scope', isOpen)}\n            highlighted={highlights.scope}\n            onHighlightedChange={(value) => highlightFor('scope', value)}\n          />\n        </div>\n        <div className='flex flex-col gap-1'>\n          <WordRoll words={explanationOf(rule)} className='text-sm' />\n          <p className='text-muted-foreground text-xs'>\n            Tap a word to pick another, drag a number sideways to dial it\n          </p>\n        </div>\n      </Card>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/select/select-53.tsx"
    },
    {
      "path": "registry/ndeko/components/select/_parts/inline-listbox.tsx",
      "content": "'use client';\n\nimport { useEffect, type RefObject } from 'react';\n\nimport { MENU_ITEM } from '@/registry/ndeko/lib/recipes';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { SelectedMark } from '@/registry/ndeko/ui/selected-mark';\n\nexport type ListOption = {\n  value: string;\n  label: string;\n};\n\n/** A row is `h-8` and the list is `p-1`: a list that opens around its trigger lines the rows up with these. */\nexport const OPTION_HEIGHT_PX = 32;\n\nexport const LISTBOX_PADDING_PX = 4;\n\nconst MARK =\n  'absolute right-1.5 transition-[scale,opacity] duration-150 ease-(--ease-out-strong)';\n\nfunction markStateOf(isSelected: boolean) {\n  if (isSelected) {\n    return 'scale-100 opacity-100';\n  }\n  return 'scale-75 opacity-0 duration-100';\n}\n\nfunction highlightAttributeOf(isHighlighted: boolean) {\n  if (isHighlighted) {\n    return '';\n  }\n  return undefined;\n}\n\nexport function optionIdOf(listId: string, value: string) {\n  return `${listId}-${value}`;\n}\n\nexport function indexOfOption(options: readonly ListOption[], value: string) {\n  return options.findIndex((option) => option.value === value);\n}\n\nexport function nextHighlightOf(\n  options: readonly ListOption[],\n  current: string | null,\n  key: string\n) {\n  const lastIndex = options.length - 1;\n  const currentIndex = options.findIndex((option) => option.value === current);\n  if (key === 'ArrowDown') {\n    return options[Math.min(lastIndex, currentIndex + 1)].value;\n  }\n  if (key === 'ArrowUp') {\n    return options[Math.max(0, currentIndex - 1)].value;\n  }\n  if (key === 'Home') {\n    return options[0].value;\n  }\n  if (key === 'End') {\n    return options[lastIndex].value;\n  }\n  return null;\n}\n\n/** With the pointer captured by the trigger, the row under it has to be found by position. */\nexport function optionValueAt(\n  listbox: HTMLElement | null,\n  x: number,\n  y: number\n) {\n  if (!listbox) {\n    return null;\n  }\n  const hit = document.elementFromPoint(x, y);\n  const option = hit?.closest<HTMLElement>('[data-option]');\n  if (!option || !listbox.contains(option)) {\n    return null;\n  }\n  return option.dataset.option ?? null;\n}\n\nexport function useDismissOutside(\n  rootRef: RefObject<HTMLElement | null>,\n  isOpen: boolean,\n  onDismiss: () => void\n) {\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    function dismissOnOutsidePress(event: PointerEvent) {\n      const root = rootRef.current;\n      const target = event.target;\n      if (!root || !(target instanceof Node)) {\n        return;\n      }\n      if (root.contains(target)) {\n        return;\n      }\n      onDismiss();\n    }\n    document.addEventListener('pointerdown', dismissOnOutsidePress);\n    return () =>\n      document.removeEventListener('pointerdown', dismissOnOutsidePress);\n  }, [rootRef, isOpen, onDismiss]);\n}\n\ntype InlineListboxProps = {\n  id: string;\n  label: string;\n  options: readonly ListOption[];\n  value: string;\n  highlighted: string | null;\n  onHighlight: (value: string) => void;\n  onPick: (value: string) => void;\n  listboxRef?: React.Ref<HTMLDivElement>;\n  className?: string;\n};\n\nexport function InlineListbox({\n  id,\n  label,\n  options,\n  value,\n  highlighted,\n  onHighlight,\n  onPick,\n  listboxRef,\n  className,\n}: InlineListboxProps) {\n  return (\n    <div\n      ref={listboxRef}\n      id={id}\n      role='listbox'\n      aria-label={label}\n      className={cn('surface-raised rounded-xl p-1 outline-none', className)}\n    >\n      {options.map((option) => {\n        const isSelected = option.value === value;\n        const isHighlighted = option.value === highlighted;\n\n        return (\n          <div\n            key={option.value}\n            id={optionIdOf(id, option.value)}\n            role='option'\n            aria-selected={isSelected}\n            data-option={option.value}\n            data-highlighted={highlightAttributeOf(isHighlighted)}\n            onPointerMove={() => onHighlight(option.value)}\n            onClick={() => onPick(option.value)}\n            className={cn(MENU_ITEM, 'cursor-pointer pr-8')}\n          >\n            {option.label}\n            <SelectedMark className={cn(MARK, markStateOf(isSelected))} />\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/select/_parts/inline-listbox.tsx"
    },
    {
      "path": "registry/ndeko/components/select/_parts/use-inline-listbox.ts",
      "content": "'use client';\n\nimport { useId, useRef, useState, type KeyboardEvent } from 'react';\n\nimport {\n  nextHighlightOf,\n  optionIdOf,\n  useDismissOutside,\n  type ListOption,\n} from './inline-listbox';\n\nexport type InlineListboxControl = {\n  options: readonly ListOption[];\n  value: string;\n  onValueChange: (value: string) => void;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  highlighted?: string | null;\n  onHighlightedChange?: (value: string | null) => void;\n};\n\nfunction idWhenOpen(isOpen: boolean, id: string | null) {\n  if (isOpen && id !== null) {\n    return id;\n  }\n  return undefined;\n}\n\nfunction activeOptionIdOf(listId: string, activeValue: string | null) {\n  if (activeValue === null) {\n    return null;\n  }\n  return optionIdOf(listId, activeValue);\n}\n\n/**\n * The state and the keyboard of a list drawn inline: the focus stays on the trigger, which names\n * the highlighted row with `aria-activedescendant`. Open and highlight can be driven from outside.\n */\nexport function useInlineListbox({\n  options,\n  value,\n  onValueChange,\n  open,\n  onOpenChange,\n  highlighted,\n  onHighlightedChange,\n}: InlineListboxControl) {\n  const listId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [ownOpen, setOwnOpen] = useState(false);\n  const [ownHighlighted, setOwnHighlighted] = useState<string | null>(null);\n  const isOpen = open ?? ownOpen;\n  const activeValue = highlighted ?? ownHighlighted;\n  const activeOptionId = activeOptionIdOf(listId, activeValue);\n  const controlsId = idWhenOpen(isOpen, listId);\n  const activeId = idWhenOpen(isOpen, activeOptionId);\n\n  function changeOpen(next: boolean) {\n    setOwnOpen(next);\n    onOpenChange?.(next);\n  }\n\n  function changeHighlight(next: string | null) {\n    setOwnHighlighted(next);\n    onHighlightedChange?.(next);\n  }\n\n  function close() {\n    changeOpen(false);\n    changeHighlight(null);\n  }\n\n  function openAround() {\n    changeHighlight(value);\n    changeOpen(true);\n  }\n\n  function pick(next: string) {\n    onValueChange(next);\n    close();\n  }\n\n  useDismissOutside(rootRef, isOpen, close);\n\n  function handleKey(event: KeyboardEvent<HTMLElement>) {\n    const isActivation = event.key === 'Enter' || event.key === ' ';\n    const isArrow = event.key === 'ArrowDown' || event.key === 'ArrowUp';\n    if (!isOpen) {\n      const opensList = isActivation || isArrow;\n      if (opensList) {\n        event.preventDefault();\n        openAround();\n      }\n      return;\n    }\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      close();\n      return;\n    }\n    if (event.key === 'Tab') {\n      close();\n      return;\n    }\n    if (isActivation) {\n      event.preventDefault();\n      pick(activeValue ?? value);\n      return;\n    }\n    const next = nextHighlightOf(options, activeValue ?? value, event.key);\n    if (next === null) {\n      return;\n    }\n    event.preventDefault();\n    changeHighlight(next);\n  }\n\n  return {\n    listId,\n    rootRef,\n    isOpen,\n    activeValue,\n    controlsId,\n    activeId,\n    openAround,\n    close,\n    pick,\n    changeHighlight,\n    handleKey,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/select/_parts/use-inline-listbox.ts"
    }
  ],
  "categories": [
    "components",
    "select"
  ],
  "type": "registry:component"
}