{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "dropdown-menu",
  "title": "Dropdown Menu",
  "type": "registry:ui",
  "description": "Floating menu launched from a trigger button — for account switchers, row actions, editor menus, and any short list of commands. Supports labels, icons, separators, keyboard shortcuts, checkbox/radio items, and nested submenus. Built on Radix UI; ARIA + keyboard navigation handled.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/dropdown-menu/dropdown-menu.ts",
      "content": "import { html } from 'lit'\nimport { MenuBase, setAttr, type MenuClasses, type Placement } from './menu-core'\n\n// React's class strings, verbatim (packages/registry-react/components/dropdown-menu).\nconst classes: MenuClasses = {\n  content:\n    'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',\n  subContent:\n    'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',\n  item: \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  checkboxItem:\n    \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  radioItem:\n    \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  label: 'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',\n  separator: 'bg-border -mx-1 my-1 h-px',\n  shortcut: 'text-muted-foreground ml-auto text-xs tracking-widest',\n  subTrigger:\n    \"focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  subChevron: 'ml-auto size-4',\n  originVar: '--radix-dropdown-menu-content-transform-origin',\n  availableHeightVar: '--radix-dropdown-menu-content-available-height',\n}\n\n/**\n * <uip-dropdown-menu> — the registry DropdownMenu as ONE web component.\n *\n * The trigger is any element in `slot=\"trigger\"` (React's `asChild`); the menu\n * is declared with light-DOM data tags that the element re-renders, with\n * React's classes and Radix's roles/data attributes, inside its shadow root:\n *\n *   <uip-dropdown-menu class=\"[&::part(content)]:w-56 [&::part(sub-content)]:w-44\">\n *     <uip-button slot=\"trigger\" variant=\"outline\">Open menu</uip-button>\n *     <uip-dropdown-menu-content align=\"end\">\n *       <uip-dropdown-menu-label>My account</uip-dropdown-menu-label>\n *       <uip-dropdown-menu-separator></uip-dropdown-menu-separator>\n *       <uip-dropdown-menu-item><svg …></svg> Profile\n *         <uip-dropdown-menu-shortcut>⌘P</uip-dropdown-menu-shortcut></uip-dropdown-menu-item>\n *       <uip-dropdown-menu-checkbox-item checked>Status bar</uip-dropdown-menu-checkbox-item>\n *       <uip-dropdown-menu-radio-group value=\"center\">\n *         <uip-dropdown-menu-radio-item value=\"top\">Top</uip-dropdown-menu-radio-item>\n *       </uip-dropdown-menu-radio-group>\n *       <uip-dropdown-menu-sub>\n *         <uip-dropdown-menu-sub-trigger>Invite</uip-dropdown-menu-sub-trigger>\n *         <uip-dropdown-menu-sub-content>…items…</uip-dropdown-menu-sub-content>\n *       </uip-dropdown-menu-sub>\n *     </uip-dropdown-menu-content>\n *   </uip-dropdown-menu>\n *\n * Content: `side` (bottom), `align` (center), `side-offset` (4), `align-offset`.\n * Items: `disabled`, `inset`, `variant=\"destructive\"`, `text-value` (typeahead).\n * Styling (React's `className`): parts `content`, `sub-content`, `item`\n * (+ `checkbox-item` / `radio-item` / `sub-trigger`), `label`, `separator`,\n * `shortcut`, reached from the host with `[&::part(content)]:w-56`. A `class`\n * on a data tag is ignored; a `part=\"name\"` on one adds that part name to its\n * row (`[&::part(name)]:text-destructive`).\n *\n * Property/attribute: `open` (boolean, reflected).\n * Events: `open-change` { open } on the host; `select` (cancelable),\n * `checked-change` { checked } and `value-change` { value } on the data tags\n * (they bubble). The trigger gets `aria-haspopup`, `aria-expanded`,\n * `data-state`, `data-uipkge` and `data-slot=\"dropdown-menu-trigger\"`.\n */\nexport class UipDropdownMenu extends MenuBase {\n  protected readonly menuPrefix = 'dropdown-menu' as const\n  protected readonly C = classes\n  protected readonly defaults: Placement = { side: 'bottom', align: 'center', sideOffset: 4, alignOffset: 0 }\n\n  private get trigger() {\n    const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot[name=\"trigger\"]')\n    return (slot?.assignedElements()[0] as HTMLElement | undefined) ?? null\n  }\n\n  private disabledTrigger() {\n    const t = this.trigger\n    return !t || (t.hasAttribute('disabled') && t.getAttribute('disabled') !== 'false')\n  }\n\n  private openFrom(focus: 'first' | 'last' | 'content') {\n    this.anchor = this.trigger\n    this.focusOnOpen = focus\n    this.setOpen(true)\n  }\n\n  private onTriggerPointerDown(e: PointerEvent) {\n    if (this.disabledTrigger() || e.button !== 0 || e.ctrlKey) return\n    if (this.open) this.closeAll(false)\n    else {\n      // Keep focus from landing on the trigger; the menu takes it.\n      e.preventDefault()\n      this.openFrom('content')\n    }\n  }\n\n  private onTriggerKeyDown(e: KeyboardEvent) {\n    if (this.disabledTrigger()) return\n    if (e.key === 'Enter' || e.key === ' ') {\n      e.preventDefault()\n      if (this.open) this.closeAll(true)\n      else this.openFrom('first')\n    } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n      e.preventDefault()\n      this.openFrom(e.key === 'ArrowDown' ? 'first' : 'last')\n    }\n  }\n\n  protected defaultAnchor() {\n    return this.trigger\n  }\n\n  protected isTriggerEvent(path: EventTarget[]) {\n    const t = this.trigger\n    return !!t && path.includes(t)\n  }\n\n  protected focusTrigger() {\n    this.trigger?.focus()\n  }\n\n  protected menuLabel() {\n    const t = this.trigger\n    return t?.getAttribute('aria-label') || t?.textContent?.trim() || undefined\n  }\n\n  protected onOpenStateRendered() {\n    const t = this.trigger\n    if (!t) return\n    setAttr(t, 'data-uipkge', '')\n    setAttr(t, 'data-slot', 'dropdown-menu-trigger')\n    setAttr(t, 'aria-haspopup', 'menu')\n    setAttr(t, 'aria-expanded', this.open ? 'true' : 'false')\n    setAttr(t, 'data-state', this.open ? 'open' : 'closed')\n  }\n\n  protected renderTrigger() {\n    return html`<slot\n      name=\"trigger\"\n      @slotchange=${() => this.onOpenStateRendered()}\n      @pointerdown=${this.onTriggerPointerDown}\n      @keydown=${this.onTriggerKeyDown}\n    ></slot>`\n  }\n}\n\ncustomElements.get('uip-dropdown-menu') || customElements.define('uip-dropdown-menu', UipDropdownMenu)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-dropdown-menu': UipDropdownMenu\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/dropdown-menu/dropdown-menu.ts"
    },
    {
      "path": "packages/elements-lit/src/components/dropdown-menu/menu-core.ts",
      "content": "import { LitElement, css, html, isServer, nothing, type PropertyDeclarations, type TemplateResult } from 'lit'\nimport { Check, ChevronRight, Circle } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { autoUpdate, computePosition, type Align, type Side } from '../../lib/position'\n\n/**\n * Shared engine for <uip-dropdown-menu> and <uip-context-menu>.\n *\n * The menu tree is declared as light-DOM DATA tags (never rendered — the host\n * has no default slot) and re-rendered inside ONE shadow root, so every\n * aria-controls / id reference and every roving-focus target lives in the same\n * tree, and the registry's `[&_svg]` / `focus:` class strings work verbatim on\n * real descendants. Item content (text, <svg> icons) is cloned from the data\n * tags; `*-shortcut` tags become the registry's shortcut <span>.\n *\n * Tags (prefix = `uip-dropdown-menu-` or `uip-context-menu-`):\n *   content (side/align/side-offset/align-offset), item (disabled, inset,\n *   variant), checkbox-item (checked, disabled), radio-group (value),\n *   radio-item (value, disabled), label (inset), separator, group, shortcut,\n *   sub > sub-trigger (inset, disabled) + sub-content.\n *\n * Styling (React's `className` on the parts): a `class` on a data tag is NOT\n * used — the rendered parts live in the shadow root, where only utilities the\n * shadow sheet happens to contain would apply. Style them with `::part` from\n * the host instead, which the page's own Tailwind compiles:\n *   `content` (root menu), `sub-content`, `item` (every row; checkbox / radio\n *   rows and sub-triggers also carry `checkbox-item` / `radio-item` /\n *   `sub-trigger`), `label`, `separator`, `shortcut`.\n *   e.g. `<uip-dropdown-menu class=\"[&::part(content)]:w-56\">`.\n * A `part` attribute on a data tag adds those names to its rendered part, to\n * style a single row: `<uip-dropdown-menu-item part=\"logout\">` +\n * `class=\"[&::part(logout)]:text-destructive\"` on the host.\n *\n * Events are dispatched ON THE DATA TAG and bubble to the host:\n *   `select` (cancelable — preventDefault keeps the menu open),\n *   `checked-change` { checked } (cancelable — preventDefault = controlled),\n *   `value-change` { value } on the radio-group (cancelable).\n * The host emits `open-change` { open }.\n */\n\nexport interface MenuClasses {\n  content: string\n  subContent: string\n  item: string\n  checkboxItem: string\n  radioItem: string\n  label: string\n  separator: string\n  shortcut: string\n  subTrigger: string\n  subChevron: string\n  originVar: string\n  availableHeightVar: string\n}\n\nexport interface Placement {\n  side: Side\n  align: Align\n  sideOffset: number\n  alignOffset: number\n}\n\nexport type Anchor = Element | DOMRect\n\nlet uid = 0\nconst ids = new WeakMap<Element, string>()\nconst idFor = (el: Element) => {\n  let id = ids.get(el)\n  if (!id) ids.set(el, (id = `uip-menu-${++uid}`))\n  return id\n}\n\n/** setAttribute only when the value changes (avoids needless mutation records). */\nexport const setAttr = (el: Element, name: string, value: string) => {\n  if (el.getAttribute(name) !== value) el.setAttribute(name, value)\n}\n\n/** Boolean data attribute; `=\"false\"` (Vue binds false as a string) is off. */\nconst flag = (el: Element, name: string) => el.hasAttribute(name) && el.getAttribute(name) !== 'false'\nconst num = (el: Element | null | undefined, name: string, fallback: number) => {\n  const v = el?.getAttribute(name)\n  return v == null || v === '' || isNaN(+v) ? fallback : +v\n}\n\nconst origins: Record<Side, Record<Align, string>> = {\n  bottom: { start: 'left top', center: 'center top', end: 'right top' },\n  top: { start: 'left bottom', center: 'center bottom', end: 'right bottom' },\n  right: { start: 'left top', center: 'left center', end: 'left bottom' },\n  left: { start: 'right top', center: 'right center', end: 'right bottom' },\n}\n\n/** Run `done` once the element's own animations finish (or 400ms, for hidden tabs). */\nfunction afterAnimations(el: Element, done: () => void) {\n  let fired = false\n  const fin = () => {\n    if (fired) return\n    fired = true\n    done()\n  }\n  setTimeout(fin, 400)\n  requestAnimationFrame(() => {\n    const anims = el.getAnimations()\n    if (anims.length) Promise.all(anims.map((a) => a.finished)).then(fin, fin)\n    else fin()\n  })\n}\n\nexport abstract class MenuBase extends LitElement {\n  // The host renders no box of its own (Radix Root renders nothing): the\n  // slotted trigger lays out as if it were the host's parent's child.\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties: PropertyDeclarations = {\n    open: { type: Boolean, reflect: true },\n    openSubs: { state: true },\n    menuState: { state: true },\n    version: { state: true },\n  }\n\n  open = false\n  private openSubs: Element[] = []\n  private menuState: 'open' | 'closed' = 'closed'\n  private version = 0\n\n  protected abstract readonly menuPrefix: 'dropdown-menu' | 'context-menu' | 'menubar'\n  protected abstract readonly C: MenuClasses\n  protected abstract readonly defaults: Placement\n\n  /** Where the root menu is anchored (trigger element or a pointer rect). */\n  protected anchor: Anchor | null = null\n  /** How the next open should move focus: first/last item or the menu itself. */\n  protected focusOnOpen: 'first' | 'last' | 'content' = 'content'\n  /** Return focus to the trigger when this close completes. */\n  protected returnFocus = false\n\n  private clones = new Map<Element, unknown[]>()\n  private cleanups = new Map<HTMLElement, () => void>()\n  private observer?: MutationObserver\n  private typeahead = ''\n  private typeaheadTimer?: ReturnType<typeof setTimeout>\n  private subOpenTimer?: ReturnType<typeof setTimeout>\n  private subCloseTimer?: ReturnType<typeof setTimeout>\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', this.menuPrefix)\n    if (isServer) return\n    // Frameworks render the data tags after the host connects and may change\n    // them (checked/value) later: re-read on any change.\n    this.observer = new MutationObserver((records) => {\n      // Ignore the host's own attributes (open, data-theme) and the ones this\n      // element mirrors onto the trigger — only the data tags matter.\n      const relevant = records.some(\n        (r) => r.type !== 'attributes' || (r.target !== this && !(r.target as Element).hasAttribute?.('slot')),\n      )\n      if (!relevant) return\n      this.clones.clear()\n      this.version++\n    })\n    this.observer.observe(this, { childList: true, subtree: true, characterData: true, attributes: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.observer?.disconnect()\n    this.teardown()\n  }\n\n  // --- data tags ---------------------------------------------------------------\n  protected kind(el: Element) {\n    const p = `uip-${this.menuPrefix}-`\n    return el.localName.startsWith(p) ? el.localName.slice(p.length) : ''\n  }\n\n  protected get contentData() {\n    // The server DOM shim has no light-DOM children; the menu renders client-side.\n    if (isServer) return null\n    return [...this.children].find((c) => this.kind(c) === 'content') ?? null\n  }\n\n  private childTags(el: Element) {\n    return [...el.children].filter((c) => this.kind(c))\n  }\n\n  /** Cloned item content: text + icons; shortcut tags become the registry span. */\n  private contentOf(el: Element): unknown[] {\n    let out = this.clones.get(el)\n    if (out) return out\n    out = []\n    for (const n of el.childNodes) {\n      if (n.nodeType === Node.ELEMENT_NODE) {\n        const k = this.kind(n as Element)\n        if (k === 'shortcut') {\n          out.push(\n            html`<span part=${this.parts('shortcut', n as Element)} data-uipkge=\"\" data-slot=${`${this.menuPrefix}-shortcut`} class=${this.C.shortcut}\n              >${n.textContent}</span\n            >`,\n          )\n          continue\n        }\n        if (k) continue\n      }\n      out.push(n.cloneNode(true))\n    }\n    this.clones.set(el, out)\n    return out\n  }\n\n  private textOf(el: Element) {\n    let t = ''\n    for (const n of el.childNodes) {\n      if (n.nodeType === Node.TEXT_NODE) t += n.textContent\n      else if (n.nodeType === Node.ELEMENT_NODE && !this.kind(n as Element)) t += n.textContent\n    }\n    return t.trim()\n  }\n\n  // --- open / close ------------------------------------------------------------\n  protected setOpen(open: boolean) {\n    if (this.open === open) return\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  private get rootMenu() {\n    return this.renderRoot?.querySelector<HTMLElement>('[data-menu-root]') ?? null\n  }\n\n  private menuFor(sub: Element) {\n    return this.renderRoot.querySelector<HTMLElement>(`[id=\"${idFor(sub)}\"]`)\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    // Derived render state belongs here: setting it in updated() would schedule\n    // a second update (Lit's \"change-in-update\" warning) on every open/close.\n    if (changed.has('open')) {\n      this.menuState = this.open ? 'open' : 'closed'\n      if (!this.open) this.openSubs = []\n    }\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    const root = this.rootMenu\n    if (!root) return\n    if (changed.has('open')) {\n      if (this.open) this.show(root)\n      else if (changed.get('open') === true) this.hide(root)\n    }\n    if (changed.has('openSubs') || changed.has('version')) this.syncSubs()\n    this.onOpenStateRendered()\n  }\n\n  /** Hook for subclasses (mirror data-state on the trigger). */\n  protected onOpenStateRendered() {}\n\n  /** Anchor used when `open` is set without a user gesture. */\n  protected defaultAnchor(): Anchor | null {\n    return null\n  }\n\n  private show(root: HTMLElement) {\n    this.anchor ??= this.defaultAnchor()\n    if (!root.matches(':popover-open')) root.showPopover()\n    this.trackRoot(root)\n    addEventListener('pointerdown', this.onOutsidePointer, true)\n    this.updateComplete.then(() => {\n      if (this.focusOnOpen === 'content') root.focus({ preventScroll: true })\n      else this.focusItem(root, this.focusOnOpen === 'first' ? 0 : -1)\n    })\n  }\n\n  /** Re-anchor an open menu (context menu re-opened at a new point). */\n  protected reposition() {\n    const root = this.rootMenu\n    if (!root || !this.open) return\n    this.openSubs = []\n    this.trackRoot(root)\n    root.focus({ preventScroll: true })\n  }\n\n  private trackRoot(root: HTMLElement) {\n    this.track(root, () => this.anchor, () => {\n      const d = this.contentData\n      return {\n        side: (d?.getAttribute('side') as Side) || this.defaults.side,\n        align: (d?.getAttribute('align') as Align) || this.defaults.align,\n        sideOffset: num(d, 'side-offset', this.defaults.sideOffset),\n        alignOffset: num(d, 'align-offset', this.defaults.alignOffset),\n      }\n    })\n  }\n\n  private hide(root: HTMLElement) {\n    removeEventListener('pointerdown', this.onOutsidePointer, true)\n    const focusTrigger = this.returnFocus\n    this.returnFocus = false\n    if (focusTrigger) this.focusTrigger()\n    this.updateComplete.then(() =>\n      afterAnimations(root, () => {\n        if (this.open) return\n        this.untrack(root)\n        if (root.matches(':popover-open')) root.hidePopover()\n      }),\n    )\n  }\n\n  protected focusTrigger() {}\n\n  private teardown() {\n    removeEventListener('pointerdown', this.onOutsidePointer, true)\n    this.cleanups.forEach((c) => c())\n    this.cleanups.clear()\n  }\n\n  /** Close everything (item selected, Escape, outside click). */\n  protected closeAll(returnFocus: boolean) {\n    this.returnFocus = returnFocus\n    this.setOpen(false)\n  }\n\n  private onOutsidePointer = (e: PointerEvent) => {\n    const path = e.composedPath()\n    if (path.some((n) => n instanceof HTMLElement && n.hasAttribute('data-menu') && this.renderRoot.contains(n))) return\n    if (this.isTriggerEvent(path, e)) return\n    this.closeAll(false)\n  }\n\n  /** Subclass: pointerdown on the trigger is handled by the trigger itself. */\n  protected isTriggerEvent(_path: EventTarget[], _e: Event) {\n    return false\n  }\n\n  // --- positioning -----------------------------------------------------------------\n  private track(el: HTMLElement, anchor: () => Anchor | null, placement: () => Placement) {\n    this.untrack(el)\n    const a = anchor()\n    if (!a) return\n    // computePosition measures the layout size (offsetWidth/Height), so the\n    // enter animation's zoom-in-95 transform doesn't skew placement.\n    const update = () => {\n      const cur = anchor()\n      if (!cur) return\n      const p = placement()\n      const { style, side, align } = computePosition(cur, el, { ...p, collisionPadding: 8 })\n      el.style.top = style.top\n      el.style.left = style.left\n      el.setAttribute('data-side', side)\n      el.setAttribute('data-align', align)\n      el.style.setProperty(this.C.originVar, origins[side][align])\n      const r = cur instanceof Element ? cur.getBoundingClientRect() : cur\n      const avail =\n        side === 'bottom' ? innerHeight - r.bottom - p.sideOffset - 8 : side === 'top' ? r.top - p.sideOffset - 8 : innerHeight - 16\n      el.style.setProperty(this.C.availableHeightVar, `${Math.max(0, Math.floor(avail))}px`)\n    }\n    const stop = a instanceof Element ? autoUpdate(a, el, update) : (update(), () => {})\n    this.cleanups.set(el, stop)\n  }\n\n  private untrack(el: HTMLElement) {\n    this.cleanups.get(el)?.()\n    this.cleanups.delete(el)\n  }\n\n  // --- submenus ------------------------------------------------------------------\n  private syncSubs() {\n    this.renderRoot.querySelectorAll<HTMLElement>('[data-sub-menu]').forEach((menu) => {\n      const sub = this.openSubs.find((s) => idFor(s) === menu.id)\n      if (sub && this.open) {\n        if (!menu.matches(':popover-open')) {\n          menu.showPopover()\n          const trigger = this.renderRoot.querySelector(`[aria-controls=\"${menu.id}\"]`)\n          this.track(menu, () => trigger, () => ({ side: 'right', align: 'start', sideOffset: 0, alignOffset: 0 }))\n        }\n      } else if (menu.matches(':popover-open')) {\n        this.untrack(menu)\n        menu.hidePopover()\n      }\n    })\n  }\n\n  private openSub(sub: Element, focusFirst: boolean) {\n    clearTimeout(this.subCloseTimer)\n    const depth = this.depthOf(sub)\n    if (this.openSubs[depth] !== sub) this.openSubs = [...this.openSubs.slice(0, depth), sub]\n    if (focusFirst)\n      this.updateComplete.then(() => {\n        const m = this.menuFor(sub)\n        if (m) this.focusItem(m, 0)\n      })\n  }\n\n  /** Close submenus opened at `depth` and deeper. */\n  private closeSubsFrom(depth: number) {\n    if (this.openSubs.length > depth) this.openSubs = this.openSubs.slice(0, depth)\n  }\n\n  /** Nesting level of the menu a data tag's rendered item lives in (root = 0). */\n  private depthOf(el: Element) {\n    let d = 0\n    for (let p = el.parentElement; p && p !== this; p = p.parentElement) if (this.kind(p) === 'sub-content') d++\n    return d\n  }\n\n  // --- focus / keyboard --------------------------------------------------------------\n  /** Enabled items that belong directly to `menu` (not to its submenus). */\n  private itemsOf(menu: HTMLElement) {\n    return [...menu.querySelectorAll<HTMLElement>('[data-menu-item]')].filter(\n      (i) => i.closest('[data-menu]') === menu && !i.hasAttribute('data-disabled'),\n    )\n  }\n\n  private focusItem(menu: HTMLElement, index: number) {\n    const items = this.itemsOf(menu)\n    const item = items.at(index)\n    item?.focus({ preventScroll: false })\n  }\n\n  private onMenuKeyDown(e: KeyboardEvent) {\n    const menu = e.currentTarget as HTMLElement\n    const target = e.target as HTMLElement\n    if (target.closest('[data-menu]') !== menu) return\n    const items = this.itemsOf(menu)\n    const i = items.indexOf(target)\n    const depth = +(menu.dataset.depth ?? 0)\n    switch (e.key) {\n      case 'ArrowDown':\n        e.preventDefault()\n        this.closeSubsFrom(depth)\n        items[i < 0 ? 0 : Math.min(i + 1, items.length - 1)]?.focus()\n        return\n      case 'ArrowUp':\n        e.preventDefault()\n        this.closeSubsFrom(depth)\n        items[i < 0 ? items.length - 1 : Math.max(i - 1, 0)]?.focus()\n        return\n      case 'Home':\n      case 'PageUp':\n        e.preventDefault()\n        items[0]?.focus()\n        return\n      case 'End':\n      case 'PageDown':\n        e.preventDefault()\n        items.at(-1)?.focus()\n        return\n      case 'ArrowRight':\n        if (target.hasAttribute('data-sub-trigger')) {\n          e.preventDefault()\n          this.activateSubTrigger(target)\n        }\n        return\n      case 'ArrowLeft':\n        if (depth > 0) {\n          e.preventDefault()\n          this.closeSubsFrom(depth - 1)\n          this.renderRoot.querySelector<HTMLElement>(`[aria-controls=\"${menu.id}\"]`)?.focus()\n        }\n        return\n      case 'Enter':\n      case ' ':\n        if (i < 0) return\n        e.preventDefault()\n        if (e.key === ' ' && this.typeahead) {\n          this.onTypeahead(' ', items)\n          return\n        }\n        target.click()\n        return\n      case 'Escape':\n        e.preventDefault()\n        e.stopPropagation()\n        this.closeAll(true)\n        return\n      case 'Tab':\n        // Radix keeps focus inside an open menu.\n        e.preventDefault()\n        return\n      default:\n        if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) this.onTypeahead(e.key, items, i)\n    }\n  }\n\n  private onTypeahead(ch: string, items: HTMLElement[], current = -1) {\n    clearTimeout(this.typeaheadTimer)\n    this.typeahead += ch.toLowerCase()\n    this.typeaheadTimer = setTimeout(() => (this.typeahead = ''), 1000)\n    const search = this.typeahead\n    // Repeating one character cycles through the items starting with it.\n    const cycling = search.length > 1 && [...search].every((c) => c === search[0])\n    const q = cycling ? search[0] : search\n    const text = (el: HTMLElement) => (el.dataset.text ?? '').toLowerCase()\n    const start = cycling || q.length === 1 ? current + 1 : Math.max(current, 0)\n    const ordered = [...items.slice(start), ...items.slice(0, start)]\n    ordered.find((el) => text(el).startsWith(q))?.focus()\n  }\n\n  // --- pointer ---------------------------------------------------------------------\n  private onItemPointerMove(e: PointerEvent, depth: number, sub?: Element) {\n    if (e.pointerType !== 'mouse') return\n    const item = e.currentTarget as HTMLElement\n    if (item.hasAttribute('data-disabled')) return\n    if (this.renderRoot instanceof ShadowRoot && this.renderRoot.activeElement !== item) item.focus({ preventScroll: true })\n    clearTimeout(this.subOpenTimer)\n    if (sub) {\n      clearTimeout(this.subCloseTimer)\n      if (this.openSubs[depth] !== sub) this.subOpenTimer = setTimeout(() => this.openSub(sub, false), 100)\n    } else if (this.openSubs.length > depth) {\n      // Grace period so a diagonal move towards the submenu doesn't close it.\n      clearTimeout(this.subCloseTimer)\n      this.subCloseTimer = setTimeout(() => this.closeSubsFrom(depth), 250)\n    }\n  }\n\n  private onItemPointerLeave(e: PointerEvent) {\n    if (e.pointerType !== 'mouse') return\n    clearTimeout(this.subOpenTimer)\n    const item = e.currentTarget as HTMLElement\n    const menu = item.closest<HTMLElement>('[data-menu]')\n    if (this.renderRoot instanceof ShadowRoot && this.renderRoot.activeElement === item) menu?.focus({ preventScroll: true })\n  }\n\n  private onSubMenuPointerEnter() {\n    clearTimeout(this.subCloseTimer)\n  }\n\n  // --- activation --------------------------------------------------------------------\n  private fire(el: Element, type: string, detail?: unknown) {\n    return el.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true, cancelable: true }))\n  }\n\n  private activateItem(el: Element) {\n    if (flag(el, 'disabled')) return\n    if (this.fire(el, 'select')) this.closeAll(true)\n  }\n\n  private activateCheckbox(el: Element) {\n    if (flag(el, 'disabled')) return\n    const checked = !flag(el, 'checked')\n    if (this.fire(el, 'checked-change', { checked })) el.toggleAttribute('checked', checked)\n    if (this.fire(el, 'select')) this.closeAll(true)\n  }\n\n  private activateRadio(el: Element) {\n    if (flag(el, 'disabled')) return\n    const group = el.parentElement\n    const value = el.getAttribute('value') ?? ''\n    if (group && this.kind(group) === 'radio-group' && group.getAttribute('value') !== value) {\n      if (this.fire(group, 'value-change', { value })) group.setAttribute('value', value)\n    }\n    if (this.fire(el, 'select')) this.closeAll(true)\n  }\n\n  private activateSubTrigger(item: HTMLElement) {\n    const sub = this.subsById.get(item.getAttribute('aria-controls') ?? '')\n    if (sub && !item.hasAttribute('data-disabled')) this.openSub(sub, true)\n  }\n\n  private subsById = new Map<string, Element>()\n\n  // --- render ------------------------------------------------------------------------\n  private renderChildren(parent: Element, depth: number): TemplateResult[] {\n    return this.childTags(parent).map((el) => this.renderNode(el, depth))\n  }\n\n  private itemAttrs(el: Element) {\n    return { disabled: flag(el, 'disabled'), text: el.getAttribute('text-value') ?? this.textOf(el) }\n  }\n\n  private renderNode(el: Element, depth: number): TemplateResult {\n    const p = this.menuPrefix\n    const C = this.C\n    const move = (e: PointerEvent) => this.onItemPointerMove(e, depth)\n    const leave = (e: PointerEvent) => this.onItemPointerLeave(e)\n    switch (this.kind(el)) {\n      case 'item': {\n        const { disabled, text } = this.itemAttrs(el)\n        return html`<div\n          role=\"menuitem\"\n          tabindex=\"-1\"\n          data-menu-item\n          data-uipkge=\"\"\n          data-slot=${`${p}-item`}\n          data-orientation=\"vertical\"\n          data-variant=${el.getAttribute('variant') || 'default'}\n          ?data-inset=${flag(el, 'inset')}\n          ?data-disabled=${disabled}\n          aria-disabled=${disabled ? 'true' : nothing}\n          data-text=${text}\n          part=${this.parts('item', el)}\n          class=${C.item}\n          @click=${() => this.activateItem(el)}\n          @pointermove=${move}\n          @pointerleave=${leave}\n          @focus=${this.onItemFocus}\n          @blur=${this.onItemBlur}\n        >${this.contentOf(el)}</div>`\n      }\n      case 'checkbox-item': {\n        const { disabled, text } = this.itemAttrs(el)\n        const checked = flag(el, 'checked')\n        const state = checked ? 'checked' : 'unchecked'\n        return html`<div\n          role=\"menuitemcheckbox\"\n          tabindex=\"-1\"\n          aria-checked=${checked ? 'true' : 'false'}\n          data-menu-item\n          data-uipkge=\"\"\n          data-slot=${`${p}-checkbox-item`}\n          data-orientation=\"vertical\"\n          data-state=${state}\n          ?data-disabled=${disabled}\n          aria-disabled=${disabled ? 'true' : nothing}\n          data-text=${text}\n          part=${this.parts('item checkbox-item', el)}\n          class=${C.checkboxItem}\n          @click=${() => this.activateCheckbox(el)}\n          @pointermove=${move}\n          @pointerleave=${leave}\n          @focus=${this.onItemFocus}\n          @blur=${this.onItemBlur}\n        >\n          <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\"\n            >${checked ? html`<span data-state=${state}>${icon(Check, 'check', 'size-4')}</span>` : nothing}</span\n          >${this.contentOf(el)}\n        </div>`\n      }\n      case 'radio-group':\n        return html`<div role=\"group\" data-uipkge=\"\" data-slot=${`${p}-radio-group`}>\n          ${this.renderChildren(el, depth)}\n        </div>`\n      case 'radio-item': {\n        const { disabled, text } = this.itemAttrs(el)\n        const group = el.parentElement\n        const checked = !!group && group.getAttribute('value') === (el.getAttribute('value') ?? '')\n        const state = checked ? 'checked' : 'unchecked'\n        return html`<div\n          role=\"menuitemradio\"\n          tabindex=\"-1\"\n          aria-checked=${checked ? 'true' : 'false'}\n          data-menu-item\n          data-uipkge=\"\"\n          data-slot=${`${p}-radio-item`}\n          data-orientation=\"vertical\"\n          data-state=${state}\n          ?data-disabled=${disabled}\n          aria-disabled=${disabled ? 'true' : nothing}\n          data-text=${text}\n          part=${this.parts('item radio-item', el)}\n          class=${C.radioItem}\n          @click=${() => this.activateRadio(el)}\n          @pointermove=${move}\n          @pointerleave=${leave}\n          @focus=${this.onItemFocus}\n          @blur=${this.onItemBlur}\n        >\n          <span class=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\"\n            >${checked ? html`<span data-state=${state}>${icon(Circle, 'circle', 'size-2 fill-current')}</span>` : nothing}</span\n          >${this.contentOf(el)}\n        </div>`\n      }\n      case 'label':\n        return html`<div\n          part=${this.parts('label', el)}\n          data-uipkge=\"\"\n          data-slot=${`${p}-label`}\n          ?data-inset=${flag(el, 'inset')}\n          class=${C.label}\n        >\n          ${this.contentOf(el)}\n        </div>`\n      case 'separator':\n        return html`<div\n          role=\"separator\"\n          aria-orientation=\"horizontal\"\n          data-uipkge=\"\"\n          data-slot=${`${p}-separator`}\n          part=${this.parts('separator', el)}\n          class=${C.separator}\n        ></div>`\n      case 'group':\n        return html`<div role=\"group\" data-uipkge=\"\" data-slot=${`${p}-group`}>\n          ${this.renderChildren(el, depth)}\n        </div>`\n      case 'sub':\n        return this.renderSub(el, depth)\n      default:\n        return html``\n    }\n  }\n\n  private renderSub(sub: Element, depth: number) {\n    const p = this.menuPrefix\n    const C = this.C\n    const trigger = [...sub.children].find((c) => this.kind(c) === 'sub-trigger')\n    const content = [...sub.children].find((c) => this.kind(c) === 'sub-content')\n    if (!trigger || !content) return html``\n    const id = idFor(sub)\n    this.subsById.set(id, sub)\n    const open = this.openSubs[depth] === sub\n    const state = open ? 'open' : 'closed'\n    const { disabled, text } = this.itemAttrs(trigger)\n    return html`<div\n        role=\"menuitem\"\n        tabindex=\"-1\"\n        aria-haspopup=\"menu\"\n        aria-expanded=${open ? 'true' : 'false'}\n        aria-controls=${id}\n        data-menu-item\n        data-sub-trigger\n        data-uipkge=\"\"\n        data-slot=${`${p}-sub-trigger`}\n        data-orientation=\"vertical\"\n        data-state=${state}\n        ?data-inset=${flag(trigger, 'inset')}\n        ?data-disabled=${disabled}\n        aria-disabled=${disabled ? 'true' : nothing}\n        data-text=${text}\n        part=${this.parts('item sub-trigger', trigger)}\n        class=${C.subTrigger}\n        @click=${(e: Event) => this.activateSubTrigger(e.currentTarget as HTMLElement)}\n        @pointermove=${(e: PointerEvent) => this.onItemPointerMove(e, depth, sub)}\n        @pointerleave=${(e: PointerEvent) => this.onItemPointerLeave(e)}\n        @focus=${this.onItemFocus}\n        @blur=${this.onItemBlur}\n      >\n        ${this.contentOf(trigger)}${icon(ChevronRight, 'chevron-right', C.subChevron)}\n      </div>\n      <div\n        id=${id}\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        tabindex=\"-1\"\n        popover=\"manual\"\n        data-menu\n        data-sub-menu\n        data-depth=${depth + 1}\n        data-uipkge=\"\"\n        data-slot=${`${p}-sub-content`}\n        data-orientation=\"vertical\"\n        data-state=${state}\n        aria-label=${text || nothing}\n        part=${this.parts('sub-content', content)}\n        class=${cn(C.subContent, 'fixed inset-auto m-0 outline-none')}\n        @keydown=${this.onMenuKeyDown}\n        @pointerenter=${this.onSubMenuPointerEnter}\n        @contextmenu=${(e: Event) => e.preventDefault()}\n      >\n        ${this.renderChildren(content, depth + 1)}\n      </div>`\n  }\n\n  /** Built-in part names plus any the data tag's own `part` attribute adds. */\n  private parts(base: string, el: Element) {\n    const extra = el.getAttribute('part')?.trim()\n    return extra ? `${base} ${extra}` : base\n  }\n\n  private onItemFocus = (e: FocusEvent) => (e.currentTarget as HTMLElement).setAttribute('data-highlighted', '')\n  private onItemBlur = (e: FocusEvent) => (e.currentTarget as HTMLElement).removeAttribute('data-highlighted')\n\n  /** Slot(s) for the trigger — subclass specific. */\n  protected abstract renderTrigger(): TemplateResult\n\n  /** Accessible name for the root menu. */\n  protected menuLabel(): string | undefined {\n    return undefined\n  }\n\n  render() {\n    const p = this.menuPrefix\n    const data = this.contentData\n    void this.version\n    const label = isServer ? undefined : this.menuLabel()\n    return html`\n      ${this.renderTrigger()}\n      <div\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        tabindex=\"-1\"\n        popover=\"manual\"\n        data-menu\n        data-menu-root\n        data-depth=\"0\"\n        data-uipkge=\"\"\n        data-slot=${`${p}-content`}\n        data-orientation=\"vertical\"\n        data-state=${this.menuState}\n        aria-label=${label || nothing}\n        part=${data ? this.parts('content', data) : 'content'}\n        class=${cn(this.C.content, 'fixed inset-auto m-0 outline-none')}\n        @keydown=${this.onMenuKeyDown}\n        @contextmenu=${(e: Event) => e.preventDefault()}\n      >\n        ${data ? this.renderChildren(data, 0) : nothing}\n      </div>\n    `\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/dropdown-menu/menu-core.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
