{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select-52",
  "title": "Popup around the pointer",
  "description": "A select whose list opens with the current choice under your finger: slide to another one and let go to pick it, or click to open it as usual.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/live-region.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"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/select/select-52.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useRef, useState, type PointerEvent } from 'react';\n\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 { ChevronsUpDownIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\n\nimport {\n  indexOfOption,\n  InlineListbox,\n  LISTBOX_PADDING_PX,\n  OPTION_HEIGHT_PX,\n  optionValueAt,\n  type ListOption,\n} from './_parts/inline-listbox';\nimport {\n  useInlineListbox,\n  type InlineListboxControl,\n} from './_parts/use-inline-listbox';\n\nconst SLIDE_THRESHOLD_PX = 6;\n\nconst TRIGGER = [\n  'bg-card hairline-ring ring-foreground/12 flex h-8 w-full cursor-pointer touch-none items-center justify-between gap-2 rounded-md px-2 text-[13px] shadow-[0_1px_2px_rgb(0_0_0/0.05)]',\n  'dark:bg-foreground/[0.04] dark:ring-foreground/15 outline-none select-none',\n  'focus-visible:ring-ring/25 focus-visible:ring-[3px]',\n].join(' ');\n\ntype Press = {\n  x: number;\n  y: number;\n  hasSlid: boolean;\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 listTopOf(index: number) {\n  const rowsAbove = index * OPTION_HEIGHT_PX;\n  return -(LISTBOX_PADDING_PX + rowsAbove);\n}\n\nfunction originYOf(index: number) {\n  const rowsAbove = index * OPTION_HEIGHT_PX;\n  const rowCenter = OPTION_HEIGHT_PX / 2;\n  return LISTBOX_PADDING_PX + rowsAbove + rowCenter;\n}\n\nfunction enterScaleOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 1;\n  }\n  return 0.96;\n}\n\nexport type SlideSelectProps = InlineListboxControl & {\n  label: string;\n  className?: string;\n};\n\nexport function SlideSelect({\n  label,\n  className,\n  ...control\n}: SlideSelectProps) {\n  const { options, value } = control;\n  const listboxRef = useRef<HTMLDivElement>(null);\n  const press = useRef<Press | null>(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 currentIndex = indexOfOption(options, value);\n  const currentLabel = labelOfValue(options, value);\n\n  function startPress(event: PointerEvent<HTMLButtonElement>) {\n    if (event.button !== 0) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    press.current = { x: event.clientX, y: event.clientY, hasSlid: false };\n    openAround();\n  }\n\n  function slidePress(event: PointerEvent<HTMLButtonElement>) {\n    const start = press.current;\n    if (!start) {\n      return;\n    }\n    const travel = Math.hypot(event.clientX - start.x, event.clientY - start.y);\n    if (travel > SLIDE_THRESHOLD_PX) {\n      start.hasSlid = true;\n    }\n    if (!start.hasSlid) {\n      return;\n    }\n    const under = optionValueAt(\n      listboxRef.current,\n      event.clientX,\n      event.clientY\n    );\n    changeHighlight(under);\n  }\n\n  function endPress(event: PointerEvent<HTMLButtonElement>) {\n    const start = press.current;\n    press.current = null;\n    if (!start || !start.hasSlid) {\n      return;\n    }\n    const under = optionValueAt(\n      listboxRef.current,\n      event.clientX,\n      event.clientY\n    );\n    if (under === null) {\n      close();\n      return;\n    }\n    pick(under);\n  }\n\n  function cancelPress() {\n    press.current = null;\n    close();\n  }\n\n  const enter = { opacity: 0, scale: enterScaleOf(shouldReduceMotion) };\n  const listStyle = {\n    top: listTopOf(currentIndex),\n    left: -LISTBOX_PADDING_PX,\n    right: -LISTBOX_PADDING_PX,\n    transformOrigin: `50% ${originYOf(currentIndex)}px`,\n  };\n\n  return (\n    <div ref={rootRef} className={cn('relative', className)}>\n      <button\n        type='button'\n        role='combobox'\n        aria-label={label}\n        aria-haspopup='listbox'\n        aria-expanded={isOpen}\n        aria-controls={controlsId}\n        aria-activedescendant={activeId}\n        onPointerDown={startPress}\n        onPointerMove={slidePress}\n        onPointerUp={endPress}\n        onPointerCancel={cancelPress}\n        onKeyDown={handleKey}\n        className={TRIGGER}\n      >\n        <Swap swapKey={value} className='min-w-0'>\n          <span className='truncate'>{currentLabel}</span>\n        </Swap>\n        <ChevronsUpDownIcon className='text-muted-foreground size-3.5 shrink-0' />\n      </button>\n      <AnimatePresence>\n        {isOpen && (\n          <motion.div\n            style={listStyle}\n            initial={enter}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}\n            className='absolute z-30'\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              listboxRef={listboxRef}\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\ntype SettingId = 'repeat' | 'alert' | 'calendar';\n\ntype Setting = {\n  id: SettingId;\n  label: string;\n  options: ListOption[];\n};\n\nconst SETTINGS: Setting[] = [\n  {\n    id: 'repeat',\n    label: 'Repeat',\n    options: [\n      { value: 'never', label: 'Never' },\n      { value: 'day', label: 'Every day' },\n      { value: 'week', label: 'Every week' },\n      { value: 'month', label: 'Every month' },\n      { value: 'year', label: 'Every year' },\n    ],\n  },\n  {\n    id: 'alert',\n    label: 'Alert',\n    options: [\n      { value: 'none', label: 'None' },\n      { value: 'start', label: 'At time of event' },\n      { value: '5', label: '5 minutes before' },\n      { value: '15', label: '15 minutes before' },\n      { value: '60', label: '1 hour before' },\n    ],\n  },\n  {\n    id: 'calendar',\n    label: 'Calendar',\n    options: [\n      { value: 'work', label: 'Work' },\n      { value: 'home', label: 'Home' },\n      { value: 'family', label: 'Family' },\n      { value: 'birthdays', label: 'Birthdays' },\n    ],\n  },\n];\n\ntype Values = Record<SettingId, string>;\n\nconst NO_HIGHLIGHTS: Record<SettingId, string | null> = {\n  repeat: null,\n  alert: null,\n  calendar: null,\n};\n\nconst RESTING_VALUES: Values = {\n  repeat: 'week',\n  alert: '15',\n  calendar: 'home',\n};\n\nexport default function Select52() {\n  const [values, setValues] = useState(RESTING_VALUES);\n  const [openId, setOpenId] = useState<SettingId | null>(null);\n  const [highlights, setHighlights] = useState(NO_HIGHLIGHTS);\n  const [announcement, setAnnouncement] = useState('');\n\n  function highlightFor(id: SettingId, value: string | null) {\n    setHighlights((current) => ({ ...current, [id]: value }));\n  }\n\n  function openList(id: SettingId) {\n    setOpenId(id);\n    highlightFor(id, values[id]);\n  }\n\n  function pickFromDemo(id: SettingId, value: string) {\n    setValues((current) => ({ ...current, [id]: value }));\n    setOpenId(null);\n    highlightFor(id, null);\n  }\n\n  function changeFromPerson(setting: Setting, value: string) {\n    const option = setting.options.find(\n      (candidate) => candidate.value === value\n    );\n    setValues((current) => ({ ...current, [setting.id]: value }));\n    if (option) {\n      setAnnouncement(`${setting.label}: ${option.label}`);\n    }\n  }\n\n  function changeOpen(id: SettingId, 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 startOver() {\n    setValues(RESTING_VALUES);\n    setOpenId(null);\n    setHighlights(NO_HIGHLIGHTS);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 2400, run: () => openList('repeat') },\n    { after: 900, run: () => highlightFor('repeat', 'month') },\n    { after: 700, run: () => highlightFor('repeat', 'day') },\n    { after: 900, run: () => pickFromDemo('repeat', 'day') },\n    { after: 1800, run: () => openList('alert') },\n    { after: 900, run: () => highlightFor('alert', '5') },\n    { after: 700, run: () => highlightFor('alert', 'start') },\n    { after: 900, run: () => pickFromDemo('alert', 'start') },\n    { after: 1800, run: () => openList('calendar') },\n    { after: 900, run: () => highlightFor('calendar', 'family') },\n    { after: 900, run: () => pickFromDemo('calendar', 'family') },\n    { after: 1600, run: hold },\n  ];\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 2400,\n    onReset: startOver,\n  });\n\n  useKeptHeight(ref);\n\n  return (\n    <div ref={ref} className='min-h-[22rem] w-full max-w-sm pt-4 pb-14'>\n      <section\n        aria-label='New event'\n        className='bg-foreground/[0.025] rounded-xl p-1'\n      >\n        <h3 className='px-3 py-2.5 text-sm font-medium'>New event</h3>\n        {SETTINGS.map((setting) => (\n          <div\n            key={setting.id}\n            className='flex h-12 items-center justify-between gap-3 px-3'\n          >\n            <span className='text-[13px]'>{setting.label}</span>\n            <SlideSelect\n              label={setting.label}\n              options={setting.options}\n              value={values[setting.id]}\n              onValueChange={(value) => changeFromPerson(setting, value)}\n              open={openId === setting.id}\n              onOpenChange={(isOpen) => changeOpen(setting.id, isOpen)}\n              highlighted={highlights[setting.id]}\n              onHighlightedChange={(value) => highlightFor(setting.id, value)}\n              className='w-44'\n            />\n          </div>\n        ))}\n      </section>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/select/select-52.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"
}