Skip to content

Dropdown Menu

Preview

<ui-dropdown-menu>
  <ui-dropdown-menu-trigger>
    <button class=${buttonClass({ variant: 'outline' })}>Open menu</button>
  </ui-dropdown-menu-trigger>
  <ui-dropdown-menu-content align="start">
    <ui-dropdown-menu-label>My Account</ui-dropdown-menu-label>
    <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
    <ui-dropdown-menu-item>Profile</ui-dropdown-menu-item>
    <ui-dropdown-menu-item>Billing</ui-dropdown-menu-item>
    <ui-dropdown-menu-sub>
      <ui-dropdown-menu-sub-trigger>Invite users</ui-dropdown-menu-sub-trigger>
      <ui-dropdown-menu-sub-content>
        <ui-dropdown-menu-item>Email</ui-dropdown-menu-item>
        <ui-dropdown-menu-item>Message</ui-dropdown-menu-item>
        <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
        <ui-dropdown-menu-item>More…</ui-dropdown-menu-item>
      </ui-dropdown-menu-sub-content>
    </ui-dropdown-menu-sub>
    <ui-dropdown-menu-item>Settings</ui-dropdown-menu-item>
    <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
    <ui-dropdown-menu-item variant="destructive">Sign out</ui-dropdown-menu-item>
  </ui-dropdown-menu-content>
</ui-dropdown-menu>

Installation

webjs ui add dropdown-menu

API Reference

Parts

Name Description
<ui-dropdown-menu> Root, owns the open state and page-level event handlers.
<ui-dropdown-menu-trigger> Toggles the menu.
<ui-dropdown-menu-content> Popover panel, role="menu". Accepts side / align / side-offset.
<ui-dropdown-menu-item> Clickable row. variant="default | destructive", inset boolean. type="checkbox | radio" makes it a checkable item (role="menuitemcheckbox" / "menuitemradio" with aria-checked).
<ui-dropdown-menu-label> Section header, smaller, semibold, muted.
<ui-dropdown-menu-separator> Horizontal divider.
<ui-dropdown-menu-shortcut> Keyboard shortcut hint, right-aligned.
<ui-dropdown-menu-group> Wraps a set of related items with role="group".
<ui-dropdown-menu-sub> Submenu root.
<ui-dropdown-menu-sub-trigger> Item that opens a submenu, auto-injects a right chevron.
<ui-dropdown-menu-sub-content> Submenu popover panel.

Props

Prop Type Default Description
open boolean (attribute) false
variant "default" | "destructive" "default" On <ui-dropdown-menu-item>.
inset boolean (attribute) false On <ui-dropdown-menu-item>, <ui-dropdown-menu-label>, and <ui-dropdown-menu-sub-trigger>, left-pad for icon alignment so the row aligns with sibling items that have leading icons.
side "top" | "right" | "bottom" | "left" "bottom" (content) / "right" (sub-content)
align "start" | "center" | "end" "start"
side-offset number (px) 4 (content) / -4 (sub-content) Attribute on <ui-dropdown-menu-content> and <ui-dropdown-menu-sub-content>.
align-offset number (px) 0 Attribute on <ui-dropdown-menu-content> and <ui-dropdown-menu-sub-content>. Pixels offset along the align axis.
text-value string (attribute) On <ui-dropdown-menu-item>. Override the string matched during typeahead. Defaults to the item textContent. Matches shadcn/Radix textValue.
type "checkbox" | "radio" On <ui-dropdown-menu-item>. Makes it a checkable item: role="menuitemcheckbox" / "menuitemradio" plus aria-checked, with an indicator in the left gutter. Omit for a plain item.
checked boolean (attribute, reflected) false On a checkable <ui-dropdown-menu-item>. Radio items keep exactly one checked per enclosing <ui-dropdown-menu-group>.
value string (attribute, reflected) On <ui-dropdown-menu-item>. Identifier reported in the ui-item-select detail; identifies a radio item within its group.

Events

Name Detail Description
ui-open-change { open: boolean }
ui-item-select { value: string, item: Element, type: string, checked: boolean }

Source: components/ui/dropdown-menu.ts

/**
 * DropdownMenu: popover-style menu of actions. Tier-2. Hand-rolled
 * keyboard nav, focus management, and positioning (no Radix).
 *
 * APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/menu/
 *
 * shadcn parity:
 *   DropdownMenu              → <ui-dropdown-menu open>
 *   DropdownMenuTrigger       → <ui-dropdown-menu-trigger>
 *   DropdownMenuContent       → <ui-dropdown-menu-content side align side-offset align-offset>
 *   DropdownMenuItem          → <ui-dropdown-menu-item variant inset>
 *   DropdownMenuCheckboxItem  → <ui-dropdown-menu-item type="checkbox" checked>
 *   DropdownMenuRadioGroup    → <ui-dropdown-menu-group> wrapping
 *   DropdownMenuRadioItem     → <ui-dropdown-menu-item type="radio" value>
 *   DropdownMenuLabel         → <ui-dropdown-menu-label inset>
 *   DropdownMenuSeparator     → <ui-dropdown-menu-separator>
 *   DropdownMenuShortcut      → <ui-dropdown-menu-shortcut>
 *   DropdownMenuGroup         → <ui-dropdown-menu-group>
 *   DropdownMenuSub           → <ui-dropdown-menu-sub>
 *   DropdownMenuSubTrigger    → <ui-dropdown-menu-sub-trigger inset>
 *   DropdownMenuSubContent    → <ui-dropdown-menu-sub-content>
 *
 * Attributes on <ui-dropdown-menu>:
 *   `open`:  boolean (reflected). Open state.
 *
 * Attributes on <ui-dropdown-menu-content>:
 *   `side`:         "top" | "right" | "bottom" (default) | "left".
 *   `align`:        "start" (default) | "center" | "end".
 *   `side-offset`:  number, default 4. Pixels between trigger and content.
 *   `align-offset`: number, default 0. Pixels of cross-axis shift.
 *
 * Attributes on <ui-dropdown-menu-item>:
 *   `variant`: "default" (default) | "destructive".
 *   `inset`:   boolean. Adds left padding to align with checkbox / radio items.
 *   `type`:    omit (default) | "checkbox" | "radio".
 *   `checked`: boolean. Applies to checkbox / radio items.
 *   `value`:   string. Identifier for radio items.
 *   `data-disabled`: boolean. Skips keyboard focus and activation, dims the
 *                    item, and sets aria-disabled. Same attribute on a
 *                    <ui-dropdown-menu-sub-trigger> disables the submenu.
 *
 * Events:
 *   `ui-open-change` on <ui-dropdown-menu>: `{ detail: { open } }` after a transition.
 *   `ui-item-select` bubbled by an item on activation, CANCELABLE:
 *     `{ detail: { value, item, type, checked } }`. `checked` is the state the
 *     item has settled on, so a checkbox / radio listener reads it directly.
 *     Calling `preventDefault()` keeps the menu OPEN (the parity shape for
 *     shadcn's `onSelect={e => e.preventDefault()}`), which is what a
 *     multi-select checkbox menu wants.
 *
 * Programmatic API on <ui-dropdown-menu>: `.show()` · `.hide()` · `.toggle()`.
 *
 * Keyboard:
 *   ArrowUp / ArrowDown   move focus between items
 *   ArrowRight            on a sub-trigger: open submenu, focus first item
 *   ArrowLeft             inside a submenu: close it, refocus the sub-trigger
 *   Home / End            first / last item
 *   Enter / Space         activate focused item
 *   Escape                close the menu that holds focus (a submenu first,
 *                         refocusing its sub-trigger) and refocus the trigger
 *   Tab                   close menu and proceed with normal tab order
 *
 * A11y (owned by the element, nothing to supply):
 *   The trigger gets `aria-haspopup` / `aria-expanded` / `aria-controls`, the
 *   panel is a `role="menu"` labelled back by the trigger, and a disabled item
 *   reflects `aria-disabled`. EVERY close path leaves focus somewhere sensible
 *   rather than dropping it to `<body>`: Escape, Tab, and item activation
 *   return focus to the trigger, and so does an outside click that did not put
 *   focus anywhere itself. An outside click ON another control leaves focus on
 *   that control, since the user chose it.
 *   A `type="checkbox"` / `type="radio"` item carries
 *   `role="menuitemcheckbox"` / `role="menuitemradio"` plus `aria-checked`.
 *   What you DO supply: a name for a radio set, as `aria-label` on the
 *   enclosing `<ui-dropdown-menu-group>` (APG asks a menuitemradio set to sit
 *   in a labelled group; the group forwards the name onto its `role="group"`).
 *   Put it in the MARKUP. The group declares no reactive props, so it renders
 *   once and its `aria-label` / `aria-labelledby` are not observed attributes: a
 *   name set on the host after mount is never picked up.
 *
 * Design tokens used: --popover, --popover-foreground, --accent,
 * --accent-foreground, --destructive, --muted-foreground, --border.
 *
 * @example
 * ```html
 * <ui-dropdown-menu>
 *   <ui-dropdown-menu-trigger>
 *     <button class=${buttonClass({ variant: 'outline' })}>Options</button>
 *   </ui-dropdown-menu-trigger>
 *   <ui-dropdown-menu-content align="end">
 *     <ui-dropdown-menu-label>My Account</ui-dropdown-menu-label>
 *     <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
 *     <ui-dropdown-menu-item>Profile</ui-dropdown-menu-item>
 *     <ui-dropdown-menu-sub>
 *       <ui-dropdown-menu-sub-trigger>Invite users</ui-dropdown-menu-sub-trigger>
 *       <ui-dropdown-menu-sub-content>
 *         <ui-dropdown-menu-item>Email</ui-dropdown-menu-item>
 *       </ui-dropdown-menu-sub-content>
 *     </ui-dropdown-menu-sub>
 *     <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
 *     <ui-dropdown-menu-item variant="destructive">Sign out</ui-dropdown-menu-item>
 *   </ui-dropdown-menu-content>
 * </ui-dropdown-menu>
 *
 * <!-- Checkbox items. Read the new state off the event's detail.checked. -->
 * <ui-dropdown-menu @ui-item-select=${onSelect}>
 *   <ui-dropdown-menu-trigger>
 *     <button class=${buttonClass({ variant: 'outline' })}>View</button>
 *   </ui-dropdown-menu-trigger>
 *   <ui-dropdown-menu-content>
 *     <ui-dropdown-menu-item type="checkbox" value="status" checked>
 *       Status bar
 *     </ui-dropdown-menu-item>
 *     <ui-dropdown-menu-item type="checkbox" value="activity">
 *       Activity bar
 *     </ui-dropdown-menu-item>
 *   </ui-dropdown-menu-content>
 * </ui-dropdown-menu>
 *
 * <!-- A multi-select menu cancels the event so the menu stays open:
 *      const onSelect = (e) => { e.preventDefault(); save(e.detail); };  -->
 *
 * <!-- Radio items: one checked per group. Name the set on the group. -->
 * <ui-dropdown-menu>
 *   <ui-dropdown-menu-trigger>
 *     <button class=${buttonClass({ variant: 'outline' })}>Panel</button>
 *   </ui-dropdown-menu-trigger>
 *   <ui-dropdown-menu-content>
 *     <ui-dropdown-menu-group aria-label="Panel position">
 *       <ui-dropdown-menu-item type="radio" value="top" checked>Top</ui-dropdown-menu-item>
 *       <ui-dropdown-menu-item type="radio" value="bottom">Bottom</ui-dropdown-menu-item>
 *       <ui-dropdown-menu-item type="radio" value="right">Right</ui-dropdown-menu-item>
 *     </ui-dropdown-menu-group>
 *   </ui-dropdown-menu-content>
 * </ui-dropdown-menu>
 * ```
 */
import { WebComponent, html, unsafeHTML, signal, prop } from '@webjsdev/core';
import { ensureId } from '../lib/utils.ts';
import { onBeforeCache } from '../lib/dom.ts';
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';

// --------------------------------------------------------------------------
// Class helpers
// --------------------------------------------------------------------------

export const dropdownMenuContentClass = (): string =>
  'fixed z-50 max-h-[--available-height] min-w-[8rem] m-0 overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md';

export const dropdownMenuItemClass = (): string =>
  "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:bg-destructive/10 data-[variant=destructive]:hover:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";

export const dropdownMenuCheckboxItemClass = (): string =>
  "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0";

export const dropdownMenuRadioItemClass = (): string =>
  "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8";

export const dropdownMenuLabelClass = (): string =>
  'px-2 pt-2 pb-1.5 text-xs font-semibold text-muted-foreground data-[inset]:pl-8';

export const dropdownMenuSeparatorClass = (): string => '-mx-1 my-1 h-px bg-border';

export const dropdownMenuShortcutClass = (): string =>
  'ml-auto text-xs tracking-widest text-muted-foreground';

export const dropdownMenuSubTriggerClass = (): string =>
  "flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm select-none outline-hidden focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground 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 [&>svg:last-child]:ml-auto";

export const dropdownMenuSubContentClass = (): string =>
  'fixed z-50 min-w-[8rem] m-0 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg';

const CHEVRON_RIGHT_SVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-auto size-4" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';

// Checked-state indicators for a checkbox / radio item. Decorative: the state
// they draw is announced from aria-checked, so both are aria-hidden.
const CHECK_SVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';

const RADIO_DOT_SVG =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-2" aria-hidden="true"><circle cx="12" cy="12" r="12"></circle></svg>';

const SUB_CLOSE_DELAY = 200;

// Every role a focusable menu item can carry. A checkbox / radio item is NOT
// role="menuitem", so a bare [role="menuitem"] query silently skips it and the
// item drops out of arrow nav, typeahead, and the focus-first-item-on-open.
const MENU_ITEM = ':is([role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"])';
const ENABLED_MENU_ITEM = `${MENU_ITEM}:not([data-disabled])`;

// --------------------------------------------------------------------------
// <ui-dropdown-menu>
// --------------------------------------------------------------------------

export class UiDropdownMenu extends WebComponent({
  open: prop(Boolean, { reflect: true }),
}) {
  _typeBuffer = '';
  _typeBufferTimer: number | undefined;

  _docClickHandler = (e: MouseEvent): void => this._onDocClick(e);
  _docPointerDownHandler = (e: Event): void => this._onDocPointerDown(e);
  _keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
  _resizeHandler = (): void => this._reposition();

  constructor() {
    super();
    this.open = false;
  }

  _disposeBeforeCache?: () => void;

  disconnectedCallback(): void {
    if (this.open) this._teardown();
    this._disposeBeforeCache?.();
    super.disconnectedCallback?.();
  }

  toggle(): void { this.open = !this.open; }
  show(): void { this.open = true; }
  hide(): void { this.open = false; }

  // APG Menu Button requires Escape, Tab, item activation, AND an outside-click
  // dismiss to close the menu without stranding focus. Without this, focus is
  // sitting on an item inside a popover="manual" panel, so hiding the panel
  // drops focus to <body> and a keyboard user loses their place entirely.
  //
  // The restore is GUARDED, because an outside click that deliberately moved
  // focus to another control must not have focus yanked back to the trigger.
  //
  // Focus moves BEFORE the panel hides. Hiding a popover whose descendant
  // holds focus makes the engine run its own focus fixup, and moving out
  // first means the trigger is the final focus rather than a target the
  // fixup then overrides.
  _closeAndRestoreFocus(): void {
    if (this._focusIsInside()) this._triggerControl()?.focus();
    this.hide();
  }

  _focusIsInside(): boolean {
    if (typeof document === 'undefined') return false;
    const active = document.activeElement;
    if (!active || active === document.body) return false;
    return this.contains(active);
  }

  render() {
    return html`<div data-slot="dropdown-menu" data-state=${this.open ? 'open' : 'closed'}>
      <slot></slot>
    </div>`;
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (!changedProperties.has('open')) return;
    if (changedProperties.get('open') === undefined) return;
    // Wait one microtask for <ui-dropdown-menu-content>'s [popover] to commit.
    queueMicrotask(() => this._afterRender());
  }

  connectedCallback(): void {
    super.connectedCallback?.();
    // webjs projects slotted light-DOM children in a pass after the first
    // render, so the trigger button and the menu are not in place at
    // connect. Defer to the next frame, when the projection has run.
    if (typeof requestAnimationFrame === 'function') {
      requestAnimationFrame(() => this._wireAria());
    }
    // Close (and tear down) before a back/forward snapshot so the menu does not
    // restore open (#766). Closing the root also hides any open submenu.
    this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
  }

  _afterRender(): void {
    const content = this._content();
    if (content) {
      this._syncContentPopover(content);
    }
    this._wireAria();
    if (this.open) this._setup();
    else this._teardown();
  }

  // The trigger wraps an author-supplied control (usually a <button>). Expose
  // the menu relationship on that focusable control: aria-haspopup announces
  // it opens a menu, aria-expanded tracks open state, aria-controls points at
  // the menu, and the menu is labelled back by the trigger. Done at runtime
  // because the menu is JS-driven (never shown without script).
  _triggerControl(): HTMLElement | null {
    const trigger = this.querySelector('ui-dropdown-menu-trigger');
    if (!trigger) return null;
    return (
      trigger.querySelector<HTMLElement>('button, [role="button"], a[href], [tabindex]') ??
      (trigger as HTMLElement)
    );
  }

  _menuEl(): HTMLElement | null {
    return this.querySelector('ui-dropdown-menu-content [role="menu"]');
  }

  _wireAria(): void {
    const control = this._triggerControl();
    if (!control) return;
    control.setAttribute('aria-haspopup', 'menu');
    control.setAttribute('aria-expanded', String(this.open));
    const menu = this._menuEl();
    if (!menu) return;
    const menuId = ensureId(menu, 'ui-menu');
    control.setAttribute('aria-controls', menuId);
    if (!menu.hasAttribute('aria-label') && !menu.hasAttribute('aria-labelledby')) {
      menu.setAttribute('aria-labelledby', ensureId(control, 'ui-menu-trigger'));
    }
    this._wireSubmenuAria();
  }

  // A SUBMENU is a role="menu" too, and APG asks for it to be labelled by the
  // menuitem that opens it, with that menuitem pointing back via aria-controls.
  // Only the root panel was wired, so every submenu shipped as an unnamed menu.
  _wireSubmenuAria(): void {
    this.querySelectorAll<HTMLElement>('ui-dropdown-menu-sub').forEach((sub) => {
      // NOT `:scope >`: the sub renders its own wrapper div with a <slot>, so the
      // trigger and content are grandchildren, not direct children. Match on the
      // nearest enclosing sub instead, which also keeps a NESTED submenu's nodes
      // from being wired to its outer sub.
      const owns = (el: Element | null): boolean =>
        !!el && el.closest('ui-dropdown-menu-sub') === sub;
      const trigger = Array.from(
        sub.querySelectorAll<HTMLElement>(`ui-dropdown-menu-sub-trigger ${MENU_ITEM}`),
      ).find((el) => owns(el.closest('ui-dropdown-menu-sub-trigger')));
      const panel = Array.from(
        sub.querySelectorAll<HTMLElement>('ui-dropdown-menu-sub-content [role="menu"]'),
      ).find((el) => owns(el.closest('ui-dropdown-menu-sub-content')));
      if (!trigger || !panel) return;
      trigger.setAttribute('aria-controls', ensureId(panel, 'ui-submenu'));
      if (!panel.hasAttribute('aria-label') && !panel.hasAttribute('aria-labelledby')) {
        panel.setAttribute('aria-labelledby', ensureId(trigger, 'ui-submenu-trigger'));
      }
    });
  }

  _content(): HTMLElement | null {
    return this.querySelector('ui-dropdown-menu-content [popover]');
  }

  _syncContentPopover(content: HTMLElement): void {
    const p = content as HTMLElement & {
      showPopover?: () => void;
      hidePopover?: () => void;
      matches: (s: string) => boolean;
    };
    if (typeof p.showPopover !== 'function') return;
    if (this.open && !p.matches(':popover-open')) p.showPopover();
    else if (!this.open && p.matches(':popover-open')) p.hidePopover();
  }

  _reposition(): void {
    const trigger = this.querySelector<HTMLElement>('ui-dropdown-menu-trigger');
    const content = this._content();
    const host = this.querySelector<HTMLElement>('ui-dropdown-menu-content');
    if (!trigger || !content || !host) return;
    positionFloating(trigger, content, {
      side: (host.getAttribute('side') ?? 'bottom') as PopoverSide,
      align: (host.getAttribute('align') ?? 'start') as PopoverAlign,
      sideOffset: Number(host.getAttribute('side-offset') ?? 4),
      alignOffset: Number(host.getAttribute('align-offset') ?? 0),
    });
  }

  _setup(): void {
    this._reposition();
    document.addEventListener('pointerdown', this._docPointerDownHandler, true);
    document.addEventListener('click', this._docClickHandler);
    document.addEventListener('keydown', this._keyHandler);
    window.addEventListener('resize', this._resizeHandler);
    window.addEventListener('scroll', this._resizeHandler, true);
    queueMicrotask(() => {
      const first = this.querySelector<HTMLElement>(
        `ui-dropdown-menu-item:not([data-disabled]) ${MENU_ITEM}`,
      );
      first?.focus();
    });
  }

  _teardown(): void {
    document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
    document.removeEventListener('click', this._docClickHandler);
    document.removeEventListener('keydown', this._keyHandler);
    this._focusWasInsideAtPointerDown = false;
    window.removeEventListener('resize', this._resizeHandler);
    window.removeEventListener('scroll', this._resizeHandler, true);
    this.querySelectorAll<UiDropdownMenuSub>('ui-dropdown-menu-sub[open]').forEach(
      (sub) => sub.hide(),
    );
  }

  // Sampled on POINTERDOWN, before the browser moves focus for the click. By
  // the time the click handler runs, a click on a non-focusable area has often
  // already blurred the focused menu item to <body>, so a check made there
  // cannot tell "clicked away from everything" (focus should go back to the
  // trigger) from "clicked another control" (leave focus where the user put it).
  _focusWasInsideAtPointerDown = false;

  _onDocPointerDown(e: Event): void {
    if (!this.open) return;
    if (e.composedPath().some((n) => n === this)) return;
    this._focusWasInsideAtPointerDown = this._focusIsInside();
  }

  _onDocClick(e: MouseEvent): void {
    if (!this.open) return;
    if (e.composedPath().some((n) => n === this)) return;
    // An outside click dismisses the menu, and it is still a close of a
    // popover="manual" panel, so it owes the same focus care as Escape: leaving
    // focus on an item that is about to become display:none drops it to <body>.
    // Restore only when the click did not land focus somewhere itself.
    const active = typeof document !== 'undefined' ? document.activeElement : null;
    const clickTookFocus = !!active && active !== document.body && !this.contains(active);
    if (this._focusWasInsideAtPointerDown && !clickTookFocus) {
      this._triggerControl()?.focus();
    }
    this._focusWasInsideAtPointerDown = false;
    this.hide();
  }

  _onKeyDown(e: KeyboardEvent): void {
    if (!this.open) return;

    // Active context = nearest content / sub-content panel owning focus.
    // Scoping arrow nav avoids walking into siblings of a different submenu.
    // Computed before Escape / Tab because both need to know which panel holds
    // focus, and neither may bail when focus is outside the menu entirely.
    const active = document.activeElement as HTMLElement | null;
    const context = active?.closest('[role="menu"]') as HTMLElement | null;

    if (e.key === 'Escape') {
      e.preventDefault();
      // APG: Escape closes the menu that CONTAINS focus and returns focus to
      // whatever opened it. Inside a submenu panel that is the submenu and its
      // sub-trigger, so the root menu stays open (the JSDoc's "close current
      // submenu first"). Focus on the sub-trigger itself is focus in the ROOT
      // panel, so that case closes the whole menu, which is why this reads the
      // panel rather than walking up to any open <ui-dropdown-menu-sub>.
      const subContent = context?.closest('ui-dropdown-menu-sub-content');
      const sub = subContent?.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
      if (sub) {
        sub.querySelector<HTMLElement>(`ui-dropdown-menu-sub-trigger ${MENU_ITEM}`)?.focus();
        sub.hide();
        return;
      }
      this._closeAndRestoreFocus();
      return;
    }

    if (e.key === 'Tab') {
      // JSDoc contract: Tab closes the menu and proceeds with the normal tab
      // order, so the default is deliberately NOT prevented. Focus is restored
      // to the trigger first because the items are tabindex="-1" inside a
      // top-layer panel that is about to be display:none, so tabbing onward
      // from there has no sensible next element. Moving to the trigger (which
      // IS in the tab sequence) means the browser's own Tab then advances to
      // the element after it, which is what APG asks for.
      this._closeAndRestoreFocus();
      return;
    }

    if (!context) return;

    const items = Array.from(
      context.querySelectorAll<HTMLElement>(ENABLED_MENU_ITEM),
    ).filter((it) => it.closest('[role="menu"]') === context);
    if (items.length === 0) return;
    const idx = active ? items.indexOf(active) : -1;

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      items[(idx + 1) % items.length].focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      items[(idx - 1 + items.length) % items.length].focus();
    } else if (e.key === 'Home') {
      e.preventDefault();
      items[0].focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      items[items.length - 1].focus();
    } else if (e.key === 'ArrowRight') {
      // Open submenu owned by the focused sub-trigger and move focus into it.
      const subTrigger = active?.closest('ui-dropdown-menu-sub-trigger');
      if (subTrigger) {
        e.preventDefault();
        const sub = subTrigger.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
        // The sub owns the open-then-focus sequencing: its panel is
        // popover="manual" and only becomes visible a microtask later, so
        // focusing from here races the reveal and loses (see the method).
        sub?.openAndFocusFirstItem();
      }
    } else if (e.key === 'ArrowLeft') {
      // Inside a sub-content: close the submenu and refocus its trigger.
      if (context.closest('ui-dropdown-menu-sub-content')) {
        e.preventDefault();
        const sub = context.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
        const trigger = sub?.querySelector<HTMLElement>(
          `ui-dropdown-menu-sub-trigger ${MENU_ITEM}`,
        );
        trigger?.focus();
        sub?.hide();
      }
    } else if (e.key === 'Enter' || e.key === ' ') {
      // A menu item is a <div role="menuitem">, and a div gets NO native
      // activation, so Enter / Space have to be synthesized here. Without this
      // the keyboard cannot activate any item at all, and a checkable item,
      // whose ONLY state transition is activation, cannot be toggled.
      //
      // Activation applies to a MENU ITEM only. A control the author slotted
      // into the panel (a filter input, a plain button) is inside [role="menu"]
      // too, so preventing Space unconditionally would swallow it there with
      // nothing to handle it, and the user could not type a space into their own
      // input. Bail before touching the event when the focus is not an item.
      const subTrigger = active?.closest('ui-dropdown-menu-sub-trigger');
      const activeItem = active?.closest('ui-dropdown-menu-item') as UiDropdownMenuItem | null;
      if (!subTrigger && !activeItem) return;
      // Space while a typeahead search is in flight belongs to the SEARCH, or a
      // multi-word item is unreachable past its first word ("Status bar" could
      // never be disambiguated from "Status line"). Radix draws the same line.
      // Enter always activates.
      if (e.key === ' ' && this._typeBuffer) {
        this._typeahead(e, items);
        return;
      }
      // Space otherwise MUST be prevented, or it falls through to typeahead,
      // matches nothing, and scrolls the page underneath the open menu.
      e.preventDefault();
      if (subTrigger) {
        // Same as ArrowRight on a sub-trigger: open it and move focus in.
        if (!subTrigger.hasAttribute('data-disabled')) {
          const sub = subTrigger.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
          sub?.openAndFocusFirstItem();
        }
        return;
      }
      if (activeItem && !activeItem.hasAttribute('data-disabled')) activeItem._select();
    } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
      this._typeahead(e, items);
    }
  }

  _typeahead(e: KeyboardEvent, items: HTMLElement[]): void {
    this._typeBuffer = (this._typeBuffer + e.key).toLowerCase();
    clearTimeout(this._typeBufferTimer);
    this._typeBufferTimer = window.setTimeout(() => { this._typeBuffer = ''; }, 500);
    const buffer = this._typeBuffer;
    const match = items.find((it) => {
      const text = (it.getAttribute('text-value') ?? it.textContent ?? '').trim().toLowerCase();
      return text.startsWith(buffer);
    });
    if (match) {
      e.preventDefault();
      match.focus();
    }
  }
}
UiDropdownMenu.register('ui-dropdown-menu');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-trigger>
// --------------------------------------------------------------------------

export class UiDropdownMenuTrigger extends WebComponent {
  render() {
    return html`<div
      data-slot="dropdown-menu-trigger"
      @click=${this._onClick}
    ><slot></slot></div>`;
  }

  _onClick = (): void => (this.closest('ui-dropdown-menu') as UiDropdownMenu | null)?.toggle();
}
UiDropdownMenuTrigger.register('ui-dropdown-menu-trigger');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-content>
// --------------------------------------------------------------------------

export class UiDropdownMenuContent extends WebComponent {
  render() {
    return html`<div
      data-slot="dropdown-menu-content"
      role="menu"
      aria-orientation="vertical"
      popover="manual"
      class=${dropdownMenuContentClass()}
    ><slot></slot></div>`;
  }
}
UiDropdownMenuContent.register('ui-dropdown-menu-content');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-item>
// --------------------------------------------------------------------------

export class UiDropdownMenuItem extends WebComponent({
  variant: prop<'default' | 'destructive'>(String, { reflect: true }),
  inset: Boolean,
  // `type` is read from the authored attribute and never written back: an
  // unreflected default keeps a plain item's markup free of a type="" that
  // means nothing. `checked` reflects so the state is readable and settable
  // as an attribute, the same contract <ui-toggle>'s `pressed` has.
  type: prop<'' | 'checkbox' | 'radio'>(String),
  checked: prop(Boolean, { reflect: true }),
  value: prop(String, { reflect: true }),
}) {
  // Keyboard / pointer highlight state for the own-rendered menuitem. A
  // local signal bound with ?data-highlighted keeps the highlight in the
  // declarative template instead of an imperative setAttribute on
  // e.currentTarget (the lit-idiomatic form).
  #highlighted = signal(false);

  constructor() {
    super();
    this.variant = 'default';
    this.inset = false;
    this.type = '';
    this.checked = false;
    this.value = '';
  }

  render() {
    // `data-disabled` on the host is the historical disabled marker (focus
    // skips it, the click / pointer handlers bail on it). Mirror it onto the
    // inner menuitem as both data-disabled (CSS) and aria-disabled, so the
    // state also reaches assistive tech.
    const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
    const checkbox = this.type === 'checkbox';
    const radio = this.type === 'radio';
    // The role is what tells a screen reader this is a checkable control at
    // all, and aria-checked is what carries the state. Neither belongs on a
    // plain item, where aria-checked is not an allowed attribute.
    //
    // Hence TWO templates rather than one with conditional holes: a null hole
    // does NOT omit an attribute on the server (the server renderer
    // stringifies the value), so a single template would ship every plain item
    // as `role="menuitem" aria-checked="" data-state=""`, carrying the exact
    // disallowed attribute this branch exists to keep off it, and disagreeing
    // with the hydrated DOM where the client renderer removes it. The two
    // shapes differ by the indicator anyway.
    if (checkbox || radio) {
      return html`<div
        data-slot=${`dropdown-menu-${this.type}-item`}
        role=${checkbox ? 'menuitemcheckbox' : 'menuitemradio'}
        tabindex="-1"
        data-variant=${this.variant}
        ?data-inset=${this.inset}
        ?data-disabled=${disabled}
        aria-disabled=${disabled ? 'true' : 'false'}
        aria-checked=${String(this.checked)}
        data-state=${this.checked ? 'checked' : 'unchecked'}
        ?data-highlighted=${this.#highlighted.get()}
        class=${checkbox ? dropdownMenuCheckboxItemClass() : dropdownMenuRadioItemClass()}
        @click=${this._onClick}
        @pointerenter=${this._onPointerEnter}
        @focus=${this._onFocus}
        @blur=${this._onBlur}
      ><span
        class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
      >${this.checked ? unsafeHTML(radio ? RADIO_DOT_SVG : CHECK_SVG) : ''}</span><slot></slot></div>`;
    }
    return html`<div
      data-slot="dropdown-menu-item"
      role="menuitem"
      tabindex="-1"
      data-variant=${this.variant}
      ?data-inset=${this.inset}
      ?data-disabled=${disabled}
      aria-disabled=${disabled ? 'true' : 'false'}
      ?data-highlighted=${this.#highlighted.get()}
      class=${dropdownMenuItemClass()}
      @click=${this._onClick}
      @pointerenter=${this._onPointerEnter}
      @focus=${this._onFocus}
      @blur=${this._onBlur}
    ><slot></slot></div>`;
  }

  _onClick = (e: Event): void => {
    const el = e.currentTarget as HTMLElement;
    if (el.hasAttribute('data-disabled')) return;
    this._select();
  };

  // Activation: flip checkbox / radio state, announce the selection, then
  // close. The event is cancelable, so a listener calling preventDefault()
  // keeps the menu open, which is the parity shape for Radix's
  // onSelect(e => e.preventDefault()) that a multi-select menu relies on.
  _select(): void {
    if (this.type === 'checkbox') this.checked = !this.checked;
    else if (this.type === 'radio') this._selectRadio();
    const proceed = this.dispatchEvent(
      new CustomEvent('ui-item-select', {
        detail: { value: this.value, item: this, type: this.type, checked: this.checked },
        bubbles: true,
        cancelable: true,
      }),
    );
    if (!proceed) return;
    (this.closest('ui-dropdown-menu') as UiDropdownMenu | null)?._closeAndRestoreFocus();
  }

  // The radio set is the nearest <ui-dropdown-menu-group>, which is the APG
  // grouping element for menuitemradio, falling back to the panel so an
  // ungrouped set still behaves like one set rather than N independent items.
  _radioScope(): Element | null {
    if (typeof this.closest !== 'function') return null;
    return (
      this.closest('ui-dropdown-menu-group') ??
      this.closest('ui-dropdown-menu-sub-content') ??
      this.closest('ui-dropdown-menu-content')
    );
  }

  _selectRadio(): void {
    const scope = this._radioScope();
    if (!scope) {
      this.checked = true;
      return;
    }
    // Filter on the PROPERTY, not a [type="radio"] attribute selector: `type`
    // is unreflected, so an item configured through the property alone would
    // be invisible to an attribute query and survive as a second checked item.
    // The scope re-check keeps a nested group's items out of this set.
    Array.from(scope.querySelectorAll<UiDropdownMenuItem>('ui-dropdown-menu-item'))
      .filter((it) => it.type === 'radio' && it._radioScope() === scope)
      .forEach((it) => {
        it.checked = it === this;
      });
  }

  _onPointerEnter = (e: Event): void => {
    const el = e.currentTarget as HTMLElement;
    if (el.hasAttribute('data-disabled')) return;
    el.focus();
  };

  _onFocus = (): void => {
    this.#highlighted.set(true);
  };

  _onBlur = (): void => {
    this.#highlighted.set(false);
  };
}
UiDropdownMenuItem.register('ui-dropdown-menu-item');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-label>
// --------------------------------------------------------------------------

export class UiDropdownMenuLabel extends WebComponent({ inset: Boolean }) {
  constructor() {
    super();
    this.inset = false;
  }

  render() {
    return html`<div
      data-slot="dropdown-menu-label"
      ?data-inset=${this.inset}
      class=${dropdownMenuLabelClass()}
    ><slot></slot></div>`;
  }
}
UiDropdownMenuLabel.register('ui-dropdown-menu-label');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-separator>
// --------------------------------------------------------------------------

export class UiDropdownMenuSeparator extends WebComponent {
  render() {
    return html`<div
      data-slot="dropdown-menu-separator"
      role="separator"
      class=${dropdownMenuSeparatorClass()}
    ></div>`;
  }
}
UiDropdownMenuSeparator.register('ui-dropdown-menu-separator');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-shortcut>
// --------------------------------------------------------------------------

export class UiDropdownMenuShortcut extends WebComponent {
  render() {
    return html`<span
      data-slot="dropdown-menu-shortcut"
      class=${dropdownMenuShortcutClass()}
    ><slot></slot></span>`;
  }
}
UiDropdownMenuShortcut.register('ui-dropdown-menu-shortcut');

// --------------------------------------------------------------------------
// <ui-dropdown-menu-group>
// --------------------------------------------------------------------------

export class UiDropdownMenuGroup extends WebComponent {
  // role="group", NOT role="radiogroup". radiogroup is the grouping role for
  // role="radio"; the grouping role ARIA specifies for menuitemradio is plain
  // group, so a radio set inside this element is already correctly grouped.
  render() {
    // The group carries the name of the set ("Sort by", "Panel position"), and
    // the name has to be on the element that holds role="group" to be exposed.
    // Forward it off the host so an author writes it where the tag is.
    //
    // Branched, not held in a null hole: a null hole does NOT omit an attribute
    // on the server, so an unnamed group would ship
    // `role="group" aria-label="" aria-labelledby=""`, an empty IDREF list and
    // an empty name on an element that simply has no name.
    const attr = (n: string): string | null =>
      typeof this.getAttribute === 'function' ? this.getAttribute(n) : null;
    const labelledBy = attr('aria-labelledby');
    const label = attr('aria-label');
    if (labelledBy) {
      return html`<div
        data-slot="dropdown-menu-group"
        role="group"
        aria-labelledby=${labelledBy}
      ><slot></slot></div>`;
    }
    if (label) {
      return html`<div
        data-slot="dropdown-menu-group"
        role="group"
        aria-label=${label}
      ><slot></slot></div>`;
    }
    return html`<div
      data-slot="dropdown-menu-group"
      role="group"
    ><slot></slot></div>`;
  }
}
UiDropdownMenuGroup.register('ui-dropdown-menu-group');

// --------------------------------------------------------------------------
// Submenu: Sub / SubTrigger / SubContent
// --------------------------------------------------------------------------

export class UiDropdownMenuSub extends WebComponent({
  open: prop(Boolean, { reflect: true }),
}) {
  _closeTimer: number | undefined;

  constructor() {
    super();
    this.open = false;
  }

  disconnectedCallback(): void {
    this._cancelClose();
    super.disconnectedCallback?.();
  }

  show(): void { this._cancelClose(); this.open = true; }
  hide(): void { this._cancelClose(); this.open = false; }
  toggle(): void { if (this.open) this.hide(); else this.show(); }

  // ArrowRight on a sub-trigger must open the submenu AND move focus to its
  // first item (APG, and what this component's JSDoc promises). The focus
  // cannot simply be queued next to show(): the panel is popover="manual" and
  // only becomes visible in _afterRender one microtask later, and focus() on a
  // still-display:none element is silently a no-op that nothing retries. So
  // record the intent and consume it once the panel is genuinely up.
  _focusFirstOnOpen = false;

  openAndFocusFirstItem(): void {
    this._focusFirstOnOpen = true;
    this.show();
    // Hover already opened it, so there is no open-change to ride on and
    // _afterRender will not run. The panel is up, so consume the intent now.
    if (this._panelIsOpen()) this._consumeFocusFirst();
  }

  _panelIsOpen(): boolean {
    const panel = this.querySelector<HTMLElement & { showPopover?: () => void }>(
      'ui-dropdown-menu-sub-content [popover]',
    );
    // Gate on showPopover, not just on matches, like the two sibling call sites.
    // `:popover-open` is an unknown pseudo-class where the Popover API is
    // absent, so matches() THROWS SyntaxError there. This runs synchronously
    // from the document keydown handler, so the throw would escape _onKeyDown
    // and take out all submenu keyboard handling on such an engine.
    if (!panel || typeof panel.showPopover !== 'function') return false;
    return typeof panel.matches === 'function' && panel.matches(':popover-open');
  }

  _consumeFocusFirst(): void {
    if (!this._focusFirstOnOpen) return;
    this._focusFirstOnOpen = false;
    this.querySelector<HTMLElement>(
      `ui-dropdown-menu-sub-content ${ENABLED_MENU_ITEM}`,
    )?.focus();
  }

  render() {
    return html`<div
      data-slot="dropdown-menu-sub"
      data-state=${this.open ? 'open' : 'closed'}
      @pointerenter=${this._cancelCloseHandler}
      @pointerleave=${this._scheduleCloseHandler}
    ><slot></slot></div>`;
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (!changedProperties.has('open')) return;
    if (changedProperties.get('open') === undefined) return;
    queueMicrotask(() => this._afterRender());
  }

  _afterRender(): void {
    const subContent = this.querySelector<HTMLElement>('ui-dropdown-menu-sub-content [popover]');
    if (subContent) {
      const p = subContent as HTMLElement & {
        showPopover?: () => void;
        hidePopover?: () => void;
        matches: (s: string) => boolean;
      };
      if (typeof p.showPopover === 'function') {
        if (this.open && !p.matches(':popover-open')) p.showPopover();
        else if (!this.open && p.matches(':popover-open')) p.hidePopover();
      }
    }
    if (this.open) {
      this._position();
      this._consumeFocusFirst();
    } else {
      // Closed before the intent was consumed (a hover-close raced the key).
      // Dropping it keeps a later hover-open from stealing focus into the panel.
      this._focusFirstOnOpen = false;
    }
  }

  _cancelCloseHandler = (): void => this._cancelClose();
  // Hover-close is a mouse affordance. On touch, lifting the finger fires
  // pointerleave; without this guard a tap-opened submenu would close itself
  // ~200ms after the tap. On touch the submenu stays open until tapped again.
  _scheduleCloseHandler = (e: Event): void => {
    if ((e as PointerEvent).pointerType === 'touch') return;
    this._scheduleClose();
  };

  _scheduleClose(): void {
    this._cancelClose();
    this._closeTimer = window.setTimeout(() => this.hide(), SUB_CLOSE_DELAY);
  }

  _cancelClose(): void {
    if (this._closeTimer !== undefined) {
      clearTimeout(this._closeTimer);
      this._closeTimer = undefined;
    }
  }

  _position(): void {
    const trigger = this.querySelector<HTMLElement>(
      'ui-dropdown-menu-sub-trigger [role="menuitem"]',
    );
    const content = this.querySelector<HTMLElement>(
      'ui-dropdown-menu-sub-content [popover]',
    );
    const contentHost = this.querySelector<HTMLElement>('ui-dropdown-menu-sub-content');
    if (!trigger || !content || !contentHost) return;
    positionFloating(trigger, content, {
      side: (contentHost.getAttribute('side') ?? 'right') as PopoverSide,
      align: (contentHost.getAttribute('align') ?? 'start') as PopoverAlign,
      sideOffset: Number(contentHost.getAttribute('side-offset') ?? -4),
      alignOffset: Number(contentHost.getAttribute('align-offset') ?? 0),
    });
  }
}
UiDropdownMenuSub.register('ui-dropdown-menu-sub');

export class UiDropdownMenuSubTrigger extends WebComponent({ inset: Boolean }) {
  constructor() {
    super();
    this.inset = false;
  }

  // SSR-safe: linkedom doesn't implement closest() on custom elements.
  _sub(): UiDropdownMenuSub | null {
    if (typeof this.closest !== 'function') return null;
    return this.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
  }

  render() {
    const open = !!this._sub()?.open;
    const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
    return html`<div
      data-slot="dropdown-menu-sub-trigger"
      role="menuitem"
      tabindex="-1"
      aria-haspopup="menu"
      aria-expanded=${String(open)}
      aria-disabled=${disabled ? 'true' : 'false'}
      data-state=${open ? 'open' : 'closed'}
      ?data-inset=${this.inset}
      ?data-disabled=${disabled}
      class=${dropdownMenuSubTriggerClass()}
      @click=${this._onClick}
      @pointerenter=${this._onPointerEnter}
    ><slot></slot>${unsafeHTML(CHEVRON_RIGHT_SVG)}</div>`;
  }

  _onClick = (e: Event): void => {
    const el = e.currentTarget as HTMLElement;
    if (el.hasAttribute('data-disabled')) return;
    this._sub()?.toggle();
  };

  _onPointerEnter = (e: Event): void => {
    // Hover-open is a mouse affordance. On touch there is no hover: a tap
    // fires pointerenter on finger-down, which would open the submenu only for
    // the following click to toggle it shut. On touch, @click is the opener.
    if ((e as PointerEvent).pointerType === 'touch') return;
    const el = e.currentTarget as HTMLElement;
    if (el.hasAttribute('data-disabled')) return;
    el.focus();
    this._sub()?.show();
  };
}
UiDropdownMenuSubTrigger.register('ui-dropdown-menu-sub-trigger');

export class UiDropdownMenuSubContent extends WebComponent {
  render() {
    return html`<div
      data-slot="dropdown-menu-sub-content"
      role="menu"
      aria-orientation="vertical"
      popover="manual"
      class=${dropdownMenuSubContentClass()}
    ><slot></slot></div>`;
  }
}
UiDropdownMenuSubContent.register('ui-dropdown-menu-sub-content');