{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dropdown-menu-clean","type":"registry:ui","title":"Dropdown Menu (Clean)","description":"Actions and choices behind any trigger – icons, shortcuts, checks, red items and submenus, a gliding highlight, type-ahead and the full menu keyboard","dependencies":[],"files":[{"path":"registry/dropdown-menu-clean/dropdown-menu-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  createElement,\n  Fragment,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type FocusEvent as ReactFocusEvent,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\";\n\n/*\n * Dropdown Menu – a single self-contained file, clean style.\n * A menu of actions or choices that opens from any trigger: icons, a quiet second line, shortcuts, dividers, red\n * items for what can't be undone, checks for choices (radio or checkbox) and submenus that open to the side. One\n * soft highlight glides to the row under the pointer or the arrow keys; typing a letter jumps to the item. The menu\n * sits in the top layer, so no overflow cuts it, and it flips to whichever side has room.\n * The styles are injected below, so no extra files are needed.\n */\n\n/** An icon from Hugeicons (e.g. `import { Copy01Icon } from \"@hugeicons/core-free-icons\"`) – drawn inline */\nexport type HugeIcon = readonly (readonly [string, { readonly [key: string]: string | number }])[];\n\nexport type DropdownMenuItem = {\n  label: string;\n  /** A second line in a lighter color */\n  description?: string;\n  /** A 16px glyph before the label: an inline SVG or a Hugeicons icon object */\n  icon?: ReactNode | HugeIcon;\n  /** A brand mark (Gmail, Slack …) in place of the icon, in its own colors */\n  logo?: ReactNode;\n  /** Shows a check – the item becomes a choice (true or false; leave it out for a plain action) */\n  checked?: boolean;\n  /** How a choice is announced: one of a set (radio, the default) or on/off on its own (checkbox) */\n  type?: \"radio\" | \"checkbox\";\n  /** Red text and icon, e.g. Delete */\n  danger?: boolean;\n  /** A keyboard shortcut on the right, e.g. \"⌥⌘R\" – shown only, wire the keys up yourself */\n  shortcut?: string;\n  /** A line above this item, to group the ones after it */\n  divider?: boolean;\n  /** Shown, but can't be picked; the arrow keys skip it */\n  disabled?: boolean;\n  /** The menu stays open after a pick – for checkboxes you tick several of */\n  keepOpen?: boolean;\n  /** More items that open to the side on hover, →, Enter or Space */\n  submenu?: DropdownMenuItem[];\n  onSelect?: () => void;\n};\n\n/** Spread these on the trigger: they wire up its name, state and the click */\nexport type DropdownMenuTriggerProps = {\n  id: string;\n  \"aria-haspopup\": \"menu\";\n  \"aria-expanded\": boolean;\n  \"aria-controls\"?: string;\n  onClick: (event?: { detail?: number }) => void;\n};\n\nexport type DropdownMenuProps = {\n  /** Draws the trigger – it gets the open state, a toggle and the props to spread on it */\n  trigger: (props: { open: boolean; toggle: (event?: { detail?: number }) => void; triggerProps: DropdownMenuTriggerProps }) => ReactNode;\n  items: DropdownMenuItem[];\n  /** start: the menu's left edge on the trigger's · end: the right edges line up */\n  align?: \"start\" | \"end\";\n  /** Where it opens; it flips when there is no room */\n  side?: \"bottom\" | \"top\";\n  /** A small heading above the items – it also names the menu */\n  title?: string;\n  /** Anything above the items, e.g. who is signed in */\n  header?: ReactNode;\n  /** Makes it controlled */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Classes on the menu panel itself (className goes on the wrapper around trigger and menu) */\n  menuClassName?: string;\n  /** The menu's width – by default it fits its items (at least 168px) */\n  width?: number | string;\n  /** Shown when there are no items */\n  empty?: ReactNode;\n  /** On the wrapper around the trigger */\n  className?: string;\n  /** Names the menu when there is no title */\n  \"aria-label\"?: string;\n};\n\ntype Start = \"first\" | \"last\" | \"menu\";\n\nexport function DropdownMenu({\n  trigger,\n  items,\n  align = \"start\",\n  side = \"bottom\",\n  title,\n  header,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  menuClassName,\n  width,\n  empty = \"Nothing here yet\",\n  className,\n  \"aria-label\": ariaLabel,\n}: DropdownMenuProps) {\n  const uid = useId();\n  const triggerId = `${uid}-trigger`;\n  const menuId = `${uid}-menu`;\n  const titleId = `${uid}-title`;\n\n  const [own, setOwn] = useState(defaultOpen);\n  const open = openProp ?? own;\n  const anchor = useRef<HTMLSpanElement>(null);\n  const panel = useRef<HTMLDivElement>(null);\n  // Where the focus goes when it opens; open from the start (a demo, a restored view) leaves the focus alone\n  const start = useRef<Start>(\"first\");\n  const wasClosed = useRef(!open);\n  if (!open) wasClosed.current = true;\n  const label = useRef<string | undefined>(triggerId);\n  const list = Array.isArray(items) ? items.filter(Boolean) : [];\n\n  const setOpen = (next: boolean) => {\n    if (next === open) return;\n    if (openProp === undefined) setOwn(next);\n    onOpenChange?.(next);\n  };\n  const trig = () => triggerOf(anchor.current, panel.current);\n  /** focusBack: after the pick (unless the pick moved the focus) · \"now\": Escape and Tab – Tab then moves on from the trigger */\n  const close = (focusBack: boolean | \"now\") => {\n    if (focusBack === \"now\") trig()?.focus({ preventScroll: true });\n    setOpen(false);\n    if (focusBack === true) refocus(trig());\n  };\n  const toggle = (event?: { detail?: number }) => {\n    // A pointer click opens it with the focus on the menu itself; Enter, Space or a call from code on the first item\n    start.current = event?.detail ? \"menu\" : \"first\";\n    setOpen(!open);\n  };\n\n  // A trigger that doesn't spread triggerProps still gets a name to point at and says it opens a menu\n  useEffect(() => {\n    const t = trig();\n    if (!t) return;\n    if (!t.id) t.id = triggerId;\n    if (!t.hasAttribute(\"aria-haspopup\")) t.setAttribute(\"aria-haspopup\", \"menu\");\n    label.current = t.id;\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Into the top layer and next to the trigger, before paint; it follows on scroll and resize\n  useLayoutEffect(() => {\n    const el = panel.current;\n    if (!open || !el) return;\n    showLayer(el);\n    const update = () => anchor.current && place(el, anchor.current.getBoundingClientRect(), side, align, 6);\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(update);\n    ro?.observe(el);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      ro?.disconnect();\n      hideLayer(el);\n    };\n  }, [open, side, align]);\n\n  // The focus moves in: onto the checked item (or the first, or the last after ↑), or onto the menu after a click\n  useEffect(() => {\n    if (!open) return;\n    const menu = panel.current?.querySelector<HTMLElement>(\":scope > .ui-dmc__list\");\n    const how = start.current;\n    if (!menu || !wasClosed.current) return;\n    if (how === \"menu\") return menu.focus({ preventScroll: true });\n    const rows = itemsOf(menu);\n    const checked = rows.find((r) => r.getAttribute(\"aria-checked\") === \"true\");\n    focusRow((how === \"last\" ? rows[rows.length - 1] : (checked ?? rows[0])) ?? menu);\n  }, [open]);\n\n  // A press anywhere else closes it, without taking the focus back\n  useEffect(() => {\n    if (!open) return;\n    const down = (e: PointerEvent) => {\n      if (!anchor.current?.contains(e.target as Node)) close(false);\n    };\n    document.addEventListener(\"pointerdown\", down, true);\n    return () => document.removeEventListener(\"pointerdown\", down, true);\n  }); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // ↓ or ↑ on the closed trigger opens it, like Enter and Space do\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLSpanElement>) => {\n    if (open || (e.key !== \"ArrowDown\" && e.key !== \"ArrowUp\") || panel.current?.contains(e.target as Node)) return;\n    e.preventDefault();\n    start.current = e.key === \"ArrowUp\" ? \"last\" : \"first\";\n    setOpen(true);\n  };\n  // Tabbing away from the trigger and its menu closes it\n  const onBlur = (e: ReactFocusEvent<HTMLSpanElement>) => {\n    const to = e.relatedTarget as Node | null;\n    if (open && to && !anchor.current?.contains(to)) close(false);\n  };\n\n  const triggerProps: DropdownMenuTriggerProps = {\n    id: triggerId,\n    \"aria-haspopup\": \"menu\",\n    \"aria-expanded\": open,\n    \"aria-controls\": open ? menuId : undefined,\n    onClick: toggle,\n  };\n\n  return (\n    <span ref={anchor} className={[\"ui-dmc\", className].filter(Boolean).join(\" \")} onKeyDown={onKeyDown} onBlur={onBlur}>\n      {/* React 19 hoists this into <head> once, no matter how many menus render */}\n      <style href=\"ui-dropdown-menu-clean\" precedence=\"default\">\n        {css}\n      </style>\n      {trigger({ open, toggle, triggerProps })}\n      {open && (\n        <div ref={panel} className={[\"ui-dmc__panel\", menuClassName].filter(Boolean).join(\" \")} popover=\"manual\" data-side={side} data-align={align} style={width != null ? { width, maxWidth: \"calc(100vw - 16px)\" } : undefined}>\n          {header}\n          {title && (\n            <span className=\"ui-dmc__title\" id={titleId}>\n              {title}\n            </span>\n          )}\n          <MenuLevel\n            id={menuId}\n            items={list}\n            empty={empty}\n            labelledBy={title ? titleId : ariaLabel ? undefined : label.current}\n            label={title ? undefined : ariaLabel}\n            onPick={() => close(true)}\n            onClose={close}\n          />\n        </div>\n      )}\n    </span>\n  );\n}\n\n/* ---------- One level of the menu: the root, or a submenu ---------- */\n\ntype LevelProps = {\n  id: string;\n  items: DropdownMenuItem[];\n  empty?: ReactNode;\n  labelledBy?: string;\n  label?: string;\n  /** A pick that closes the whole menu */\n  onPick: () => void;\n  /** Escape at the root, Tab anywhere – the trigger takes the focus */\n  onClose: (focusBack: boolean | \"now\") => void;\n  /** ← or Escape in a submenu: back to the item that opened it */\n  onBack?: () => void;\n};\n\nfunction MenuLevel({ id, items, empty, labelledBy, label, onPick, onClose, onBack }: LevelProps) {\n  const uid = useId();\n  const list = useRef<HTMLDivElement>(null);\n  const [glow, setGlow] = useState<{ top: number; height: number; danger: boolean } | null>(null);\n  const [sub, setSub] = useState<{ index: number; focus: boolean } | null>(null);\n  const timer = useRef(0);\n  /** What the timer will do: open the submenu of this index, or -1 to close the open one */\n  const pending = useRef<number | null>(null);\n  const typed = useRef({ text: \"\", at: 0 });\n\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  const stopTimer = () => {\n    window.clearTimeout(timer.current);\n    pending.current = null;\n  };\n  const later = (fn: () => void, ms: number, what: number) => {\n    window.clearTimeout(timer.current);\n    pending.current = what;\n    timer.current = window.setTimeout(() => {\n      pending.current = null;\n      fn();\n    }, ms);\n  };\n\n  const rowAt = (i: number) => list.current?.querySelector<HTMLElement>(`:scope > [data-index=\"${i}\"]`) ?? null;\n  const openSub = (index: number, focus: boolean) => {\n    stopTimer();\n    setSub((s) => (s?.index === index ? (focus ? { index, focus } : s) : { index, focus }));\n    // Already open from a hover: the keyboard steps into it right away\n    if (focus && sub?.index === index) focusRow(itemsOf(document.getElementById(`${uid}-sub`)?.querySelector(\".ui-dmc__list\") ?? null)[0]);\n  };\n  const back = () => {\n    const at = sub?.index;\n    setSub(null);\n    if (at != null) focusRow(rowAt(at));\n  };\n\n  const activate = (i: number, keyboard: boolean) => {\n    const item = items[i];\n    if (!item || item.disabled) return;\n    if (item.submenu) {\n      if (!keyboard && sub?.index === i && !sub.focus) setSub(null);\n      else openSub(i, keyboard);\n      return;\n    }\n    item.onSelect?.();\n    if (!item.keepOpen) onPick();\n  };\n\n  // Typing letters jumps to the next item that starts with them\n  const typeahead = (key: string) => {\n    const now = Date.now();\n    typed.current.text = now - typed.current.at > 700 ? key : typed.current.text + key;\n    typed.current.at = now;\n    const q = typed.current.text.toLowerCase();\n    const rows = itemsOf(list.current);\n    const at = rows.indexOf(document.activeElement as HTMLElement);\n    const from = q.length === 1 ? at + 1 : Math.max(at, 0);\n    for (let k = 0; k < rows.length; k++) {\n      const row = rows[(from + k) % rows.length];\n      if ((items[Number(row.dataset.index)]?.label ?? \"\").toLowerCase().startsWith(q)) return focusRow(row);\n    }\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    const rows = itemsOf(list.current);\n    const at = rows.indexOf(document.activeElement as HTMLElement);\n    const index = at >= 0 ? Number(rows[at].dataset.index) : -1;\n    const item = index >= 0 ? items[index] : undefined;\n    const stop = () => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n    const go = (i: number) => {\n      stop();\n      // Moving on from an item closes a submenu the pointer had opened\n      if (sub) setSub(null);\n      if (rows.length) focusRow(rows[(i + rows.length) % rows.length]);\n    };\n    switch (e.key) {\n      case \"ArrowDown\":\n        return go(at + 1);\n      case \"ArrowUp\":\n        return go(at < 0 ? -1 : at - 1);\n      case \"Home\":\n      case \"PageUp\":\n        return go(0);\n      case \"End\":\n      case \"PageDown\":\n        return go(-1);\n      case \"ArrowRight\":\n        if (item?.submenu) {\n          stop();\n          openSub(index, true);\n        }\n        return;\n      case \"ArrowLeft\":\n        if (onBack) {\n          stop();\n          onBack();\n        }\n        return;\n      case \"Escape\":\n        stop();\n        return onBack ? onBack() : onClose(\"now\");\n      case \"Enter\":\n      case \" \":\n        stop();\n        if (e.key === \" \" && Date.now() - typed.current.at < 700) return typeahead(\" \");\n        return activate(index, true);\n      case \"Tab\":\n        // The focus goes to the trigger first, so Tab moves on from there\n        return onClose(\"now\");\n      default:\n        if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n          stop();\n          typeahead(e.key);\n        }\n    }\n  };\n\n  // The highlight follows the focused row – the pointer focuses the row it is on\n  const onFocus = (e: ReactFocusEvent<HTMLDivElement>) => {\n    const row = e.target as HTMLElement;\n    if (row === list.current || row.parentElement !== list.current) return setGlow(null);\n    setGlow({ top: row.offsetTop, height: row.offsetHeight, danger: row.hasAttribute(\"data-danger\") });\n  };\n  const onBlur = (e: ReactFocusEvent<HTMLDivElement>) => {\n    const to = e.relatedTarget as HTMLElement | null;\n    if (!to || to.parentElement !== list.current) setGlow(null);\n  };\n\n  const subItems = sub ? items[sub.index]?.submenu : undefined;\n\n  return (\n    <>\n      <div\n        ref={list}\n        id={id}\n        className=\"ui-dmc__list\"\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        aria-labelledby={labelledBy}\n        aria-label={label}\n        tabIndex={-1}\n        onKeyDown={onKeyDown}\n        onFocus={onFocus}\n        onBlur={onBlur}\n        onPointerLeave={() => {\n          stopTimer();\n          // The pointer left: the highlight goes, the keys start again from the menu\n          if (list.current?.contains(document.activeElement) && document.activeElement !== list.current) list.current?.focus({ preventScroll: true });\n        }}\n      >\n        {glow && <span className=\"ui-dmc__glow\" aria-hidden=\"true\" data-danger={glow.danger || undefined} style={{ transform: `translateY(${glow.top}px)`, height: glow.height }} />}\n        {items.length === 0 && <span className=\"ui-dmc__empty\">{empty}</span>}\n        {items.map((item, i) => {\n          const choice = item.checked !== undefined && !item.submenu;\n          const role = !choice ? \"menuitem\" : item.type === \"checkbox\" ? \"menuitemcheckbox\" : \"menuitemradio\";\n          const isOpen = sub?.index === i;\n          return (\n            <Fragment key={`${i}-${item.label}`}>\n              {item.divider && i > 0 && <span className=\"ui-dmc__sep\" role=\"separator\" />}\n              <div\n                className=\"ui-dmc__item\"\n                role={role}\n                tabIndex={-1}\n                data-index={i}\n                aria-checked={choice ? Boolean(item.checked) : undefined}\n                aria-disabled={item.disabled || undefined}\n                aria-haspopup={item.submenu ? \"menu\" : undefined}\n                aria-expanded={item.submenu ? isOpen : undefined}\n                aria-controls={item.submenu && isOpen ? `${uid}-sub-list` : undefined}\n                data-danger={item.danger || undefined}\n                data-rich={item.description ? \"\" : undefined}\n                data-open={isOpen || undefined}\n                onPointerDown={(e) => e.preventDefault()}\n                onPointerMove={(e) => {\n                  const row = e.currentTarget;\n                  if (item.disabled) {\n                    if (document.activeElement !== list.current) list.current?.focus({ preventScroll: true });\n                    return;\n                  }\n                  if (document.activeElement !== row) row.focus({ preventScroll: true });\n                  // Rest on an item with a submenu to open it; on another item the open one closes after a beat\n                  if (item.submenu) {\n                    if (isOpen) stopTimer();\n                    else if (pending.current !== i) later(() => openSub(i, false), 90, i);\n                  } else if (!sub) stopTimer();\n                  else if (pending.current !== -1) later(() => setSub(null), 220, -1);\n                }}\n                onClick={(e) => activate(i, e.detail === 0)}\n              >\n                {(item.logo || item.icon) && (\n                  <span className=\"ui-dmc__icon\" data-logo={item.logo ? \"\" : undefined} aria-hidden=\"true\">\n                    {item.logo ?? <Glyph icon={item.icon} />}\n                  </span>\n                )}\n                <span className=\"ui-dmc__label\">\n                  <span className=\"ui-dmc__text\" title={item.label}>\n                    {item.label}\n                  </span>\n                  {item.description && <span className=\"ui-dmc__desc\" title={item.description}>{item.description}</span>}\n                </span>\n                {item.shortcut && <kbd className=\"ui-dmc__kbd\">{item.shortcut}</kbd>}\n                {item.submenu && (\n                  <span className=\"ui-dmc__more\" aria-hidden=\"true\">\n                    <ChevronIcon />\n                  </span>\n                )}\n                {choice && (\n                  <span className=\"ui-dmc__check\" aria-hidden=\"true\">\n                    {item.checked && <CheckIcon />}\n                  </span>\n                )}\n              </div>\n            </Fragment>\n          );\n        })}\n      </div>\n      {sub && subItems && (\n        <SubMenu\n          id={`${uid}-sub`}\n          listId={`${uid}-sub-list`}\n          row={() => rowAt(sub.index)}\n          items={subItems}\n          focus={sub.focus}\n          label={items[sub.index]?.label}\n          onEnter={() => stopTimer()}\n          onBack={back}\n          onPick={onPick}\n          onClose={onClose}\n        />\n      )}\n    </>\n  );\n}\n\n/** A submenu: its own small panel in the top layer, beside the item that opened it (or on the other side) */\nfunction SubMenu({\n  id,\n  listId,\n  row,\n  items,\n  focus,\n  label,\n  onEnter,\n  onBack,\n  onPick,\n  onClose,\n}: {\n  id: string;\n  listId: string;\n  row: () => HTMLElement | null;\n  items: DropdownMenuItem[];\n  focus: boolean;\n  label?: string;\n  onEnter: () => void;\n  onBack: () => void;\n  onPick: () => void;\n  onClose: (focusBack: boolean | \"now\") => void;\n}) {\n  const panel = useRef<HTMLDivElement>(null);\n\n  useLayoutEffect(() => {\n    const el = panel.current;\n    if (!el) return;\n    showLayer(el);\n    const update = () => {\n      const r = row()?.getBoundingClientRect();\n      // Its first item lines up with the item that opened it: the panel's 4px padding sits above it\n      if (r) place(el, new DOMRect(r.left - 4, r.top - 4, r.width + 8, r.height + 8), \"right\", \"start\", 4);\n    };\n    update();\n    window.addEventListener(\"resize\", update);\n    window.addEventListener(\"scroll\", update, true);\n    return () => {\n      window.removeEventListener(\"resize\", update);\n      window.removeEventListener(\"scroll\", update, true);\n      hideLayer(el);\n    };\n  }, []); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (focus) focusRow(itemsOf(panel.current?.querySelector(\".ui-dmc__list\") ?? null)[0]);\n  }, [focus]);\n\n  return (\n    <div ref={panel} id={id} className=\"ui-dmc__panel\" data-sub=\"\" popover=\"manual\" onPointerEnter={onEnter}>\n      <MenuLevel id={listId} items={items} label={label} onPick={onPick} onClose={onClose} onBack={onBack} />\n    </div>\n  );\n}\n\n/* ---------- Helpers ---------- */\n\n/** The rows of one level that can be focused – not separators, not disabled ones, not a submenu's */\nconst itemsOf = (menu: Element | null) =>\n  menu ? [...menu.querySelectorAll<HTMLElement>(':scope > [role^=\"menuitem\"]:not([aria-disabled=\"true\"])')] : [];\n\nfunction focusRow(row: HTMLElement | null | undefined) {\n  if (!row) return;\n  row.focus({ preventScroll: true });\n  row.scrollIntoView?.({ block: \"nearest\" });\n}\n\n/** The trigger: the anchor's first control that isn't inside the menu */\nfunction triggerOf(anchor: HTMLElement | null, panel: HTMLElement | null) {\n  if (!anchor) return null;\n  const all = anchor.querySelectorAll<HTMLElement>(\"button, [href], input, [tabindex]\");\n  for (const el of all) if (!panel?.contains(el)) return el;\n  return null;\n}\n\n/** After a pick the menu is gone – the trigger takes the focus back, unless the pick moved it somewhere (a rename field) */\nconst refocus = (trigger: HTMLElement | null) =>\n  window.setTimeout(() => {\n    const at = document.activeElement;\n    if (trigger?.isConnected && (!at || at === document.body)) trigger.focus({ preventScroll: true });\n  });\n\n/** The top layer, so no overflow cuts it and no z-index fights it */\nfunction showLayer(el: HTMLElement) {\n  if (\"showPopover\" in el && !el.matches(\":popover-open\")) el.showPopover();\n}\nfunction hideLayer(el: HTMLElement) {\n  if (\"hidePopover\" in el && el.isConnected && el.matches(\":popover-open\")) el.hidePopover();\n}\n\n/**\n * Puts a fixed panel next to a rectangle: on the preferred side, or the other one when that has more room; the\n * edge it lines up with switches when it would stick out, and it is pushed inside the window as a last resort.\n * Too tall for the room it gets: it scrolls.\n */\nfunction place(el: HTMLElement, r: DOMRect, side: \"top\" | \"bottom\" | \"left\" | \"right\", align: \"start\" | \"end\", gap: number) {\n  const pad = 8;\n  // The trigger's width, for a menu that should be at least as wide: min-width: var(--ui-dmc-trigger-w)\n  el.style.setProperty(\"--ui-dmc-trigger-w\", `${Math.round(r.width)}px`);\n  const vw = document.documentElement.clientWidth || window.innerWidth;\n  const vh = window.innerHeight;\n  el.style.maxHeight = \"\";\n  const w = el.offsetWidth;\n  const h = el.offsetHeight;\n  const room = { bottom: vh - r.bottom - gap - pad, top: r.top - gap - pad, right: vw - r.right - gap - pad, left: r.left - gap - pad };\n  const flip = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" } as const;\n  let s: keyof typeof room = side;\n  let a = align;\n  let top: number;\n  let left: number;\n  const vertical = s === \"top\" || s === \"bottom\";\n  if (room[s] < (vertical ? h : w) && room[flip[s]] > room[s]) s = flip[s];\n  if (!vertical && room[s] < w) {\n    // No room beside it on a narrow screen: under the row, a little indented\n    s = \"bottom\";\n    top = r.bottom - 2;\n    left = Math.min(Math.max(pad, r.left + 16), vw - pad - w);\n    el.style.maxHeight = `${Math.max(120, vh - top - pad)}px`;\n  } else if (s === \"top\" || s === \"bottom\") {\n    const fit = Math.min(h, Math.max(120, room[s]));\n    top = s === \"bottom\" ? r.bottom + gap : r.top - gap - fit;\n    left = a === \"start\" ? r.left : r.right - w;\n    if (a === \"start\" && left + w > vw - pad && r.right - w >= pad) (a = \"end\"), (left = r.right - w);\n    else if (a === \"end\" && left < pad && r.left + w <= vw - pad) (a = \"start\"), (left = r.left);\n    left = Math.min(Math.max(pad, left), vw - pad - w);\n    if (h > fit) el.style.maxHeight = `${fit}px`;\n  } else {\n    left = s === \"right\" ? r.right + gap : r.left - gap - w;\n    top = a === \"start\" ? r.top : r.bottom - h;\n    if (top + h > vh - pad) top = Math.max(pad, vh - pad - h);\n    if (top < pad) top = pad;\n    if (h > vh - 2 * pad) el.style.maxHeight = `${vh - 2 * pad}px`;\n  }\n  el.style.top = `${Math.round(top)}px`;\n  el.style.left = `${Math.round(left)}px`;\n  el.dataset.side = s;\n  el.dataset.align = a;\n}\n\nconst isHugeIcon = (icon: unknown): icon is HugeIcon =>\n  Array.isArray(icon) && icon.length > 0 && icon.every((part) => Array.isArray(part) && typeof part[0] === \"string\");\n\n/** Draws a Hugeicons icon object as an inline SVG; anything else is shown as it is */\nfunction Glyph({ icon }: { icon: ReactNode | HugeIcon }) {\n  if (!isHugeIcon(icon)) return <>{icon}</>;\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" focusable=\"false\">\n      {icon.map(([tag, { key, ...attrs }], i) => createElement(tag, { ...attrs, key: String(key ?? i) }))}\n    </svg>\n  );\n}\n\n/* Icons: Hugeicons Free (MIT, © Hugeicons, hugeicons.com) */\nfunction ChevronIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M9 6s6 4.42 6 6-6 6-6 6\" />\n    </svg>\n  );\n}\nfunction CheckIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path className=\"ui-dmc__tick\" d=\"M5 14 8.5 17.5 19 6.5\" pathLength={1} />\n    </svg>\n  );\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-dmc (see Theming in the docs) */\n.ui-dmc {\n  --ui-dmc-surface: var(--aui-overlay, var(--aui-surface, #ffffff));\n  --ui-dmc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-dmc-text: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-dmc-icon: var(--aui-muted, rgba(0, 0, 0, 0.45));\n  --ui-dmc-faint: var(--aui-placeholder, rgba(0, 0, 0, 0.27));\n  --ui-dmc-line: var(--aui-border, rgba(0, 0, 0, 0.06));\n  --ui-dmc-glow: rgba(0, 0, 0, 0.035);\n  --ui-dmc-danger: var(--aui-red, #f04438);\n  --ui-dmc-danger-glow: color-mix(in srgb, var(--aui-red, #f04438) 8%, transparent);\n  --ui-dmc-shadow: var(--aui-shadow-popover, 0 0 0 0.5px var(--aui-border-strong, rgba(0, 0, 0, 0.1)), 0 12px 32px -8px rgba(0, 0, 0, 0.16));\n  --ui-dmc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-dmc-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  --ui-dmc-radius: calc(var(--aui-radius-lg, 10px) + 2px);\n  --ui-dmc-row-radius: calc(var(--aui-radius-sm, 5px) + 3px);\n\n  position: relative;\n  display: inline-flex;\n  min-width: 0;\n}\n\n/* The menu: a white card in the top layer, a 0.5px edge and a soft drop */\n.ui-dmc__panel {\n  position: fixed;\n  inset: auto;\n  z-index: 1000;\n  display: flex;\n  flex-direction: column;\n  box-sizing: border-box;\n  min-width: 168px;\n  /* Fits its content up to 320px – long items shorten with … (full text on hover) instead of stretching it; the width prop lifts the cap */\n  max-width: min(320px, calc(100vw - 16px));\n  max-height: calc(100vh - 16px);\n  margin: 0;\n  padding: 4px;\n  border: 0;\n  border-radius: var(--ui-dmc-radius);\n  background: var(--ui-dmc-surface);\n  box-shadow: var(--ui-dmc-shadow);\n  color: var(--ui-dmc-ink);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13px;\n  line-height: 20px;\n  font-weight: 400;\n  text-align: start;\n  overflow: visible;\n  -webkit-font-smoothing: antialiased;\n  transform-origin: top left;\n  animation: ui-dmc-in 220ms var(--ui-dmc-ease) both;\n}\n.ui-dmc__panel::backdrop { display: none; }\n.ui-dmc__panel[data-align=\"end\"] { transform-origin: top right; }\n.ui-dmc__panel[data-side=\"top\"] { transform-origin: bottom left; animation-name: ui-dmc-in-up; }\n.ui-dmc__panel[data-side=\"top\"][data-align=\"end\"] { transform-origin: bottom right; }\n.ui-dmc__panel[data-side=\"right\"] { transform-origin: top left; animation-name: ui-dmc-in-side; animation-duration: 160ms; }\n.ui-dmc__panel[data-side=\"left\"] { transform-origin: top right; animation-name: ui-dmc-in-side; animation-duration: 160ms; }\n\n.ui-dmc__title { display: block; flex: none; padding: 6px 10px 4px; font-size: 12px; line-height: 16px; font-weight: 500; color: var(--ui-dmc-faint); }\n\n/* The rows; a long menu scrolls inside the card */\n.ui-dmc__list {\n  position: relative;\n  display: grid;\n  grid-template-columns: minmax(0, 1fr);\n  align-content: start;\n  gap: 1px;\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scrollbar-width: thin;\n}\n.ui-dmc__list:focus { outline: none; }\n\n/* One soft highlight that glides between the rows */\n.ui-dmc__glow {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  border-radius: var(--ui-dmc-row-radius);\n  background: var(--ui-dmc-glow);\n  pointer-events: none;\n  transition: transform 180ms var(--ui-dmc-ease), height 180ms var(--ui-dmc-ease), background-color 120ms ease;\n}\n.ui-dmc__glow[data-danger] { background: var(--ui-dmc-danger-glow); }\n\n.ui-dmc__item {\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: 9px;\n  box-sizing: border-box;\n  min-width: 0;\n  min-height: 32px;\n  padding: 0 10px;\n  border-radius: var(--ui-dmc-row-radius);\n  color: var(--ui-dmc-text);\n  white-space: nowrap;\n  cursor: pointer;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: color 120ms ease;\n}\n.ui-dmc__item[data-rich] { min-height: 44px; padding: 6px 10px; }\n.ui-dmc__item:focus { outline: none; color: var(--ui-dmc-ink); }\n.ui-dmc__item:focus-visible { outline: 2px solid var(--ui-dmc-focus); outline-offset: -2px; }\n/* The item whose submenu is open keeps a highlight while the focus is in the submenu */\n.ui-dmc__item[data-open] { color: var(--ui-dmc-ink); }\n.ui-dmc__item[data-open]:not(:focus) { background: var(--ui-dmc-glow); }\n.ui-dmc__item[aria-checked=\"true\"] { color: var(--ui-dmc-ink); font-weight: 500; }\n.ui-dmc__item[data-danger], .ui-dmc__item[data-danger]:focus { color: var(--ui-dmc-danger); }\n.ui-dmc__item[aria-disabled=\"true\"] { color: var(--ui-dmc-faint); cursor: default; }\n\n.ui-dmc__icon { display: grid; flex: none; place-items: center; width: 16px; height: 16px; color: var(--ui-dmc-icon); transition: color 120ms ease; }\n.ui-dmc__icon > svg { display: block; width: 15px; height: 15px; }\n.ui-dmc__icon[data-logo] > svg, .ui-dmc__icon[data-logo] > img { width: 14px; height: 14px; }\n.ui-dmc__item:focus .ui-dmc__icon:not([data-logo]), .ui-dmc__item[data-open] .ui-dmc__icon:not([data-logo]) { color: var(--ui-dmc-ink); }\n.ui-dmc__item[data-danger] .ui-dmc__icon { color: var(--ui-dmc-danger); }\n.ui-dmc__item[aria-disabled=\"true\"] .ui-dmc__icon { opacity: 0.5; color: var(--ui-dmc-icon); }\n\n/* Long names stay on one line – the full name on hover */\n.ui-dmc__label { display: grid; flex: 1; gap: 1px; min-width: 0; }\n.ui-dmc__text, .ui-dmc__desc { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n.ui-dmc__desc { font-size: 12px; line-height: 16px; font-weight: 400; color: var(--ui-dmc-faint); }\n\n.ui-dmc__kbd { flex: none; padding-left: 16px; font: inherit; font-size: 12px; font-weight: 400; letter-spacing: 0.04em; color: var(--ui-dmc-faint); }\n.ui-dmc__more, .ui-dmc__check { display: grid; flex: none; place-items: center; width: 14px; height: 14px; }\n.ui-dmc__more { color: var(--ui-dmc-faint); }\n.ui-dmc__check { color: var(--ui-dmc-ink); }\n.ui-dmc__more > svg, .ui-dmc__check > svg { display: block; width: 14px; height: 14px; }\n.ui-dmc__tick { stroke-dasharray: 1; animation: ui-dmc-draw 280ms 40ms var(--ui-dmc-ease) both; }\n\n.ui-dmc__sep { display: block; height: 1px; margin: 4px 6px; background: var(--ui-dmc-line); }\n.ui-dmc__empty { display: block; padding: 6px 8px; color: var(--ui-dmc-faint); }\n\n@keyframes ui-dmc-in { from { opacity: 0; transform: scale(0.96) translateY(-4px); } }\n@keyframes ui-dmc-in-up { from { opacity: 0; transform: scale(0.96) translateY(4px); } }\n@keyframes ui-dmc-in-side { from { opacity: 0; transform: scale(0.98); } }\n@keyframes ui-dmc-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }\n\n/* Touch screens: rows a finger can hit */\n@media (pointer: coarse) {\n  .ui-dmc__item { min-height: 40px; }\n  .ui-dmc__item[data-rich] { min-height: 48px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-dmc__panel, .ui-dmc__tick { animation-duration: 1ms; }\n  .ui-dmc__glow, .ui-dmc__item, .ui-dmc__icon { transition-duration: 1ms; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-dmc {\n  --ui-dmc-surface: var(--aui-overlay, var(--aui-surface, #1b1b1e));\n  --ui-dmc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-dmc-text: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-dmc-icon: var(--aui-muted, rgba(255, 255, 255, 0.45));\n  --ui-dmc-faint: var(--aui-placeholder, rgba(255, 255, 255, 0.28));\n  --ui-dmc-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-dmc-glow: rgba(255, 255, 255, 0.06);\n  --ui-dmc-danger: var(--aui-red, #ff6b5f);\n  --ui-dmc-danger-glow: color-mix(in srgb, var(--aui-red, #ff6b5f) 14%, transparent);\n  --ui-dmc-shadow: var(--aui-shadow-popover, 0 0 0 0.5px var(--aui-border-strong, rgba(255, 255, 255, 0.14)), 0 12px 32px -8px rgba(0, 0, 0, 0.6));\n  --ui-dmc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  color-scheme: dark;\n}\n`;\n"}]}