{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dropdown","type":"registry:ui","title":"Dropdown Menu","description":"Menu button with button, icon, avatar or account-card triggers, an account header or filtering search, sections, checkbox and radio rows and submenus; it springs open from the trigger and a highlight glides between rows.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/button"],"files":[{"path":"registry/geturui/ui/dropdown.tsx","type":"registry:ui","target":"components/ui/dropdown.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronDown, ChevronsUpDown, EllipsisVertical, Search } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button, type ButtonSize, type ButtonVariant } from \"./button\";\nimport { MenuPanel, filterMenuEntries, isMenuItem, type MenuEntry, type MenuItemEntry, type MenuPlacement } from \"./menu-core\";\n\nexport type DropdownEntry = MenuEntry;\nexport type DropdownItem = MenuItemEntry;\n\n/** Props to spread on a custom trigger element. */\nexport interface DropdownTriggerProps {\n  id: string;\n  \"aria-haspopup\": \"menu\";\n  \"aria-expanded\": boolean;\n  \"aria-controls\"?: string;\n  onClick: (e: React.MouseEvent<HTMLElement>) => void;\n  onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;\n}\n\nexport type DropdownTrigger =\n  | { kind: \"button\"; label: string; icon?: React.ReactNode; variant?: ButtonVariant; size?: ButtonSize }\n  | { kind: \"icon\"; label: string; icon?: React.ReactNode; variant?: ButtonVariant; size?: ButtonSize }\n  | { kind: \"avatar\"; name: string; initials?: string; label?: string; size?: \"sm\" | \"md\" | \"lg\" }\n  | { kind: \"account\"; name: string; email?: string; initials?: string }\n  | ((props: DropdownTriggerProps & { open: boolean }) => React.ReactNode);\n\nexport interface DropdownProps {\n  /** A preset trigger (button, icon button, avatar, account card) or a render function that spreads the given props on your own element. */\n  trigger: DropdownTrigger;\n  items: DropdownEntry[];\n  /** Account card at the top of the menu. */\n  account?: { name: string; email: string; initials?: string; badge?: React.ReactNode };\n  /** Search box that filters items by label, description and keywords. */\n  search?: boolean | { placeholder?: string; emptyLabel?: string };\n  /** Custom content above the items (after the account card / search). */\n  header?: React.ReactNode;\n  /** Non-interactive line under the items, e.g. a version or plan meter. Put actions in `items`. */\n  footer?: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Preferred side; flips when the viewport has more room on the other side. */\n  side?: \"top\" | \"bottom\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Gap between trigger and menu, px. */\n  sideOffset?: number;\n  /** Whether checkbox and radio rows close the menu. */\n  closeOnCheck?: boolean;\n  /** Menu width in px (default fits content, min 216). */\n  width?: number;\n  disabled?: boolean;\n  /** Classes for the trigger wrapper. */\n  className?: string;\n  /** Classes for every menu surface, including submenus. */\n  menuClassName?: string;\n}\n\ntype FocusMode = \"first\" | \"last\" | \"panel\";\n\nconst noopSubscribe = () => () => {};\nconst FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\nconst tints = [\"bg-volt-soft text-volt-ink\", \"bg-info-soft text-info\", \"bg-ember-soft text-ember-ink\", \"bg-success-soft text-success\", \"bg-warning-soft text-warning\"];\n\nfunction initialsOf(name: string) {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0])\n    .join(\"\")\n    .toUpperCase();\n}\n\nfunction Initials({ name, initials, className }: { name: string; initials?: string; className?: string }) {\n  let h = 0;\n  for (const c of name) h = (h + c.charCodeAt(0)) % 997;\n  return (\n    <span aria-hidden className={cn(\"grid shrink-0 place-items-center rounded-full font-semibold\", tints[h % tints.length], className)}>\n      {initials ?? initialsOf(name)}\n    </span>\n  );\n}\n\nconst avatarSizes = { sm: \"size-8 text-[11px]\", md: \"size-9 text-xs\", lg: \"size-10 text-[13px]\" } as const;\n\nfunction Chevron({ open, up }: { open: boolean; up?: boolean }) {\n  const reduce = useReducedMotion();\n  const Icon = up ? ChevronsUpDown : ChevronDown;\n  return (\n    <motion.span\n      aria-hidden\n      className=\"inline-flex\"\n      animate={{ rotate: open && !up ? 180 : 0 }}\n      transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 26 }}\n    >\n      <Icon className=\"size-4 opacity-70\" />\n    </motion.span>\n  );\n}\n\nfunction RenderTrigger({ render, ...props }: DropdownTriggerProps & { open: boolean; render: (p: DropdownTriggerProps & { open: boolean }) => React.ReactNode }) {\n  return <>{render(props)}</>;\n}\n\n/**\n * Menu button: a trigger (button, icon, avatar, account card or your own) opens a menu that springs from the trigger,\n * flips to stay on screen and supports sections, icons, descriptions, shortcuts, badges, checkbox / radio rows,\n * submenus, an account header or a filtering search box. Full APG menu-button keyboard support.\n */\nexport function Dropdown({\n  trigger,\n  items,\n  account,\n  search,\n  header,\n  footer,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"start\",\n  sideOffset = 6,\n  closeOnCheck = false,\n  width,\n  disabled,\n  className,\n  menuClassName,\n}: DropdownProps) {\n  const uid = React.useId();\n  const triggerId = `${uid}-trigger`;\n  const menuId = `${uid}-menu`;\n  const [inner, setInner] = React.useState(defaultOpen);\n  const isOpen = (openProp ?? inner) && !disabled;\n  const [focusMode, setFocusMode] = React.useState<FocusMode>(\"panel\");\n  const [query, setQuery] = React.useState(\"\");\n  const [anchor, setAnchor] = React.useState<HTMLSpanElement | null>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const searchRef = React.useRef<HTMLInputElement>(null);\n  const mounted = React.useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false,\n  );\n  const onOpenChangeRef = React.useRef(onOpenChange);\n  React.useEffect(() => {\n    onOpenChangeRef.current = onOpenChange;\n  });\n\n  const hasSearch = !!search;\n  const searchOpts = search ? (typeof search === \"object\" ? search : {}) : null;\n  const shown = React.useMemo(() => (hasSearch ? filterMenuEntries(items, query) : items), [items, query, hasSearch]);\n  const resultCount = shown.filter(isMenuItem).length;\n  const scrollable = !items.some((e) => isMenuItem(e) && e.items?.length);\n\n  const placement = React.useMemo<MenuPlacement | null>(\n    () => (anchor ? { kind: \"anchor\", anchor, side, align, offset: sideOffset } : null),\n    [anchor, side, align, sideOffset],\n  );\n\n  const restoreFocus = React.useCallback(() => {\n    requestAnimationFrame(() => anchor?.querySelector<HTMLElement>(FOCUSABLE)?.focus({ preventScroll: true }));\n  }, [anchor]);\n\n  const setOpen = React.useCallback(\n    (next: boolean, opts: { focus?: FocusMode; restore?: boolean } = {}) => {\n      if (next) {\n        setFocusMode(opts.focus ?? \"panel\");\n        setQuery(\"\");\n      }\n      if (openProp === undefined) setInner(next);\n      onOpenChangeRef.current?.(next);\n      if (!next && opts.restore) restoreFocus();\n    },\n    [openProp, restoreFocus],\n  );\n\n  // Outside press closes without moving focus; the trigger itself toggles via its click handler.\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const onDown = (e: PointerEvent) => {\n      const t = e.target as Node;\n      if (panelRef.current?.contains(t) || anchor?.contains(t)) return;\n      setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onDown, true);\n  }, [isOpen, anchor, setOpen]);\n\n  // With a search box, focus lands in it on open.\n  React.useEffect(() => {\n    if (!isOpen || !hasSearch) return;\n    const t = requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));\n    return () => cancelAnimationFrame(t);\n  }, [isOpen, hasSearch]);\n\n  function focusRow(which: \"first\" | \"last\") {\n    const rows = panelRef.current?.querySelectorAll<HTMLElement>('[role=\"menu\"] > [data-menu-item]:not([aria-disabled]), [role=\"menu\"] > [role=\"group\"] > [data-menu-item]:not([aria-disabled])');\n    if (!rows?.length) return;\n    (which === \"first\" ? rows[0] : rows[rows.length - 1]).focus({ preventScroll: true });\n  }\n\n  const triggerProps: DropdownTriggerProps = {\n    id: triggerId,\n    \"aria-haspopup\": \"menu\",\n    \"aria-expanded\": isOpen,\n    \"aria-controls\": isOpen ? menuId : undefined,\n    onClick: (e) => {\n      if (disabled) return;\n      if (isOpen) return setOpen(false);\n      // detail 0 = keyboard click (Enter / Space): focus the first row, as the APG menu button asks.\n      setOpen(true, { focus: e.detail === 0 ? \"first\" : \"panel\" });\n    },\n    onKeyDown: (e) => {\n      if (disabled) return;\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setOpen(true, { focus: e.key === \"ArrowDown\" ? \"first\" : \"last\" });\n      }\n    },\n  };\n\n  let triggerNode: React.ReactNode;\n  if (typeof trigger === \"function\") triggerNode = <RenderTrigger render={trigger} {...triggerProps} open={isOpen} />;\n  else if (trigger.kind === \"button\")\n    triggerNode = (\n      <Button {...triggerProps} variant={trigger.variant ?? \"secondary\"} size={trigger.size ?? \"md\"} disabled={disabled}>\n        {trigger.icon && <span className=\"inline-flex [&>svg]:size-4\" aria-hidden>{trigger.icon}</span>}\n        {trigger.label}\n        <Chevron open={isOpen} />\n      </Button>\n    );\n  else if (trigger.kind === \"icon\")\n    triggerNode = (\n      <Button {...triggerProps} aria-label={trigger.label} variant={trigger.variant ?? \"ghost\"} size={trigger.size ?? \"md\"} iconOnly disabled={disabled}>\n        <span className=\"inline-flex [&>svg]:size-[18px]\" aria-hidden>\n          {trigger.icon ?? <EllipsisVertical />}\n        </span>\n      </Button>\n    );\n  else if (trigger.kind === \"avatar\")\n    triggerNode = (\n      <motion.button\n        type=\"button\"\n        {...triggerProps}\n        aria-label={trigger.label ?? `Account menu for ${trigger.name}`}\n        disabled={disabled}\n        whileTap={{ scale: 0.94 }}\n        transition={{ type: \"spring\", stiffness: 520, damping: 26 }}\n        className=\"rounded-full outline-none ring-2 ring-transparent transition-shadow hover:ring-border-strong focus-visible:shadow-[var(--focus-ring)] disabled:opacity-45\"\n      >\n        <Initials name={trigger.name} initials={trigger.initials} className={avatarSizes[trigger.size ?? \"md\"]} />\n      </motion.button>\n    );\n  else\n    triggerNode = (\n      <motion.button\n        type=\"button\"\n        {...triggerProps}\n        disabled={disabled}\n        whileTap={{ scale: 0.98 }}\n        transition={{ type: \"spring\", stiffness: 520, damping: 30 }}\n        className={cn(\n          \"flex w-full min-w-0 items-center gap-2.5 rounded-md border border-border bg-surface p-2 pr-2.5 text-left outline-none transition-colors\",\n          \"hover:border-border-strong hover:bg-surface-raised focus-visible:shadow-[var(--focus-ring)] disabled:opacity-45\",\n          isOpen && \"border-border-strong bg-surface-raised\",\n        )}\n      >\n        <Initials name={trigger.name} initials={trigger.initials} className=\"size-9 text-xs\" />\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"block truncate text-[13.5px] font-semibold leading-5 text-ink\">{trigger.name}</span>\n          {trigger.email && <span className=\"block truncate text-[12px] leading-4 text-ink-subtle\">{trigger.email}</span>}\n        </span>\n        <span className=\"text-ink-subtle\">\n          <Chevron open={isOpen} up />\n        </span>\n      </motion.button>\n    );\n\n  const headerNode =\n    account || searchOpts || header ? (\n      <>\n        {account && (\n          <div className=\"-mx-1 -mt-1 mb-1 flex items-center gap-2.5 border-b border-border px-3.5 py-3\">\n            <Initials name={account.name} initials={account.initials} className=\"size-9 text-xs\" />\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-[13.5px] font-semibold leading-5\">{account.name}</p>\n              <p className=\"truncate text-[12px] leading-4 text-ink-subtle\">{account.email}</p>\n            </div>\n            {account.badge}\n          </div>\n        )}\n        {searchOpts && (\n          <div className={cn(\"-mx-1 mb-1 border-b border-border px-1.5 pb-1.5\", account ? \"pt-0.5\" : \"-mt-1 pt-1.5\")}>\n            <label className=\"flex h-8 items-center gap-2 rounded-sm bg-surface-sunken px-2.5 text-ink-subtle focus-within:shadow-[0_0_0_2px_var(--volt-soft)]\">\n              <Search aria-hidden className=\"size-3.5 shrink-0\" />\n              <input\n                ref={searchRef}\n                type=\"search\"\n                value={query}\n                onChange={(e) => setQuery(e.target.value)}\n                aria-label={searchOpts.placeholder ?? \"Search\"}\n                aria-controls={menuId}\n                placeholder={searchOpts.placeholder ?? \"Search\"}\n                autoComplete=\"off\"\n                spellCheck={false}\n                onKeyDown={(e) => {\n                  if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n                    e.preventDefault();\n                    focusRow(e.key === \"ArrowDown\" ? \"first\" : \"last\");\n                  } else if (e.key === \"Enter\") {\n                    e.preventDefault();\n                    panelRef.current?.querySelector<HTMLElement>(\"[data-menu-item]:not([aria-disabled])\")?.click();\n                  } else if (e.key === \"Escape\") {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    setOpen(false, { restore: true });\n                  } else if (e.key === \"Tab\") {\n                    setOpen(false);\n                  }\n                }}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-subtle [&::-webkit-search-cancel-button]:hidden\"\n              />\n            </label>\n            <span className=\"sr-only\" aria-live=\"polite\">\n              {query ? `${resultCount} ${resultCount === 1 ? \"result\" : \"results\"}` : \"\"}\n            </span>\n          </div>\n        )}\n        {header}\n      </>\n    ) : null;\n\n  return (\n    <>\n      <span ref={setAnchor} className={cn(\"inline-flex\", typeof trigger !== \"function\" && trigger.kind === \"account\" && \"flex w-full\", className)}>\n        {triggerNode}\n      </span>\n      {mounted &&\n        placement &&\n        createPortal(\n          <AnimatePresence>\n            {isOpen && (\n              <MenuPanel\n                key=\"menu\"\n                ref={panelRef}\n                entries={shown}\n                placement={placement}\n                focusOnOpen={searchOpts ? \"none\" : focusMode}\n                onCloseAll={(restore) => setOpen(false, { restore })}\n                onExitTop={searchOpts ? () => searchRef.current?.focus({ preventScroll: true }) : undefined}\n                onPrintableKey={\n                  searchOpts\n                    ? (key) => {\n                        setQuery((q) => (key === \"Backspace\" ? q.slice(0, -1) : q + key));\n                        searchRef.current?.focus({ preventScroll: true });\n                      }\n                    : undefined\n                }\n                closeOnCheck={closeOnCheck}\n                header={headerNode}\n                footer={footer ? <div className=\"-mx-1 -mb-1 mt-1 border-t border-border px-3.5 py-2.5 text-[12px] leading-4 text-ink-subtle\">{footer}</div> : undefined}\n                empty={\n                  <div className=\"px-2.5 py-6 text-center text-[13px] text-ink-subtle\">\n                    {searchOpts?.emptyLabel ?? (\n                      <>\n                        No matches for <span className=\"text-ink-muted\">“{query}”</span>\n                      </>\n                    )}\n                  </div>\n                }\n                scrollable={scrollable}\n                menuId={menuId}\n                labelledBy={triggerId}\n                menuClassName={menuClassName}\n                width={width}\n              />\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </>\n  );\n}\n"},{"path":"registry/geturui/ui/menu-core.tsx","type":"registry:ui","target":"components/ui/menu-core.tsx","content":"\"use client\";\n\n/**\n * Shared menu engine for ContextMenu and Dropdown: entries, APG menu keyboard support (arrows, Home/End,\n * typeahead, submenus), checkbox and radio rows, sections, the gliding highlight and viewport-aware placement.\n * Internal module — import ContextMenu or Dropdown instead.\n */\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronRight } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MenuItemEntry {\n  /** \"checkbox\" and \"radio\" render menuitemcheckbox / menuitemradio rows. A plain item with `checked` set is a checkbox too. */\n  type?: \"item\" | \"checkbox\" | \"radio\";\n  label: string;\n  icon?: React.ReactNode;\n  /** Second line under the label. */\n  description?: string;\n  /** Shown right-aligned, e.g. \"⌘D\". Display only — bind the keys yourself. */\n  shortcut?: string;\n  /** Trailing node such as a Badge or a count. */\n  badge?: React.ReactNode;\n  onSelect?: () => void;\n  disabled?: boolean;\n  /** Destructive styling. */\n  danger?: boolean;\n  checked?: boolean;\n  /** Nested entries open as a submenu beside the item. */\n  items?: MenuEntry[];\n  /** Extra words a search box matches against. */\n  keywords?: string[];\n  /** Override whether choosing this row closes the menu. */\n  closeOnSelect?: boolean;\n}\n\nexport type MenuEntry = MenuItemEntry | { type: \"separator\" } | { type: \"label\"; label: string };\n\nexport type MenuPlacement =\n  | { kind: \"point\"; x: number; y: number }\n  | { kind: \"anchor\"; anchor: HTMLElement; side: \"top\" | \"bottom\"; align: \"start\" | \"center\" | \"end\"; offset: number }\n  | { kind: \"sub\"; flip: boolean };\n\nexport const isMenuItem = (e: MenuEntry): e is MenuItemEntry =>\n  e.type === undefined || e.type === \"item\" || e.type === \"checkbox\" || e.type === \"radio\";\n\nconst SUB_WIDTH = 216;\nconst EDGE = 8;\n\nfunction roleOf(e: MenuItemEntry) {\n  if (e.type === \"radio\") return \"menuitemradio\" as const;\n  if (e.type === \"checkbox\" || e.checked !== undefined) return \"menuitemcheckbox\" as const;\n  return \"menuitem\" as const;\n}\n\n/** Filter entries by a query (label, description, keywords), keeping submenu parents with matching children and tidying separators and section labels. */\nexport function filterMenuEntries(entries: MenuEntry[], query: string): MenuEntry[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return entries;\n  const kept: MenuEntry[] = [];\n  for (const e of entries) {\n    if (!isMenuItem(e)) {\n      kept.push(e);\n      continue;\n    }\n    const hay = [e.label, e.description ?? \"\", ...(e.keywords ?? [])].join(\" \").toLowerCase();\n    if (hay.includes(q)) kept.push(e);\n    else if (e.items?.length) {\n      const sub = filterMenuEntries(e.items, q);\n      if (sub.some(isMenuItem)) kept.push({ ...e, items: sub });\n    }\n  }\n  // Drop section labels with no items after them, and leading / trailing / doubled separators.\n  const out: MenuEntry[] = [];\n  kept.forEach((e, i) => {\n    if (e.type === \"label\") {\n      const next = kept[i + 1];\n      if (!next || !isMenuItem(next)) return;\n    }\n    if (e.type === \"separator\") {\n      const prev = out[out.length - 1];\n      const hasNext = kept.slice(i + 1).some(isMenuItem);\n      if (!prev || prev.type === \"separator\" || !hasNext) return;\n    }\n    out.push(e);\n  });\n  return out;\n}\n\nexport interface MenuPanelProps {\n  entries: MenuEntry[];\n  level?: number;\n  placement: MenuPlacement;\n  /** Where focus goes on mount: the first or last item (keyboard), the menu (pointer) or nowhere (hover-opened submenu). */\n  focusOnOpen: \"first\" | \"last\" | \"panel\" | \"none\";\n  /** Changing this re-applies focusOnOpen, e.g. ArrowRight on an item whose submenu is already open from hover. */\n  focusToken?: number;\n  onCloseAll: (restore: boolean) => void;\n  /** Submenu only: close this level and return focus to the parent item. */\n  onBack?: () => void;\n  /** ArrowUp on the first row calls this instead of wrapping (e.g. back to a search box). */\n  onExitTop?: () => void;\n  /** Replaces typeahead: printable keys and Backspace are handed here (e.g. to a search box). */\n  onPrintableKey?: (key: string) => void;\n  /** Whether checkbox / radio rows close the menu when chosen. */\n  closeOnCheck?: boolean;\n  /** Root only: content above the list (account card, search box). */\n  header?: React.ReactNode;\n  /** Root only: content below the list. */\n  footer?: React.ReactNode;\n  /** Shown when there are no entries (e.g. no search results). */\n  empty?: React.ReactNode;\n  /** Cap the list height and scroll it. Leave off when rows have submenus. */\n  scrollable?: boolean;\n  menuId?: string;\n  labelledBy?: string;\n  menuClassName?: string;\n  /** Extra classes for the root surface only. */\n  className?: string;\n  /** Root surface width in px (clamped to the viewport). */\n  width?: number;\n}\n\n/** The menu surface: renders entries, owns keyboard navigation, the highlight and nested submenus. */\nexport function MenuPanel({\n  entries,\n  level = 0,\n  placement,\n  focusOnOpen,\n  focusToken,\n  onCloseAll,\n  onBack,\n  onExitTop,\n  onPrintableKey,\n  closeOnCheck = true,\n  header,\n  footer,\n  empty,\n  scrollable,\n  menuId,\n  labelledBy,\n  menuClassName,\n  className,\n  width,\n  ref: forwarded,\n}: MenuPanelProps & { ref?: React.Ref<HTMLDivElement> }) {\n  const reduce = useReducedMotion();\n  const uid = React.useId();\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [active, setActive] = React.useState(-1);\n  const [sub, setSub] = React.useState<{ index: number; keyboard: boolean; flip: boolean; token: number } | null>(null);\n  const hoverTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const typeahead = React.useRef<{ buffer: string; timer: ReturnType<typeof setTimeout> | null }>({ buffer: \"\", timer: null });\n  const sideRef = React.useRef<\"top\" | \"bottom\" | null>(null);\n  React.useImperativeHandle(forwarded, () => surfaceRef.current as HTMLDivElement);\n\n  const enabled = entries.map((e, i) => (isMenuItem(e) && !e.disabled ? i : -1)).filter((i) => i !== -1);\n\n  // Entries can change while open (search filter, toggled checkbox). Drop a highlight or submenu that no longer points at a live row.\n  const activeEntry = entries[active];\n  if (active !== -1 && !(activeEntry && isMenuItem(activeEntry) && !activeEntry.disabled)) setActive(-1);\n  const subEntry = sub ? entries[sub.index] : undefined;\n  if (sub && !(subEntry && isMenuItem(subEntry) && subEntry.items?.length)) setSub(null);\n\n  // Placement: keep the root inside the viewport (flip at the pointer, or flip / shift around an anchor) and nudge submenus up if they overflow.\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    if (!el) return;\n    if (placement.kind === \"point\") {\n      const r = el.getBoundingClientRect();\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const left = placement.x + r.width > vw - EDGE ? Math.max(EDGE, placement.x - r.width) : placement.x;\n      const top = placement.y + r.height > vh - EDGE ? Math.max(EDGE, placement.y - r.height) : placement.y;\n      el.style.left = `${left}px`;\n      el.style.top = `${top}px`;\n      el.style.transformOrigin = `${left < placement.x ? \"100%\" : \"0%\"} ${top < placement.y ? \"100%\" : \"0%\"}`;\n      return;\n    }\n    if (placement.kind === \"sub\") {\n      const r = el.getBoundingClientRect();\n      const vh = window.innerHeight;\n      if (r.bottom > vh - EDGE) el.style.top = `${-4 - (r.bottom - (vh - EDGE))}px`;\n      return;\n    }\n    const { anchor, align, offset } = placement;\n    const place = () => {\n      const a = anchor.getBoundingClientRect();\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      // offsetWidth/Height ignore the entrance scale, so the first measurement is exact.\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const below = vh - a.bottom - offset - EDGE;\n      const above = a.top - offset - EDGE;\n      if (!sideRef.current) {\n        sideRef.current =\n          placement.side === \"bottom\" ? (h > below && above > below ? \"top\" : \"bottom\") : h > above && below > above ? \"bottom\" : \"top\";\n      }\n      const side = sideRef.current;\n      const raw = align === \"start\" ? a.left : align === \"end\" ? a.right - w : a.left + a.width / 2 - w / 2;\n      const left = Math.round(Math.min(Math.max(EDGE, raw), Math.max(EDGE, vw - w - EDGE)));\n      el.style.left = `${left}px`;\n      if (side === \"bottom\") {\n        el.style.top = `${Math.round(a.bottom + offset)}px`;\n        el.style.bottom = \"auto\";\n      } else {\n        el.style.top = \"auto\";\n        el.style.bottom = `${Math.round(vh - a.top + offset)}px`;\n      }\n      // Room left for the scrolling list once the header / footer are accounted for.\n      const chrome = h - (listRef.current?.offsetHeight ?? 0);\n      el.style.setProperty(\"--menu-available-height\", `${Math.max(140, Math.round((side === \"bottom\" ? below : above) - chrome))}px`);\n      const ox = Math.round(Math.min(Math.max(a.left + a.width / 2 - left, 12), w - 12));\n      el.style.transformOrigin = `${ox}px ${side === \"bottom\" ? \"0%\" : \"100%\"}`;\n    };\n    place();\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    return () => {\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n    };\n  }, [placement]);\n\n  React.useEffect(() => {\n    const t = requestAnimationFrame(() => {\n      if (focusOnOpen === \"first\" && enabled.length) focusItem(enabled[0]);\n      else if (focusOnOpen === \"last\" && enabled.length) focusItem(enabled[enabled.length - 1]);\n      else if (focusOnOpen === \"panel\") listRef.current?.focus({ preventScroll: true });\n    });\n    return () => cancelAnimationFrame(t);\n    // On mount, and again when the parent asks (focusToken).\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [focusToken]);\n\n  React.useEffect(() => {\n    const ta = typeahead.current;\n    return () => {\n      if (hoverTimer.current) clearTimeout(hoverTimer.current);\n      if (ta.timer) clearTimeout(ta.timer);\n    };\n  }, []);\n\n  function focusItem(i: number) {\n    setActive(i);\n    itemRefs.current[i]?.focus({ preventScroll: true });\n  }\n\n  function openSub(i: number, viaKeyboard: boolean) {\n    const r = surfaceRef.current?.getBoundingClientRect();\n    const f = r ? r.right + SUB_WIDTH > window.innerWidth - EDGE : false;\n    setSub((prev) =>\n      prev?.index === i\n        ? { ...prev, keyboard: viaKeyboard || prev.keyboard, token: viaKeyboard ? prev.token + 1 : prev.token }\n        : { index: i, keyboard: viaKeyboard, flip: f, token: 0 },\n    );\n  }\n\n  function activate(i: number, viaKeyboard: boolean) {\n    const e = entries[i];\n    if (!e || !isMenuItem(e) || e.disabled) return;\n    if (e.items?.length) return openSub(i, viaKeyboard);\n    e.onSelect?.();\n    const checkable = roleOf(e) !== \"menuitem\";\n    if (e.closeOnSelect ?? (checkable ? closeOnCheck : true)) onCloseAll(true);\n  }\n\n  function runTypeahead(key: string) {\n    const ta = typeahead.current;\n    if (ta.timer) clearTimeout(ta.timer);\n    ta.buffer += key.toLowerCase();\n    ta.timer = setTimeout(() => {\n      ta.buffer = \"\";\n    }, 500);\n    const pos = enabled.indexOf(active);\n    // A single repeated letter cycles through matches; a longer buffer refines from the current row.\n    const repeat = ta.buffer.length > 1 && ta.buffer.split(\"\").every((c) => c === ta.buffer[0]);\n    const needle = repeat ? ta.buffer[0] : ta.buffer;\n    const start = needle.length === 1 ? pos + 1 : Math.max(pos, 0);\n    for (let k = 0; k < enabled.length; k++) {\n      const idx = enabled[(start + k) % enabled.length];\n      const e = entries[idx];\n      if (isMenuItem(e) && e.label.toLowerCase().startsWith(needle)) {\n        focusItem(idx);\n        return;\n      }\n    }\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    const pos = enabled.indexOf(active);\n    switch (e.key) {\n      case \"ArrowDown\":\n        if (!enabled.length) return;\n        focusItem(enabled[(pos + 1) % enabled.length]);\n        break;\n      case \"ArrowUp\":\n        if (pos <= 0 && onExitTop) {\n          setActive(-1);\n          onExitTop();\n          break;\n        }\n        if (!enabled.length) return;\n        focusItem(enabled[pos <= 0 ? enabled.length - 1 : pos - 1]);\n        break;\n      case \"Home\":\n        if (enabled.length) focusItem(enabled[0]);\n        break;\n      case \"End\":\n        if (enabled.length) focusItem(enabled[enabled.length - 1]);\n        break;\n      case \"ArrowRight\": {\n        const entry = entries[active];\n        if (entry && isMenuItem(entry) && entry.items?.length) openSub(active, true);\n        else return;\n        break;\n      }\n      case \"ArrowLeft\":\n        if (!onBack) return;\n        onBack();\n        break;\n      case \"Escape\":\n        if (onBack) onBack();\n        else onCloseAll(true);\n        break;\n      case \"Enter\":\n      case \" \":\n        if (active >= 0) activate(active, true);\n        break;\n      case \"Tab\":\n        onCloseAll(false);\n        break;\n      default: {\n        const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;\n        if (onPrintableKey && (printable || e.key === \"Backspace\")) onPrintableKey(e.key);\n        else if (printable && e.key !== \" \") runTypeahead(e.key);\n        else return;\n      }\n    }\n    e.preventDefault();\n    e.stopPropagation();\n  }\n\n  let itemCount = 0;\n\n  function renderItem(entry: MenuItemEntry, i: number) {\n    const n = itemCount++;\n    const on = active === i;\n    const hasSub = !!entry.items?.length;\n    const subOpen = sub?.index === i;\n    const role = roleOf(entry);\n    const checkable = role !== \"menuitem\";\n    const indicator =\n      role === \"menuitemradio\" ? (\n        <span className={cn(\"grid size-3.5 place-items-center rounded-full border\", entry.checked ? \"border-volt-ink\" : \"border-border-strong\")}>\n          {entry.checked && <span className=\"size-1.5 rounded-full bg-volt-ink\" />}\n        </span>\n      ) : entry.type === \"checkbox\" ? (\n        <span\n          className={cn(\n            \"grid size-3.5 place-items-center rounded-[4px] border\",\n            entry.checked ? \"border-volt bg-volt text-on-volt\" : \"border-border-strong\",\n          )}\n        >\n          {entry.checked && <Check className=\"!size-2.5\" strokeWidth={3.5} />}\n        </span>\n      ) : entry.checked ? (\n        <Check className=\"text-volt-ink\" strokeWidth={2.5} />\n      ) : null;\n    // Checkable rows with their own icon keep it and move the indicator to the end.\n    const leading = checkable && !entry.icon ? indicator : entry.icon;\n    const trailingIndicator = checkable && entry.icon ? indicator : null;\n    return (\n      <motion.div\n        key={`${entry.label}-${i}`}\n        ref={(el) => {\n          itemRefs.current[i] = el;\n        }}\n        role={role}\n        aria-checked={checkable ? !!entry.checked : undefined}\n        aria-disabled={entry.disabled || undefined}\n        aria-haspopup={hasSub ? \"menu\" : undefined}\n        aria-expanded={hasSub ? subOpen : undefined}\n        data-menu-item=\"\"\n        tabIndex={-1}\n        initial={reduce ? false : { opacity: 0, y: -4 }}\n        animate={{ opacity: entry.disabled ? 0.4 : 1, y: 0 }}\n        transition={{ delay: reduce ? 0 : 0.02 + n * 0.018, duration: 0.18 }}\n        onFocus={() => {\n          if (!entry.disabled && active !== i) setActive(i);\n        }}\n        onPointerMove={() => {\n          if (entry.disabled || active === i) return;\n          focusItem(i);\n          if (hoverTimer.current) clearTimeout(hoverTimer.current);\n          hoverTimer.current = setTimeout(() => {\n            if (hasSub) openSub(i, false);\n            else setSub(null);\n          }, 110);\n        }}\n        aria-label={entry.description ? undefined : entry.label}\n        onClick={(e) => {\n          e.stopPropagation();\n          activate(i, false);\n        }}\n        className={cn(\n          \"relative flex cursor-default select-none gap-2.5 rounded-sm px-2.5 outline-none\",\n          entry.description ? \"items-start py-2\" : \"h-8 items-center\",\n          entry.danger && !entry.disabled ? \"text-danger\" : \"text-ink\",\n        )}\n      >\n        {(on || subOpen) && (\n          <motion.span\n            layoutId={`${uid}-highlight`}\n            aria-hidden\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 40 }}\n            className={cn(\"absolute inset-0 rounded-sm\", entry.danger ? \"bg-danger/12\" : \"bg-surface-hover\")}\n          />\n        )}\n        {(leading || !entry.description) && (\n          <span\n            aria-hidden\n            className={cn(\n              \"relative grid shrink-0 place-items-center text-ink-muted [&>svg]:size-4\",\n              entry.description ? \"mt-px min-h-4 min-w-4\" : \"size-4\",\n              entry.danger && \"text-danger\",\n            )}\n          >\n            {leading}\n          </span>\n        )}\n        {entry.description ? (\n          <span className=\"relative min-w-0 flex-1\">\n            <span className={cn(\"block truncate font-medium leading-5\", entry.danger && \"text-danger\")}>{entry.label}</span>\n            <span className=\"block text-[12px] leading-4 text-ink-subtle\">{entry.description}</span>\n          </span>\n        ) : (\n          <span className={cn(\"relative flex-1 truncate\", entry.danger && \"text-danger\")}>{entry.label}</span>\n        )}\n        {entry.badge && <span className={cn(\"relative flex shrink-0\", entry.description && \"mt-0.5\")}>{entry.badge}</span>}\n        {entry.shortcut && !hasSub && (\n          <kbd className={cn(\"relative font-sans text-[11.5px] tracking-[0.5px] text-ink-subtle\", entry.description && \"mt-0.5\")}>{entry.shortcut}</kbd>\n        )}\n        {trailingIndicator && (\n          <span aria-hidden className=\"relative grid size-4 shrink-0 place-items-center [&>svg]:size-4\">\n            {trailingIndicator}\n          </span>\n        )}\n        {hasSub && <ChevronRight aria-hidden className=\"relative size-3.5 text-ink-subtle\" />}\n        <AnimatePresence>\n          {subOpen && entry.items && (\n            <MenuPanel\n              key=\"sub\"\n              entries={entry.items}\n              level={level + 1}\n              placement={{ kind: \"sub\", flip: sub.flip }}\n              focusOnOpen={sub.keyboard ? \"first\" : \"none\"}\n              focusToken={sub.token}\n              onCloseAll={onCloseAll}\n              closeOnCheck={closeOnCheck}\n              onBack={() => {\n                setSub(null);\n                focusItem(i);\n              }}\n              menuClassName={menuClassName}\n            />\n          )}\n        </AnimatePresence>\n      </motion.div>\n    );\n  }\n\n  // Rows after a section label are wrapped in role=\"group\" labelled by it.\n  const nodes: React.ReactNode[] = [];\n  const open: { group: { key: string; labelId: string; children: React.ReactNode[] } | null } = { group: null };\n  const flush = () => {\n    const g = open.group;\n    if (!g) return;\n    nodes.push(\n      <div key={g.key} role=\"group\" aria-labelledby={g.labelId}>\n        {g.children}\n      </div>,\n    );\n    open.group = null;\n  };\n  for (let i = 0; i < entries.length; i++) {\n    const entry = entries[i];\n    if (entry.type === \"separator\") {\n      flush();\n      nodes.push(<div key={`sep-${i}`} role=\"separator\" className=\"mx-2 my-1 h-px bg-border\" />);\n    } else if (entry.type === \"label\") {\n      flush();\n      const labelId = `${uid}-l${i}`;\n      open.group = {\n        key: `group-${i}`,\n        labelId,\n        children: [\n          <div key=\"label\" id={labelId} className=\"px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-[0.5px] text-ink-subtle\">\n            {entry.label}\n          </div>,\n        ],\n      };\n    } else if (isMenuItem(entry)) {\n      const node = renderItem(entry, i);\n      if (open.group) open.group.children.push(node);\n      else nodes.push(node);\n    }\n  }\n  flush();\n\n  const isRoot = placement.kind !== \"sub\";\n  const flip = placement.kind === \"sub\" && placement.flip;\n\n  return (\n    <motion.div\n      ref={surfaceRef}\n      onPointerLeave={() => {\n        if (!sub) setActive(-1);\n      }}\n      initial={\n        reduce\n          ? { opacity: 0 }\n          : level === 0\n            ? { opacity: 0, scale: placement.kind === \"anchor\" ? 0.94 : 0.96, filter: \"blur(4px)\" }\n            : { opacity: 0, x: flip ? 8 : -8, filter: \"blur(4px)\" }\n      }\n      animate={{ opacity: 1, scale: 1, x: 0, filter: \"blur(0px)\" }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.1 } }\n          : level === 0\n            ? { opacity: 0, scale: 0.97, filter: \"blur(2px)\", transition: { duration: 0.12 } }\n            : { opacity: 0, x: flip ? 6 : -6, transition: { duration: 0.12 } }\n      }\n      transition={{ type: \"spring\", stiffness: 520, damping: 34, opacity: { duration: 0.14 } }}\n      style={\n        placement.kind === \"point\"\n          ? { position: \"fixed\", left: placement.x, top: placement.y }\n          : placement.kind === \"anchor\"\n            ? { position: \"fixed\", left: 0, top: 0, ...(width ? { width, maxWidth: \"calc(100vw - 16px)\" } : null) }\n            : { position: \"absolute\", top: -5, ...(flip ? { right: \"calc(100% + 4px)\" } : { left: \"calc(100% + 4px)\" }) }\n      }\n      className={cn(\n        \"z-[100] min-w-[216px] rounded-md border border-border bg-glass p-1 text-[13px] text-ink shadow-lg outline-none backdrop-blur-xl backdrop-saturate-150\",\n        isRoot && className,\n        menuClassName,\n      )}\n    >\n      {isRoot && header}\n      <div\n        ref={listRef}\n        id={menuId}\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        aria-labelledby={labelledBy}\n        tabIndex={-1}\n        onKeyDown={onKeyDown}\n        className={cn(\"outline-none\", scrollable && \"max-h-[min(26rem,var(--menu-available-height,26rem))] overflow-y-auto overscroll-contain\")}\n      >\n        {nodes}\n        {!enabled.length && !entries.some(isMenuItem) && empty}\n      </div>\n      {isRoot && footer}\n    </motion.div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","dropdown","menu","menu button","account menu","actions","search","checkbox","radio","submenu","keyboard"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/dropdown"}}