Menus
Action menus — a dropdown on a trigger, a right-click context menu, an application menubar and an inline-expanding 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
<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>
Installation
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:
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.
-
resources/views/components/ui/dropdown.blade.php -
resources/views/components/ui/dropdown/trigger.blade.php -
resources/views/components/ui/dropdown/content.blade.php -
resources/views/components/ui/dropdown/item.blade.php -
resources/views/components/ui/dropdown/label.blade.php -
resources/views/components/ui/dropdown/separator.blade.php -
resources/views/components/ui/dropdown/shortcut.blade.php -
resources/views/components/ui/dropdown/group.blade.php -
resources/views/components/ui/dropdown/checkbox-item.blade.php -
resources/views/components/ui/dropdown/radio-group.blade.php -
resources/views/components/ui/dropdown/radio-item.blade.php -
resources/views/components/ui/dropdown/submenu.blade.php -
resources/views/components/ui/dropdown/sub-trigger.blade.php -
resources/views/components/ui/dropdown/sub-content.blade.php -
resources/js/ui/dropdown.js -
resources/js/ui/overlay-position.js
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.
# 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` 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 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
{{--
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>
{{-- 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>
{{-- 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
{{-- 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
{{-- 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
{{-- 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>
{{-- 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
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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>
@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>
@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
@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 }}>
@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>
@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>
@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
@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>
@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>
@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>
@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>
{{--
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>
@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>
{{--
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>
/**
* 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());
},
}));
});
/**
* 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