Skip to content
Brok UI

Loading…

No results

Menus

Open source Core

Action menus — a dropdown on a trigger, a right-click context menu, an application menubar and an inline-expanding nested menu.

Version
v1.12.1
Stability
stable
License
MIT
Related
Context Menu
Menubar
Nested Menu

Dropdown Menu — Accessible menu with roving focus, type-ahead, click-outside dismissal and Escape handling. Sub-parts cover labels, groups, shortcuts, checkbox/radio items and nested submenus. Opt into `anchored` to teleport the panel past a clipping/scrolling ancestor.

Preview

Align
Disabled
previews.components.dropdown.default.blade.php Blade
<x-ui.dropdown>
    <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Options') }}
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content>
        <x-ui.dropdown.label>{{ __('My Account') }}</x-ui.dropdown.label>
        <x-ui.dropdown.item>{{ __('Profile') }}</x-ui.dropdown.item>
        <x-ui.dropdown.item>{{ __('Billing') }}</x-ui.dropdown.item>
        <x-ui.dropdown.item>{{ __('Settings') }}</x-ui.dropdown.item>
        <x-ui.dropdown.separator />
        <x-ui.dropdown.item>{{ __('Sign out') }}</x-ui.dropdown.item>
    </x-ui.dropdown.content>
</x-ui.dropdown>
Start Current
Center Current
End Current

Installation

terminal
php artisan ui:add dropdown

Note

This component ships an Alpine behavior module at resources/js/ui/dropdown.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './dropdown.js';

Registry contract

php artisan ui:add dropdown writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/dropdown.blade.php
  • blade resources/views/components/ui/dropdown/trigger.blade.php
  • blade resources/views/components/ui/dropdown/content.blade.php
  • blade resources/views/components/ui/dropdown/item.blade.php
  • blade resources/views/components/ui/dropdown/label.blade.php
  • blade resources/views/components/ui/dropdown/separator.blade.php
  • blade resources/views/components/ui/dropdown/shortcut.blade.php
  • blade resources/views/components/ui/dropdown/group.blade.php
  • blade resources/views/components/ui/dropdown/checkbox-item.blade.php
  • blade resources/views/components/ui/dropdown/radio-group.blade.php
  • blade resources/views/components/ui/dropdown/radio-item.blade.php
  • blade resources/views/components/ui/dropdown/submenu.blade.php
  • blade resources/views/components/ui/dropdown/sub-trigger.blade.php
  • blade resources/views/components/ui/dropdown/sub-content.blade.php
  • js resources/js/ui/dropdown.js
  • js resources/js/ui/overlay-position.js
Registry dependencies
kbd
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

dropdown.md
# Brok UI: Dropdown Menu (`dropdown`)

Accessible menu with roving focus, type-ahead, click-outside dismissal and Escape handling. Sub-parts cover labels, groups, shortcuts, checkbox/radio items and nested submenus. Opt into `anchored` to teleport the panel past a clipping/scrolling ancestor.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add dropdown
```

## Usage

```blade
<x-ui.dropdown>
    <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Options') }}
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content>
        <x-ui.dropdown.label>{{ __('My Account') }}</x-ui.dropdown.label>
        <x-ui.dropdown.item>{{ __('Profile') }}</x-ui.dropdown.item>
        <x-ui.dropdown.item>{{ __('Billing') }}</x-ui.dropdown.item>
        <x-ui.dropdown.item>{{ __('Settings') }}</x-ui.dropdown.item>
        <x-ui.dropdown.separator />
        <x-ui.dropdown.item>{{ __('Sign out') }}</x-ui.dropdown.item>
    </x-ui.dropdown.content>
</x-ui.dropdown>
```

## Props

- `anchored` (bool, default `false`) — Teleport the menu to <body> and position it against the trigger's measured bounding rect, flipping/clamping to stay in the viewport. Use inside a scrollable or `overflow`-clipping ancestor (e.g. a data-table row).
- `align` (start|center|end, default `start`) — Documented catalog control used by the preview workbench.
- `disabled` (false|true, default `false`) — Documented catalog control used by the preview workbench.
- `variant` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `size` (string, default `md`) — Declared by @props in the registry Blade source.
- `label` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `description` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `anchor` (string, default `trigger`) — Declared by @props in the registry Blade source.
- `show` (string, default `open`) — Declared by @props in the registry Blade source.
- `href` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `type` (string, default `button`) — Declared by @props in the registry Blade source.
- `keys` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `checked` (bool, default `false`) — Declared by @props in the registry Blade source.

## Use when

- Use for secondary actions when compact access is more important than constant visibility.
- Grouping several related actions or options behind a single trigger button.
- Needing checkbox items, radio groups, shortcuts, or nested submenus inside a menu.

## Avoid when

- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- The options should be visible at a glance without a click; use a visible group of buttons or radio-tabs.
- Right-click is the expected trigger instead of a visible button; use context-menu.

## Anti-patterns

- Hiding primary actions in menus

## Rules

- Use the `<brok:dropdown>` tag (or `<x-ui.dropdown>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/dropdown
- Registry JSON (files, props, contract): https://brokui.dev/r/open/dropdown.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

anchored.blade.php Blade
{{--
    `anchored` teleports the menu past a clipping/scrolling ancestor — the
    exact problem a data-table row's `overflow-hidden` cell creates. The row
    below is wrapped in a short `overflow-hidden` box to make that concrete:
    without `anchored` the menu would be cut off at the box edge instead of
    floating over the page. The tall spacer lets a test scroll the trigger
    toward the bottom of the viewport to exercise the flip-to-top behaviour.
--}}
<div class="mx-auto flex w-full max-w-md flex-col gap-6 p-6">
    <p class="text-sm text-muted-foreground">
        {{ __('Scroll down — the trigger sits inside a short, overflow-hidden row.') }}
    </p>

    <div class="h-dvh"></div>

    <div class="h-16 overflow-hidden rounded-lg border border-border bg-card">
        <div class="flex h-full items-center justify-between px-4">
            <span class="text-sm text-foreground">{{ __('Order #4821') }}</span>

            <x-ui.dropdown anchored>
                <x-ui.dropdown.trigger
                    class="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="12" cy="5" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="19" r="1" /></svg>
                    <span class="sr-only">{{ __('Row actions') }}</span>
                </x-ui.dropdown.trigger>

                <x-ui.dropdown.content align="end">
                    <x-ui.dropdown.item>{{ __('View order') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.item>{{ __('Duplicate') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.separator />
                    <x-ui.dropdown.item class="text-destructive focus:bg-destructive/10 focus:text-destructive hover:bg-destructive/10 hover:text-destructive">{{ __('Cancel order') }}</x-ui.dropdown.item>
                </x-ui.dropdown.content>
            </x-ui.dropdown>
        </div>
    </div>

    <div class="h-dvh"></div>
</div>
checkbox.blade.php Blade
{{-- Checkbox items toggling local panel-visibility state (role="menuitemcheckbox"). --}}
<div x-data="{ panels: { status: true, activity: false, files: true } }">
    <x-ui.dropdown>
        <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
            {{ __('Panels') }}
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
        </x-ui.dropdown.trigger>

        <x-ui.dropdown.content>
            <x-ui.dropdown.label>{{ __('Appearance') }}</x-ui.dropdown.label>
            <x-ui.dropdown.checkbox-item x-bind:aria-checked="panels.status" x-on:click="panels.status = !panels.status">
                {{ __('Status bar') }}
            </x-ui.dropdown.checkbox-item>
            <x-ui.dropdown.checkbox-item x-bind:aria-checked="panels.activity" x-on:click="panels.activity = !panels.activity">
                {{ __('Activity bar') }}
            </x-ui.dropdown.checkbox-item>
            <x-ui.dropdown.checkbox-item x-bind:aria-checked="panels.files" x-on:click="panels.files = !panels.files">
                {{ __('File explorer') }}
            </x-ui.dropdown.checkbox-item>
        </x-ui.dropdown.content>
    </x-ui.dropdown>
</div>
custom-class.blade.php Blade
{{--
    Regression fixture: a consumer-supplied `class` on `<x-ui.dropdown.content>`
    used to be silently discarded (`$attributes->except('class')` dropped it
    instead of merging), so a fixed panel width and a `max-h-*` scroll cap both
    went dead without any error. `w-[232px]`/`max-h-[320px]` are the exact
    classes from the reported bug — a long action list must scroll inside the
    capped height rather than growing the panel past it.
--}}
<x-ui.dropdown>
    <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Options') }}
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content class="w-[232px] max-h-[320px] overflow-y-auto">
        @for ($i = 1; $i <= 20; $i++)
            <x-ui.dropdown.item>{{ __('Action :n', ['n' => $i]) }}</x-ui.dropdown.item>
        @endfor
    </x-ui.dropdown.content>
</x-ui.dropdown>
radio.blade.php Blade
{{-- Single-choice radio group selecting the active position (role="menuitemradio"). --}}
<div x-data="{ position: 'bottom' }">
    <x-ui.dropdown>
        <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
            {{ __('Panel position') }}
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
        </x-ui.dropdown.trigger>

        <x-ui.dropdown.content>
            <x-ui.dropdown.label>{{ __('Position') }}</x-ui.dropdown.label>
            <x-ui.dropdown.radio-group aria-label="{{ __('Panel position') }}">
                <x-ui.dropdown.radio-item x-bind:aria-checked="position === 'top'" x-on:click="position = 'top'">
                    {{ __('Top') }}
                </x-ui.dropdown.radio-item>
                <x-ui.dropdown.radio-item x-bind:aria-checked="position === 'bottom'" x-on:click="position = 'bottom'">
                    {{ __('Bottom') }}
                </x-ui.dropdown.radio-item>
                <x-ui.dropdown.radio-item x-bind:aria-checked="position === 'right'" x-on:click="position = 'right'">
                    {{ __('Right') }}
                </x-ui.dropdown.radio-item>
            </x-ui.dropdown.radio-group>
        </x-ui.dropdown.content>
    </x-ui.dropdown>
</div>
rich.blade.php Blade
{{-- Icons, grouped items, keyboard-shortcut hints, a disabled item and a destructive action. --}}
<x-ui.dropdown>
    <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Actions') }}
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content>
        <x-ui.dropdown.label>{{ __('My Account') }}</x-ui.dropdown.label>
        <x-ui.dropdown.group aria-label="{{ __('Account') }}">
            <x-ui.dropdown.item>
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="12" cy="8" r="4" /><path d="M20 21a8 8 0 0 0-16 0" /></svg>
                {{ __('Profile') }}
                <x-ui.dropdown.shortcut>⇧⌘P</x-ui.dropdown.shortcut>
            </x-ui.dropdown.item>
            <x-ui.dropdown.item>
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
                {{ __('Settings') }}
                <x-ui.dropdown.shortcut>⌘,</x-ui.dropdown.shortcut>
            </x-ui.dropdown.item>
            <x-ui.dropdown.item :disabled="true">
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><rect x="2" y="7" width="20" height="14" rx="2" /><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" /></svg>
                {{ __('Team (coming soon)') }}
            </x-ui.dropdown.item>
        </x-ui.dropdown.group>

        <x-ui.dropdown.separator />

        <x-ui.dropdown.item class="text-destructive focus:bg-destructive/10 focus:text-destructive hover:bg-destructive/10 hover:text-destructive">
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
            {{ __('Delete account') }}
            <x-ui.dropdown.shortcut>⌘⌫</x-ui.dropdown.shortcut>
        </x-ui.dropdown.item>
    </x-ui.dropdown.content>
</x-ui.dropdown>
row-trigger.blade.php Blade
{{-- variant="row": the workspace menu at the top of a sidebar and the user
     menu at the bottom. Full width, start-aligned, a two-line label and an
     up-down chevron; the slot is the leading mark. --}}
<div class="flex w-64 max-w-full flex-col gap-2 rounded-lg border border-border bg-card p-2">
    <x-ui.dropdown class="w-full">
        <x-ui.dropdown.trigger variant="row" :label="__('Northwind Studio')" :description="__('Internal workspace')">
            <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary text-sm font-semibold text-primary-foreground" aria-hidden="true">N</span>
        </x-ui.dropdown.trigger>
        <x-ui.dropdown.content class="w-60">
            <x-ui.dropdown.label>{{ __('Workspaces') }}</x-ui.dropdown.label>
            <x-ui.dropdown.item href="#">{{ __('Northwind Studio') }}</x-ui.dropdown.item>
            <x-ui.dropdown.item href="#">{{ __('Harbour Bakery') }}</x-ui.dropdown.item>
            <x-ui.dropdown.separator />
            <x-ui.dropdown.item href="#">{{ __('Workspace settings') }}</x-ui.dropdown.item>
        </x-ui.dropdown.content>
    </x-ui.dropdown>

    <x-ui.dropdown class="w-full">
        <x-ui.dropdown.trigger variant="row" :label="__('Sanne de Vries-Bakker')" :description="__('sanne.devries-bakker@northwind.example')">
            <span class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground" aria-hidden="true">SV</span>
        </x-ui.dropdown.trigger>
        <x-ui.dropdown.content class="w-60">
            <x-ui.dropdown.item href="#">{{ __('Profile') }}</x-ui.dropdown.item>
            <x-ui.dropdown.item href="#">{{ __('Log out') }}</x-ui.dropdown.item>
        </x-ui.dropdown.content>
    </x-ui.dropdown>
</div>
row-trigger-sidebar.blade.php Blade
{{-- variant="row" inside a collapsible sidebar: fold the rail from the trigger
     and the workspace and user menus fold to their mark, a square icon button
     named by its label (also its title). Expanded, they are the full row. --}}
<div class="flex h-[28rem] w-full overflow-hidden rounded-lg border border-border" data-surface="admin">
    <x-ui.sidebar state-key="brok:docs:dropdown-row-trigger">
        <x-ui.sidebar.header class="flex-col items-stretch">
            <x-ui.dropdown class="w-full">
                <x-ui.dropdown.trigger variant="row" :label="__('Northwind Studio')" :description="__('Internal workspace')">
                    <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-foreground text-sm font-semibold text-background" aria-hidden="true">N</span>
                </x-ui.dropdown.trigger>
                <x-ui.dropdown.content class="w-60">
                    <x-ui.dropdown.label>{{ __('Workspaces') }}</x-ui.dropdown.label>
                    <x-ui.dropdown.item href="#">{{ __('Northwind Studio') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.item href="#">{{ __('Harbour Bakery') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.separator />
                    <x-ui.dropdown.item href="#">{{ __('Workspace settings') }}</x-ui.dropdown.item>
                </x-ui.dropdown.content>
            </x-ui.dropdown>
        </x-ui.sidebar.header>

        <x-ui.sidebar.content>
            <x-ui.sidebar.group>
                <x-ui.sidebar.menu>
                    <x-ui.sidebar.menu-item>
                        <x-ui.sidebar.menu-button href="#" :active="true" :label="__('Inbox')"><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"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg></x-ui.sidebar.menu-button>
                    </x-ui.sidebar.menu-item>
                    <x-ui.sidebar.menu-item>
                        <x-ui.sidebar.menu-button href="#" :label="__('Projects')"><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"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></x-ui.sidebar.menu-button>
                    </x-ui.sidebar.menu-item>
                </x-ui.sidebar.menu>
            </x-ui.sidebar.group>
        </x-ui.sidebar.content>

        <x-ui.sidebar.footer>
            <x-ui.sidebar.trigger />
            <x-ui.dropdown class="w-full">
                <x-ui.dropdown.trigger variant="row" size="sm" :label="__('Sanne de Vries-Bakker')">
                    <span class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-2xs font-medium text-muted-foreground" aria-hidden="true">SV</span>
                </x-ui.dropdown.trigger>
                <x-ui.dropdown.content class="w-60">
                    <x-ui.dropdown.item href="#">{{ __('Profile') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.item href="#">{{ __('Log out') }}</x-ui.dropdown.item>
                </x-ui.dropdown.content>
            </x-ui.dropdown>
        </x-ui.sidebar.footer>
    </x-ui.sidebar>

    <x-ui.sidebar.inset class="p-6">
        <h2 class="text-lg font-semibold text-foreground">{{ __('Inbox') }}</h2>
        <p class="mt-2 text-sm text-muted-foreground">{{ __('Fold the sidebar from the trigger: the workspace and user menus keep only their mark.') }}</p>
    </x-ui.sidebar.inset>
</div>
shortcuts.blade.php Blade
{{-- A row menu with platform shortcut labels (keys renders hotkeys.hint:
     ⌘ on macOS, Ctrl elsewhere), a ghost small trigger from the button
     recipe, and a destructive row. --}}
<x-ui.dropdown>
    <x-ui.dropdown.trigger variant="ghost" size="sm" aria-label="{{ __('Task actions') }}">
        {{ __('Actions') }}
        <x-ui.icon name="chevron-down" size="sm" />
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content>
        <x-ui.dropdown.item aria-keyshortcuts="S">
            {{ __('Change status') }}
            <x-ui.dropdown.shortcut keys="s" />
        </x-ui.dropdown.item>
        <x-ui.dropdown.item aria-keyshortcuts="A">
            <x-ui.icon name="user" />
            {{ __('Assign to') }}
            <x-ui.dropdown.shortcut keys="a" />
        </x-ui.dropdown.item>
        <x-ui.dropdown.item>
            {{ __('Mark as blocked') }}
            <x-ui.dropdown.shortcut keys="m b" />
        </x-ui.dropdown.item>
        <x-ui.dropdown.item aria-keyshortcuts="Control+Period">
            {{ __('Copy ID') }}
            <x-ui.dropdown.shortcut keys="mod+." />
        </x-ui.dropdown.item>
        <x-ui.dropdown.separator />
        <x-ui.dropdown.item variant="destructive" aria-keyshortcuts="Control+Backspace">
            {{ __('Delete task') }}
            <x-ui.dropdown.shortcut keys="mod+backspace" />
        </x-ui.dropdown.item>
    </x-ui.dropdown.content>
</x-ui.dropdown>
submenu.blade.php Blade
{{-- Nested submenu: ArrowRight (or hover) opens it, ArrowLeft/Escape returns. --}}
<x-ui.dropdown>
    <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Account') }}
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content>
        <x-ui.dropdown.item>{{ __('New tab') }}</x-ui.dropdown.item>
        <x-ui.dropdown.item>{{ __('New window') }}</x-ui.dropdown.item>

        <x-ui.dropdown.submenu>
            <x-ui.dropdown.sub-trigger>
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4M15.4 6.5 8.6 10.5" /></svg>
                {{ __('Share') }}
            </x-ui.dropdown.sub-trigger>
            <x-ui.dropdown.sub-content>
                <x-ui.dropdown.item>{{ __('Email link') }}</x-ui.dropdown.item>
                <x-ui.dropdown.item>{{ __('Copy link') }}</x-ui.dropdown.item>
                <x-ui.dropdown.separator />
                <x-ui.dropdown.item>{{ __('Invite people') }}</x-ui.dropdown.item>
            </x-ui.dropdown.sub-content>
        </x-ui.dropdown.submenu>

        <x-ui.dropdown.separator />
        <x-ui.dropdown.item>{{ __('Sign out') }}</x-ui.dropdown.item>
    </x-ui.dropdown.content>
</x-ui.dropdown>
with-icons.blade.php Blade
{{-- A leading <x-ui.icon> in an item or a sub-trigger is sized to 16px and muted; it brightens
     with the row on hover and focus. An icon with its own text colour keeps it. --}}
<x-ui.dropdown>
    <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Account') }}
        <x-ui.icon name="chevron-down" size="sm" />
    </x-ui.dropdown.trigger>

    <x-ui.dropdown.content>
        <x-ui.dropdown.item href="#">
            <x-ui.icon name="user" />
            {{ __('Profile') }}
        </x-ui.dropdown.item>
        <x-ui.dropdown.item href="#">
            <x-ui.icon name="building" />
            {{ __('Workspace settings') }}
        </x-ui.dropdown.item>
        <x-ui.dropdown.item href="#">
            <x-ui.icon name="sliders" />
            {{ __('Preferences') }}
            <x-ui.dropdown.shortcut>⌘,</x-ui.dropdown.shortcut>
        </x-ui.dropdown.item>

        <x-ui.dropdown.submenu>
            <x-ui.dropdown.sub-trigger>
                <x-ui.icon name="languages" />
                {{ __('Language') }}
            </x-ui.dropdown.sub-trigger>
            <x-ui.dropdown.sub-content>
                <x-ui.dropdown.item lang="en">English</x-ui.dropdown.item>
                <x-ui.dropdown.item lang="nl">Nederlands</x-ui.dropdown.item>
            </x-ui.dropdown.sub-content>
        </x-ui.dropdown.submenu>

        <x-ui.dropdown.separator />
        <x-ui.dropdown.item>
            <x-ui.icon name="arrow-left-right" />
            {{ __('Switch account') }}
        </x-ui.dropdown.item>

        <x-ui.dropdown.item class="text-destructive focus:bg-destructive/10 focus:text-destructive hover:bg-destructive/10 hover:text-destructive">
            <x-ui.icon name="log-out" class="text-current" />
            {{ __('Sign out') }}
        </x-ui.dropdown.item>
    </x-ui.dropdown.content>
</x-ui.dropdown>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
anchored bool false Teleport the menu to <body> and position it against the trigger's measured bounding rect, flipping/clamping to stay in the viewport. Use inside a scrollable or `overflow`-clipping ancestor (e.g. a data-table row).
align start | center | end start Documented catalog control used by the preview workbench.
disabled false | true false Documented catalog control used by the preview workbench.
variant mixed | null null Declared by @props in the registry Blade source.
size string md Declared by @props in the registry Blade source.
label mixed | null null Declared by @props in the registry Blade source.
description mixed | null null Declared by @props in the registry Blade source.
anchor string trigger Declared by @props in the registry Blade source.
show string open Declared by @props in the registry Blade source.
href mixed | null null Declared by @props in the registry Blade source.
type string button Declared by @props in the registry Blade source.
keys mixed | null null Declared by @props in the registry Blade source.
checked bool false Declared by @props in the registry Blade source.

Slots

  • default — Compositional parts: trigger, content, and item variants.
  • x-ui.dropdown.trigger — Installed subcomponent from the registry item.
  • x-ui.dropdown.content — Installed subcomponent from the registry item.
  • x-ui.dropdown.item — Installed subcomponent from the registry item.
  • x-ui.dropdown.label — Installed subcomponent from the registry item.
  • x-ui.dropdown.separator — Installed subcomponent from the registry item.
  • x-ui.dropdown.shortcut — Installed subcomponent from the registry item.
  • x-ui.dropdown.group — Installed subcomponent from the registry item.
  • x-ui.dropdown.checkbox-item — Installed subcomponent from the registry item.
  • x-ui.dropdown.radio-group — Installed subcomponent from the registry item.
  • x-ui.dropdown.radio-item — Installed subcomponent from the registry item.
  • x-ui.dropdown.submenu — Installed subcomponent from the registry item.
  • x-ui.dropdown.sub-trigger — Installed subcomponent from the registry item.
  • x-ui.dropdown.sub-content — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

<their name> dropdown-checkbox-item dropdown-group dropdown-radio-group dropdown-radio-item dropdown-sub-content dropdown-sub-trigger dropdown-submenu dropdown-trigger dropdown-trigger-label status-select {{ $slotName }}

Behavior

  • A leading `<x-ui.icon>` placed first inside `dropdown.item` or `dropdown.sub-trigger` is sized to 16px and muted, and brightens with the row on hover, focus and an open submenu; an icon that sets its own `text-*` class (for example `text-current` in a destructive row) keeps that colour. Hand-written `<svg>` children are left as authored.
  • Opening the menu moves roving focus to the first item; arrow keys move focus between items and typing jumps to a matching item (type-ahead).
  • Clicking outside the menu or pressing Escape closes it and returns focus to the trigger. Focus moves in the same task, before the menu hides, so a fast key press never reaches the closing menu and a quick re-open cannot race the return.
  • The opt-in anchored prop teleports the panel so it is not clipped by a scrolling or overflow-hidden ancestor.
  • Anchored placement (flip above the anchor on real overflow, clamp into the viewport, data-side) lives in overlay-position.js, shipped with this item and shared with menubar and context-menu, which compose dropdown.content with the show and anchor props.
  • Submenus open on hover or the right arrow key and share the same roving focus and type-ahead behavior as the root menu.
  • A variant="row" trigger follows the collapsible sidebar around it: while the sidebar is collapsed (data-state="collapsed" on the sidebar root) the description and chevron hide, the label stays for assistive tech and becomes the title, and the button is a centred square (32px for sm, 40px for md) around the slot. Outside a sidebar it is always the full row.
  • dropdown.shortcut keys="mod+shift+d" renders the hint as kbd keys with the same grammar and labels as hotkeys.hint, so a menu shows the same labels as the command palette and the shortcut help list; with the hotkeys registry installed they swap to the macOS glyphs once Alpine starts.
  • dropdown.trigger variant="ghost" size="sm" styles the trigger with the button recipe, so a menu trigger no longer needs a restyled class list or a nested button.
  • With keys and the hotkeys registry installed, dropdown.shortcut hides a shortcut without Ctrl, Cmd or Alt while single-key shortcuts are switched off ($store.hotkeys.characterKeys false) and shows it again when they are on. Without the registry it always shows.
  • Binds wire:model (Livewire) or x-model (Alpine) to its open state through x-modelable.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Secondary action menu

Provide compact access to secondary actions.

Use when

  • Use for secondary actions when compact access is more important than constant visibility.
  • Grouping several related actions or options behind a single trigger button.
  • Needing checkbox items, radio groups, shortcuts, or nested submenus inside a menu.

Avoid when

  • Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
  • The options should be visible at a glance without a click; use a visible group of buttons or radio-tabs.
  • Right-click is the expected trigger instead of a visible button; use context-menu.

Use instead

  • Visible button for frequent actions
  • Inline action bar

Anti-patterns

  • Hiding primary actions in menus
Anatomy
root dropdown-checkbox-item dropdown-content status-select dropdown-group dropdown-radio-group dropdown-radio-item dropdown-sub-content dropdown-sub-trigger dropdown-submenu dropdown-trigger
Theming hooks
menu

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Escape ArrowUp ArrowDown Home End
Focus
managed
  • The menu follows the WAI-ARIA menu pattern: role="menu" with roving tabindex, arrow-key navigation, and type-ahead.
  • Checkbox and radio items expose their state through aria-checked so assistive tech announces selection accurately.
  • Shortcut hints are decorative (aria-hidden); name the shortcut on the item with aria-keyshortcuts so assistive technology hears it once.
  • A destructive item keeps role="menuitem" and its visible label; the colour is never the only signal, so write the label as the action (Delete task).
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="dropdown-{{ $record->id }}">
    <x-ui.dropdown>
        <x-ui.dropdown.trigger class="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
            {{ __('Options') }}
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m6 9 6 6 6-6" /></svg>
        </x-ui.dropdown.trigger>
    
        <x-ui.dropdown.content>
            <x-ui.dropdown.label>{{ __('My Account') }}</x-ui.dropdown.label>
            <x-ui.dropdown.item>{{ __('Profile') }}</x-ui.dropdown.item>
            <x-ui.dropdown.item>{{ __('Billing') }}</x-ui.dropdown.item>
            <x-ui.dropdown.item>{{ __('Settings') }}</x-ui.dropdown.item>
            <x-ui.dropdown.separator />
            <x-ui.dropdown.item>{{ __('Sign out') }}</x-ui.dropdown.item>
        </x-ui.dropdown.content>
    </x-ui.dropdown>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:dropdown
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/dropdown.blade.php Blade
@props([
    'anchored' => false,
])

@php
    // Outside-click detection normally just checks containment in this root,
    // which is correct while the content panel renders as its DOM child. In
    // `anchored` mode the content teleports to <body> (so it can escape a
    // clipping/scrolling ancestor such as a data-table row), so it is no
    // longer a descendant of this root — a click inside it would otherwise be
    // misread as "outside" and close the menu instantly. Extending the check
    // to the teleported `$refs.menu` covers both cases from one expression,
    // and reduces to the original behaviour byte-for-byte when unanchored.
    $outsideClick = $anchored ? "if (!\$refs.menu?.contains(\$event.target)) close()" : 'close()';

    // A component that composes this one needs the root to carry ITS name:
    // `status-select`, `saved-views`, `color-dropdown` and `turn-into-menu` all
    // pass `data-slot="<their name>"`. That used to print a SECOND `data-slot`
    // attribute on this same tag, and the browser keeps only the first — so
    // `[data-slot="status-select"]` matched nothing in the DOM even though the
    // manifest documents it as that component's anatomy, and any consumer CSS
    // or script keyed on it silently did nothing. Read the override out of the
    // bag and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'dropdown');
@endphp

{{--
    Dropdown menu (spec §17 dropdown keyboard navigation + §19 behavior).
    Root holds open state and menu keyboard handling. Click-outside and Escape
    close the menu and return focus to the trigger.

    `anchored` (opt-in, default false): teleports the content panel to <body>
    and positions it against the trigger's measured bounding rect instead of
    CSS `absolute` positioning inside this relatively-positioned root. Use it
    when the trigger lives inside a container that clips overflow (a
    scrollable table, a `overflow-hidden` card, …). Keyboard behaviour, roving
    focus, typeahead and submenus are identical in both modes; only how the
    panel is placed in the DOM/viewport differs. See `content.blade.php` for
    the placement math.
--}}
<div
    x-data="{{ $anchored ? 'uiDropdown(true)' : 'uiDropdown()' }}"
    x-modelable="open"
    data-slot="{{ $slotName }}"
    @keydown.escape="if (open) { $event.stopPropagation(); closeAndFocus() }"
    @click.outside="{{ $outsideClick }}"
    {{ $attributes->except('data-slot')->merge(['class' => 'relative inline-block text-start']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/dropdown/trigger.blade.php Blade
@props([
    // Optional button look for the trigger, from the shared button recipe:
    // default | secondary | outline | ghost | link | destructive. Null (the
    // default) keeps the bare, unstyled trigger exactly as before, so a
    // consumer can still style it through `class`.
    //
    // `row` is the menu-row look of a sidebar header or footer menu (the
    // workspace or the user menu): full width, start-aligned, a quiet hover
    // like a sidebar row, a two-line label and an up-down chevron at the end.
    // The slot is the leading mark (a logo or an avatar). Inside a collapsed
    // `sidebar` (the icon rail) the row folds to the mark alone: a square
    // icon button named by `label` (kept for assistive tech and as its title).
    // Outside a sidebar it is always the full row.
    'variant' => null,
    // Button size when `variant` is set: sm | md | lg | icon | icon-sm | icon-lg.
    // For `row`: sm (32px, one line reads best) or md (48px, two lines).
    'size' => 'md',
    // With variant="row": the first line (the workspace or person) and an
    // optional second, muted line (the plan, the email).
    'label' => null,
    'description' => null,
])

@php
    $classes = 'inline-flex items-center justify-center';
    $isRow = $variant === 'row';
    if ($isRow) {
        $size = $size === 'sm' ? 'sm' : 'md';
        $classes = 'flex w-full min-w-0 items-center gap-2 rounded-md px-2 text-start text-sm text-foreground outline-none transition-colors '
            .'hover:bg-accent/60 aria-expanded:bg-accent focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none '
            .($size === 'sm' ? 'min-h-8 py-1' : 'min-h-12 py-2').' '
            // Collapsed sidebar rail: only the mark, centred in a square.
            .'group-data-[state=collapsed]/sidebar:mx-auto group-data-[state=collapsed]/sidebar:min-h-0 group-data-[state=collapsed]/sidebar:justify-center group-data-[state=collapsed]/sidebar:gap-0 group-data-[state=collapsed]/sidebar:p-0 '
            .($size === 'sm' ? 'group-data-[state=collapsed]/sidebar:size-8' : 'group-data-[state=collapsed]/sidebar:size-10');
    } elseif (filled($variant)) {
        $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
        $button = $styles['button'];
        $variant = $styles['normalizeVariant']($variant);
        $variant = array_key_exists($variant, $button['variants']) ? $variant : 'ghost';
        $size = array_key_exists((string) $size, $button['sizes']) ? (string) $size : 'md';
        $classes = trim($button['base'].' '.$button['variants'][$variant].' '.($variant === 'link' ? $button['linkSizes'][$size] : $button['sizes'][$size]));
    }
@endphp

<button
    type="button"
    data-slot="dropdown-trigger"
    @if (filled($variant)) data-variant="{{ $variant }}" data-size="{{ $size }}" @endif
    x-ref="trigger"
    aria-haspopup="menu"
    :aria-expanded="open"
    @if ($isRow && filled($label)) :title="$el.closest('[data-slot=sidebar]') && typeof expanded !== 'undefined' && !expanded ? @js($label) : null" @endif
    @click="toggle()"
    @keydown.arrow-down.prevent="show()"
    @keydown.arrow-up.prevent="show()"
    {{ $attributes->merge(['class' => $classes]) }}
>
    {{ $slot }}
    @if ($isRow)
        @if (filled($label) || filled($description))
            <span data-slot="dropdown-trigger-label" class="flex min-w-0 flex-1 flex-col group-data-[state=collapsed]/sidebar:sr-only">
                @if (filled($label))
                    <span class="truncate font-medium leading-tight">{{ $label }}</span>
                @endif
                @if (filled($description))
                    <span class="truncate text-xs leading-tight text-muted-foreground group-data-[state=collapsed]/sidebar:hidden">{{ $description }}</span>
                @endif
            </span>
        @endif
        <svg class="ms-auto size-4 shrink-0 text-muted-foreground group-data-[state=collapsed]/sidebar:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
    @endif
</button>
resources/views/components/ui/dropdown/content.blade.php Blade
@aware([
    // Read from the dropdown root; null (not false) so a host that composes
    // this panel outside a dropdown (menubar, context-menu) can pass
    // `:anchored="true"` as a prop instead — see the prop below.
    'anchored' => null,
])

@props([
    // Declared as a prop too: the root's value wins when there is one, and a
    // host outside a dropdown sets it directly. (Left in the attribute bag it
    // would be printed on the panel and unset the aware variable.)
    'anchored' => null,
    'align' => 'start',
    // What the anchored panel is placed against: the trigger's box
    // (`trigger`) or a point the host's script supplies (`point`, a context
    // menu at the pointer). Read by the shared `overlay-position.js` engine
    // through `data-anchor`; it only changes the panel's transform origin here.
    'anchor' => 'trigger',
    // Alpine expression for the panel's open state. A dropdown owns a single
    // `open`; a menubar shows one panel per menu id (`isOpen(menuId)`).
    'show' => 'open',
])

@php
    $anchored = (bool) $anchored;
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    // Both branches used to build their recipe classes with `@class([...])`
    // and then render `$attributes->except('class')` — dropping any consumer
    // `class` on the floor instead of merging it. `@class` prints its own
    // literal `class="…"` attribute; appending `$attributes` unfiltered would
    // have printed a SECOND `class="…"` attribute on the same tag (invalid
    // HTML, and not a merge either way), so `except('class')` was reached for
    // to avoid that duplicate — at the cost of silently discarding every
    // consumer class, including layout-critical ones like `max-h-*`. The fix
    // is the pattern the rest of this registry already uses for a panel with
    // conditional classes (`dialog/content.blade.php`, `sheet/content.blade.php`):
    // build one class string in PHP and pass it through a single
    // `$attributes->merge(['class' => $classes])`, so the consumer's class is
    // appended after the recipe rather than lost. Ordinary conflicting
    // utilities still resolve by generated stylesheet order, not source order
    // (see CLAUDE.md) — a consumer needing to force a structural class to lose
    // reaches for the `!` important modifier, same as everywhere else here.
    // No overflow clipping on the panel: a nested submenu floats beside it and
    // would be cut off; rows truncate their own long labels.
    $anchorKey = $anchor === 'point' ? 'point' : 'trigger';
    $anchoredOrigin = $anchorKey === 'point'
        ? 'origin-top-left rtl:origin-top-right data-[side=top]:origin-bottom-left rtl:data-[side=top]:origin-bottom-right'
        : 'origin-top data-[side=top]:origin-bottom';
    $anchoredClasses = 'fixed '.$anchoredOrigin.' '.$styles['menu']['surface'];

    // menubar and context-menu compose this panel under their own name; the
    // browser keeps only the first of two identical attributes, so read the
    // override out of the bag and print exactly one (see dropdown.item).
    $slotName = (string) ($attributes->get('data-slot') ?: 'dropdown-content');

    $alignClass = match ($align) {
        'start' => 'start-0',
        'end' => 'end-0',
        default => '',
    };
    $unanchoredClasses = trim('absolute mt-2 origin-top '.$styles['menu']['surface'].' '.$alignClass);
@endphp

@if ($anchored)
    {{--
        Anchored mode: teleported to <body> so an `overflow`-clipping ancestor
        (a scrollable data-table row, a card with `overflow-hidden`, …) can
        never cut the menu off. The shared `overlay-position.js` engine
        (called from `dropdown.js`'s `reposition()`, and from menubar.js /
        context-menu.js, which compose this same panel with `show`/`anchor`)
        measures the anchor + this panel and sets `position: fixed` top/left
        directly (mirroring `popover.js`'s measured-overflow flip test rather
        than a naive always-clamp), so no Tailwind position/inset utility is
        needed here — only `data-align`/`data-anchor` for it to read. Escape uses `.window` because
        a teleported node no longer bubbles keydowns through the dropdown
        root; roving-focus/typeahead (`onKey`) and Tab-to-close keep working
        unchanged because they listen on this element directly, and menu items
        remain its real DOM descendants regardless of where it renders.
    --}}
    <template x-teleport="body">
        <div
            data-slot="{{ $slotName }}"
            data-align="{{ $align }}"
            data-anchor="{{ $anchorKey }}"
            role="menu"
            x-ref="menu"
            x-show="{{ $show }}"
            x-cloak
            @keydown="onKey($event)"
            @keydown.tab="close()"
            @keydown.escape.window="({{ $show }}) && closeAndFocus()"
            x-on:click="onItemClick($event)"
            x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
            x-transition:enter-start="opacity-0 scale-95"
            x-transition:enter-end="opacity-100 scale-100"
            x-transition:leave="motion-safe:transition motion-safe:duration-75 motion-safe:ease-in"
            x-transition:leave-start="opacity-100 scale-100"
            x-transition:leave-end="opacity-0 scale-95"
            {{ $attributes->except('data-slot')->merge(['class' => $anchoredClasses]) }}
        >
            {{ $slot }}
        </div>
    </template>
@else
<div
    data-slot="{{ $slotName }}"
    role="menu"
    x-ref="menu"
    x-show="open"
    x-cloak
    @keydown="onKey($event)"
    @keydown.tab="close()"
    x-on:click="onItemClick($event)"
    x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="motion-safe:transition motion-safe:duration-75 motion-safe:ease-in"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95"
    {{ $attributes->except('data-slot')->merge(['class' => $unanchoredClasses]) }}
>
    {{ $slot }}
</div>
@endif
resources/views/components/ui/dropdown/item.blade.php Blade
@props([
    'href' => null,
    'disabled' => false,
    'type' => 'button',
    // default | destructive. Destructive paints the label and icon in the
    // destructive text token and tints the hover/focus row, for a delete or
    // remove row; the confirmation still belongs to the action it runs.
    'variant' => 'default',
])

{{--
    Fixed-height row by design: `items-center` + `text-sm` keep every item in
    a menu the same control height, so a list of items reads as one aligned
    stack rather than a ragged one — the same "fixed control tiers" rule
    `_styles.php` recipes follow elsewhere. A two-line entry (label plus a
    description) is a real, supported case, but it belongs to the *content*,
    not this root: wrap it in a child `<span class="flex min-w-0 flex-col">`
    (see `select-advanced/option.blade.php`'s rich option, or `slash-menu`)
    so the row still aligns its icon/leading edge at the top of a taller item
    while sibling single-line items keep their normal height. Switching the
    root itself to `items-start` would misalign every plain single-line item
    in the same menu, which is a worse default for the common case to fix an
    uncommon one.
--}}
{{-- Choosing an item closes the menu: the content panel listens for the
     click (see `onItemClick` in dropdown.js) so a consumer's own `@click` /
     `x-on:click` on the item is never shadowed by a duplicate attribute. --}}
@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $tag = $href ? 'a' : 'button';
    $variant = $styles['normalizeVariant']($variant) === 'destructive' ? 'destructive' : 'default';
    $classes = $variant === 'destructive'
        // The base row paints text-popover-foreground; drop it so the
        // destructive colour never depends on stylesheet order, and let the
        // icon take the same colour instead of the muted one.
        ? trim(str_replace('text-popover-foreground', '', $styles['menu']['item'])).' text-destructive-text hover:bg-destructive/10 focus:bg-destructive/10 [&>[data-slot=icon]]:size-4 [&>[data-slot=icon]]:shrink-0'
        : $styles['menu']['item'].' '.$styles['menu']['itemActive'].' '.$styles['menu']['itemIcon'];
    // menubar and context-menu compose this row under their own name; the
    // browser keeps only the first of two identical attributes, so read the
    // override out of the bag and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'dropdown-item');
@endphp

<{{ $tag }}
    data-slot="{{ $slotName }}"
    @if ($variant === 'destructive') data-variant="destructive" @endif
    role="menuitem"
    tabindex="-1"
    @if ($tag === 'a') href="{{ $href }}" @else type="{{ $type }}" @endif
    @if ($disabled) data-disabled="true" aria-disabled="true" @endif
    {{ $attributes->except('data-slot')->merge(['class' => $classes]) }}
>
    {{ $slot }}
</{{ $tag }}>
resources/views/components/ui/dropdown/label.blade.php Blade
@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $slotName = (string) ($attributes->get('data-slot') ?: 'dropdown-label');
@endphp

<div
    data-slot="{{ $slotName }}"
    {{ $attributes->except('data-slot')->merge(['class' => $styles['menu']['label']]) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/dropdown/separator.blade.php Blade
@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $slotName = (string) ($attributes->get('data-slot') ?: 'dropdown-separator');
@endphp

<div
    data-slot="{{ $slotName }}"
    role="separator"
    aria-orientation="horizontal"
    {{ $attributes->except('data-slot')->merge(['class' => $styles['menu']['separator']]) }}
></div>
resources/views/components/ui/dropdown/shortcut.blade.php Blade
@props([
    // A hotkey string ("mod+k", "shift+d", "g p"): `+` joins a chord, a space
    // separates the two steps of a sequence, `mod` is Ctrl here and ⌘ on
    // macOS. It renders as kbd keys with the non-macOS labels; when the
    // hotkeys registry is installed (`$store.hotkeys`) the labels swap to
    // this platform's once Alpine starts. The slot stays the free-text
    // fallback when `keys` is not set.
    'keys' => null,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $slotName = (string) ($attributes->get('data-slot') ?: 'dropdown-shortcut');

    $steps = [];
    if (filled($keys)) {
        // Same key grammar and labels as hotkeys.hint (kept here because a
        // core menu cannot depend on the hotkeys pattern).
        $aliases = [
            'cmd' => 'meta', 'command' => 'meta', 'super' => 'meta', 'win' => 'meta',
            'control' => 'ctrl', 'ctl' => 'ctrl', 'option' => 'alt', 'opt' => 'alt',
            'esc' => 'escape', 'return' => 'enter', 'up' => 'arrowup', 'down' => 'arrowdown',
            'left' => 'arrowleft', 'right' => 'arrowright', 'space' => ' ', 'spacebar' => ' ',
            'del' => 'delete', 'plus' => '+',
        ];
        $keyLabels = [
            ' ' => 'Space', 'enter' => 'Enter', 'escape' => 'Esc', 'tab' => 'Tab', 'backspace' => 'Backspace',
            'delete' => 'Delete', 'arrowup' => '↑', 'arrowdown' => '↓', 'arrowleft' => '←', 'arrowright' => '→',
            'home' => 'Home', 'end' => 'End', 'pageup' => 'Page Up', 'pagedown' => 'Page Down',
        ];
        $modifierLabels = ['ctrl' => 'Ctrl', 'meta' => 'Win', 'alt' => 'Alt', 'shift' => 'Shift'];
        foreach (array_slice(preg_split('/\s+/', trim((string) $keys), -1, PREG_SPLIT_NO_EMPTY), 0, 2) as $step) {
            $parts = explode('+', mb_strtolower($step));
            if (count($parts) > 1 && end($parts) === '' && $parts[count($parts) - 2] === '') {
                array_splice($parts, -2, 2, ['+']);
            }
            $chord = ['ctrl' => false, 'meta' => false, 'alt' => false, 'shift' => false];
            $key = '';
            foreach ($parts as $part) {
                $part = $aliases[$part] ?? $part;
                if ($part === 'mod') {
                    $chord['ctrl'] = true;
                } elseif (array_key_exists($part, $chord)) {
                    $chord[$part] = true;
                } else {
                    $key = $part;
                }
            }
            $label = $keyLabels[$key] ?? (mb_strlen($key) === 1 ? mb_strtoupper($key) : ucfirst($key));
            $steps[] = [...array_values(array_map(fn ($m) => $modifierLabels[$m], array_keys(array_filter($chord)))), $label];
        }
    }
@endphp

{{--
    Inline keyboard-shortcut hint for a menu item. Purely presentational
    (decorative), pushed to the inline end with `ms-auto` so it mirrors in RTL.
    Name the shortcut on the item itself with `aria-keyshortcuts`.
    With `keys` and the hotkeys registry installed, a shortcut without Ctrl,
    Cmd or Alt hides while character-key shortcuts are switched off.
--}}
@if ($steps !== [])
    <span
        data-slot="{{ $slotName }}"
        data-keys="{{ $keys }}"
        aria-hidden="true"
        dir="ltr"
        x-data="{ steps: null }"
        x-init="steps = $store.hotkeys?.format({{ \Illuminate\Support\Js::from((string) $keys) }}) ?? null"
        x-show="$store.hotkeys?.showsHint?.({{ \Illuminate\Support\Js::from((string) $keys) }}) ?? true"
        {{ $attributes->except('data-slot')->merge(['class' => 'ms-auto inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground']) }}
    >
        @foreach ($steps as $s => $labels)
            @if ($s > 0)
                <span>{{ __('then') }}</span>
            @endif
            <x-ui.kbd.group>
                @foreach ($labels as $i => $label)
                    <x-ui.kbd x-text="steps?.[{{ $s }}]?.[{{ $i }}] ?? {{ \Illuminate\Support\Js::from($label) }}">{{ $label }}</x-ui.kbd>
                @endforeach
            </x-ui.kbd.group>
        @endforeach
    </span>
@else
    <span
        data-slot="{{ $slotName }}"
        aria-hidden="true"
        {{ $attributes->except('data-slot')->merge(['class' => $styles['menu']['shortcut']]) }}
    >{{ $slot }}</span>
@endif
resources/views/components/ui/dropdown/group.blade.php Blade
@props([
    'label' => null,
])

{{--
    Groups related menu items. Renders a `role="group"`; when `label` is set it
    is exposed via `aria-label` so assistive tech announces the grouping.
--}}
<div
    data-slot="dropdown-group"
    role="group"
    @if ($label) aria-label="{{ $label }}" @endif
    {{ $attributes->except('class') }}
>
    {{ $slot }}
</div>
resources/views/components/ui/dropdown/checkbox-item.blade.php Blade
@props([
    'checked' => false,
    'disabled' => false,
])

{{--
    Checkable menu item (role="menuitemcheckbox"). The leading slot reserves
    space for a check mark, shown only when checked. `checked`/`disabled` accept
    a boolean (static) or, when set as a bound Alpine attribute (`:checked`),
    an expression — the indicator toggles off `aria-checked`/`data-state` via CSS.
--}}
@php
    $classes = 'group relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-2 pe-2 ps-8 text-start text-sm text-popover-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50';
@endphp

<button
    type="button"
    data-slot="dropdown-checkbox-item"
    role="menuitemcheckbox"
    tabindex="-1"
    @if ($checked) aria-checked="true" data-state="checked" @else aria-checked="false" data-state="unchecked" @endif
    @if ($disabled) data-disabled="true" aria-disabled="true" @endif
    {{ $attributes->merge(['class' => $classes]) }}
>
    <span class="pointer-events-none absolute inset-y-0 start-2 flex items-center opacity-0 group-aria-checked:opacity-100">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="M20 6 9 17l-5-5" /></svg>
    </span>
    {{ $slot }}
</button>
resources/views/components/ui/dropdown/radio-group.blade.php Blade
@props([
    'label' => null,
])

{{--
    Wraps a set of <x-ui.dropdown.radio-item> as a single-choice group
    (role="group"). Optional `label` is announced via `aria-label`.
--}}
<div
    data-slot="dropdown-radio-group"
    role="group"
    @if ($label) aria-label="{{ $label }}" @endif
    {{ $attributes->except('class') }}
>
    {{ $slot }}
</div>
resources/views/components/ui/dropdown/radio-item.blade.php Blade
@props([
    'checked' => false,
    'disabled' => false,
])

{{--
    Single-choice menu item (role="menuitemradio"). Pair several inside an
    <x-ui.dropdown.radio-group>. The leading dot indicator shows only for the
    selected item, toggled purely off `aria-checked` via CSS (no JS required).
--}}
@php
    $classes = 'group relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-2 pe-2 ps-8 text-start text-sm text-popover-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50';
@endphp

<button
    type="button"
    data-slot="dropdown-radio-item"
    role="menuitemradio"
    tabindex="-1"
    @if ($checked) aria-checked="true" data-state="checked" @else aria-checked="false" data-state="unchecked" @endif
    @if ($disabled) data-disabled="true" aria-disabled="true" @endif
    {{ $attributes->merge(['class' => $classes]) }}
>
    <span class="pointer-events-none absolute inset-y-0 start-2 flex items-center opacity-0 group-aria-checked:opacity-100">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" class="size-2"><circle cx="12" cy="12" r="10" /></svg>
    </span>
    {{ $slot }}
</button>
resources/views/components/ui/dropdown/submenu.blade.php Blade
{{--
    Nested submenu. Holds its own open state (`uiDropdownSub`) so it can open on
    hover or via ArrowRight from its trigger, and close on ArrowLeft/Escape or
    pointer-leave. Compose a <x-ui.dropdown.sub-trigger> + <x-ui.dropdown.sub-content>.
--}}
<div
    x-data="uiDropdownSub()"
    data-slot="dropdown-submenu"
    @mouseenter="openSub()"
    @mouseleave="closeSoon()"
    {{ $attributes->merge(['class' => 'relative']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/dropdown/sub-trigger.blade.php Blade
@props([
    'disabled' => false,
])

{{--
    Opens its sibling <x-ui.dropdown.sub-content>. Stays a menuitem in the parent
    menu's roving focus (role="menuitem"), exposes aria-haspopup/aria-expanded,
    and opens on ArrowRight (ArrowLeft closes). The trailing chevron points to the
    inline end and flips automatically under dir="rtl" (rtl:rotate-180). A leading
    <x-ui.icon> gets the same 16px muted treatment as a dropdown item (menu.itemIcon).
--}}
@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $classes = 'flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-2 text-start text-sm text-popover-foreground outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 '.$styles['menu']['itemIcon'];
@endphp

<button
    type="button"
    data-slot="dropdown-sub-trigger"
    role="menuitem"
    tabindex="-1"
    aria-haspopup="menu"
    :aria-expanded="subOpen"
    :data-state="subOpen ? 'open' : 'closed'"
    @if ($disabled) data-disabled="true" aria-disabled="true" @endif
    @click="openSub(); focusFirst()"
    @keydown="onTriggerKey($event)"
    {{ $attributes->merge(['class' => $classes]) }}
>
    {{ $slot }}
    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ms-auto size-4 rtl:rotate-180"><path d="m9 18 6-6-6-6" /></svg>
</button>
resources/views/components/ui/dropdown/sub-content.blade.php Blade
{{--
    The nested menu panel. Floats to the inline-end side of the submenu row
    (start-full) so it sits beside the parent menu, mirroring under dir="rtl"
    via logical `start-full`. ArrowLeft/Escape inside it returns to the trigger.
--}}
<div
    data-slot="dropdown-sub-content"
    role="menu"
    x-ref="subMenu"
    x-show="subOpen"
    x-cloak
    @keydown="onSubKey($event)"
    x-transition:enter="motion-safe:transition motion-safe:duration-100 motion-safe:ease-out"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="motion-safe:transition motion-safe:duration-75 motion-safe:ease-in"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95"
    {{ $attributes->merge(['class' => 'absolute -top-1 start-full z-dropdown min-w-[12rem] origin-top-left rtl:origin-top-right rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md motion-reduce:transition-none']) }}
>
    {{ $slot }}
</div>
resources/js/ui/dropdown.js JS
/**
 * Dropdown menu behavior (spec §17 dropdown keyboard navigation).
 *
 * Opening focuses the first menu item. ArrowUp/Down/Home/End move a roving
 * focus across enabled items; printable keys type-ahead to a matching item;
 * Escape and outside click close. Selecting an item closes the menu and
 * returns focus to the trigger. Nested submenus use the `uiDropdownSub`
 * component (inline-end opens, inline-start/Escape closes, including RTL).
 *
 * v1.4.0 adds an opt-in `anchored` mode (see `dropdown.blade.php` /
 * `content.blade.php`): the content panel teleports to <body> and
 * `reposition()` measures the trigger + panel rects to place it with
 * `position: fixed`, flipping above the trigger when it would overflow the
 * viewport below (and there is more room above) and clamping the aligned
 * edge into the viewport otherwise. This mirrors `popover.js`'s
 * measured-overflow collision test — flip only on real overflow, choosing
 * the side with more room, rather than a naive always-clamp against an
 * estimated box size — rather than duplicating a weaker implementation.
 * Listeners are only attached in anchored mode and are torn down on close
 * and in `destroy()`. The placement math lives in `overlay-position.js`,
 * shared with menubar and context-menu, which compose the same panel.
 */
import { placeOverlay, trackOverlayPosition } from './overlay-position.js';

document.addEventListener('alpine:init', () => {
    // Enabled, focusable menu items across the three menu-item roles. Excludes
    // items that live inside a closed nested submenu so roving focus skips them.
    const enabledItems = (menu) => {
        if (!menu) {
            return [];
        }
        const selector =
            '[role="menuitem"]:not([aria-disabled="true"]),' +
            '[role="menuitemcheckbox"]:not([aria-disabled="true"]),' +
            '[role="menuitemradio"]:not([aria-disabled="true"])';
        return Array.from(menu.querySelectorAll(selector)).filter((el) => {
            const subContent = el.closest('[data-slot="dropdown-sub-content"]');
            // Items in a sub-content that belongs to *this* menu are only
            // reachable once that sub-content is visible (x-show toggles it).
            return !subContent || subContent.offsetParent !== null;
        });
    };

    // Move roving focus on Arrow/Home/End, or type-ahead on a printable key.
    // Returns true if the event was handled.
    const handleMenuKey = (event, getItems) => {
        const items = getItems();
        if (items.length === 0) {
            return false;
        }
        const current = items.indexOf(document.activeElement);

        let next;
        switch (event.key) {
            case 'ArrowDown':
                next = (current + 1) % items.length;
                break;
            case 'ArrowUp':
                next = (current - 1 + items.length) % items.length;
                break;
            case 'Home':
                next = 0;
                break;
            case 'End':
                next = items.length - 1;
                break;
            default:
                // Single printable character → type-ahead to next match.
                if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
                    const needle = event.key.toLowerCase();
                    const start = current + 1;
                    for (let i = 0; i < items.length; i++) {
                        const item = items[(start + i) % items.length];
                        if ((item.textContent || '').trim().toLowerCase().startsWith(needle)) {
                            event.preventDefault();
                            item.focus();
                            return true;
                        }
                    }
                }
                return false;
        }

        event.preventDefault();
        items[next].focus();
        return true;
    };

    window.Alpine.data('uiDropdown', (anchored = false) => ({
        open: false,
        anchored,
        // Teardown for the resize/scroll tracker attached on open (anchored
        // mode only) and removed on close/destroy.
        _untrack: null,
        // Bumped on every open and close; the deferred open work checks it,
        // so a close (or a close and re-open) before the next tick cancels it.
        _openSeq: 0,

        toggle() {
            this.open ? this.close() : this.show();
        },

        show() {
            if (this.open) return;
            this.open = true;
            const openSeq = ++this._openSeq;
            this.$nextTick(() => {
                if (!this.open || openSeq !== this._openSeq) {
                    return;
                }
                this.items()[0]?.focus();
                if (!this.anchored) {
                    return;
                }
                this.reposition();
                this.teardownReposition();
                this._untrack = trackOverlayPosition(() => this.reposition());
            });
        },

        close() {
            this.open = false;
            this._openSeq++;
            this.teardownReposition();
        },

        // A click on a plain menu item (not a checkbox / radio row, which stay
        // open for further toggles) chooses it: the consumer's own handler on
        // the item has already run by the time the click bubbles here.
        onItemClick(event) {
            const item = event.target.closest('[data-slot="dropdown-item"]');
            if (!item || item.dataset.disabled === 'true' || event.defaultPrevented) return;
            this.closeAndFocus();
        },

        /**
         * Close and return focus to the trigger. Focus moves synchronously,
         * before the menu hides: a deferred focus left a gap in which a fast
         * Enter still activated an item of the closing menu, or a quick
         * re-open ran its own deferred focus in the wrong order.
         */
        closeAndFocus() {
            this.$refs.trigger?.focus();
            this.close();
        },

        teardownReposition() {
            if (this._untrack) {
                this._untrack();
                this._untrack = null;
            }
        },

        /**
         * Place the (teleported, `position: fixed`) panel against the trigger
         * so it stays visible — see `placeOverlay()` for the flip/clamp rules.
         */
        reposition() {
            const panel = this.$refs.menu;
            const trigger = this.$refs.trigger;
            if (!panel || !trigger || !this.open || !this.anchored) {
                return;
            }

            placeOverlay(panel, trigger.getBoundingClientRect());
        },

        items() {
            return enabledItems(this.$refs.menu);
        },

        onKey(event) {
            handleMenuKey(event, () => this.items());
        },

        destroy() {
            this.teardownReposition();
        },
    }));

    // Nested submenu. Lives inside a dropdown-content; opens on hover or
    // ArrowRight from its trigger, closes on ArrowLeft/Escape/pointer-leave.
    window.Alpine.data('uiDropdownSub', () => ({
        subOpen: false,
        _closeTimer: null,

        // Named openSub/closeSub, never open/close: Alpine resolves a method
        // through the nearest scope, so a sub `close()` would shadow the parent
        // dropdown's own close() when a row inside the submenu is chosen.
        openSub() {
            clearTimeout(this._closeTimer);
            this.subOpen = true;
        },

        closeSub() {
            clearTimeout(this._closeTimer);
            this.subOpen = false;
        },

        /**
         * Pointer-leave close with a short grace period, so crossing the gap
         * between the row and the floating panel (or a diagonal move towards
         * it) does not slam the submenu shut; re-entering cancels it.
         */
        closeSoon() {
            clearTimeout(this._closeTimer);
            this._closeTimer = setTimeout(() => {
                this.subOpen = false;
            }, 150);
        },

        toggle() {
            this.subOpen = !this.subOpen;
        },

        destroy() {
            clearTimeout(this._closeTimer);
        },

        subItems() {
            return enabledItems(this.$refs.subMenu);
        },

        focusFirst() {
            this.$nextTick(() => this.subItems()[0]?.focus());
        },

        isRtl() {
            return getComputedStyle(this.$el).direction === 'rtl';
        },

        onTriggerKey(event) {
            const openKey = this.isRtl() ? 'ArrowLeft' : 'ArrowRight';
            const closeKey = this.isRtl() ? 'ArrowRight' : 'ArrowLeft';
            if (event.key === openKey) {
                event.preventDefault();
                event.stopPropagation();
                this.openSub();
                this.focusFirst();
            } else if (event.key === closeKey || event.key === 'Escape') {
                event.preventDefault();
                event.stopPropagation();
                this.closeSub();
            }
        },

        onSubKey(event) {
            const closeKey = this.isRtl() ? 'ArrowRight' : 'ArrowLeft';
            if (event.key === closeKey || event.key === 'Escape') {
                event.preventDefault();
                event.stopPropagation();
                this.closeSub();
                this.$nextTick(() => this.$el.parentElement?.querySelector('[data-slot="dropdown-sub-trigger"]')?.focus());
                return;
            }
            handleMenuKey(event, () => this.subItems());
        },
    }));
});
resources/js/ui/overlay-position.js JS
/**
 * Shared placement engine for a teleported, `position: fixed` floating panel
 * (dropdown, menubar, context-menu). Ships with the dropdown item and is
 * imported by every menu that composes `<x-ui.dropdown.content>` in anchored
 * mode.
 *
 * `placeOverlay()` measures the panel and the viewport and writes fixed
 * top/left plus `data-side`. Vertical placement prefers below the anchor and
 * flips above it only when that would overflow the viewport AND there is more
 * room above than below — the same measured-overflow test `popover.js` uses,
 * rather than a naive always-clamp against an estimated box. Horizontal
 * placement follows the logical `align` (mirrored for RTL) and is clamped
 * into the viewport as a last resort.
 *
 * The anchor is either a box (a trigger's `getBoundingClientRect()`) or a
 * point (`{ x, y }`, a context menu at the pointer): a point is a zero-size
 * box, so the panel's start corner lands on it.
 *
 * `trackOverlayPosition()` re-runs a callback on resize and on any scroll
 * (`capture: true` on `window` also catches a nested scrollable ancestor)
 * and returns the teardown, so a menu can attach it on open and drop it on
 * close and in `destroy()`.
 */
export function placeOverlay(panel, anchor, options = {}) {
    if (!panel || !anchor) {
        return null;
    }

    const align = options.align || panel.getAttribute('data-align') || 'start';
    const margin = options.margin ?? 8; // gutter kept from the viewport edge
    const gap = options.gap ?? 8; // matches the unanchored mode's `mt-2` trigger gap

    const box = 'width' in anchor
        ? anchor
        : { top: anchor.y, bottom: anchor.y, left: anchor.x, right: anchor.x, width: 0, height: 0 };

    const isRtl = getComputedStyle(panel).direction === 'rtl';
    const vw = document.documentElement.clientWidth;
    const vh = document.documentElement.clientHeight;
    // Layout size, not the bounding rect: placement runs while the enter
    // transition still scales the panel, and a scaled rect would put a
    // flipped panel a few pixels over its anchor.
    const r = { width: panel.offsetWidth, height: panel.offsetHeight };

    let side = 'bottom';
    let top = box.bottom + gap;
    if (top + r.height + margin > vh) {
        const spaceBelow = vh - box.bottom;
        const spaceAbove = box.top;
        if (spaceAbove > spaceBelow) {
            side = 'top';
            top = box.top - r.height - gap;
        }
    }
    // Last-resort clamp so an oversized panel never leaves the viewport entirely.
    top = Math.min(Math.max(top, margin), Math.max(margin, vh - r.height - margin));

    // `start` is the anchor's left edge in LTR, right edge in RTL — and the
    // opposite for `end` — the same logical resolution `popover.js` applies.
    const alignStart = (align === 'start') !== isRtl;
    let left = alignStart ? box.left : box.right - r.width;
    left = Math.min(Math.max(left, margin), Math.max(margin, vw - r.width - margin));

    panel.style.position = 'fixed';
    panel.style.top = `${Math.round(top)}px`;
    panel.style.left = `${Math.round(left)}px`;
    panel.dataset.side = side;

    return side;
}

export function trackOverlayPosition(callback) {
    window.addEventListener('resize', callback, { passive: true });
    window.addEventListener('scroll', callback, { passive: true, capture: true });

    return () => {
        window.removeEventListener('resize', callback);
        window.removeEventListener('scroll', callback, { capture: true });
    };
}

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.12.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0