Menus
Action menus — a dropdown on a trigger, a right-click context menu, an application menubar and an inline-expanding nested menu.
Preview
Q3 report.pdf
2.4 MB, edited yesterday
Right-click for actions
{{-- A file tile: right-click (or Shift+F10 / Enter with focus) opens its actions. --}}
<x-ui.context-menu>
<x-ui.context-menu.trigger class="w-64 rounded-lg">
<div class="flex flex-col gap-4 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
<div class="flex h-24 items-center justify-center rounded-md bg-muted">
<svg class="size-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ __('Q3 report.pdf') }}</p>
<p class="truncate text-xs text-muted-foreground">{{ __('2.4 MB, edited yesterday') }}</p>
</div>
<p class="flex items-center gap-2 text-xs text-muted-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="3" width="12" height="18" rx="6" /><path d="M12 7v3M12 3v4" /></svg>
{{ __('Right-click for actions') }}
</p>
</div>
</x-ui.context-menu.trigger>
<x-ui.context-menu.content>
<x-ui.context-menu.label>{{ __('Q3 report.pdf') }}</x-ui.context-menu.label>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
{{ __('Open') }}
</x-ui.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
{{ __('Rename') }}
</x-ui.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
{{ __('Duplicate') }}
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
{{ __('Download') }}
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item class="!text-destructive-text">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg>
{{ __('Move to trash') }}
</x-ui.context-menu.item>
</x-ui.context-menu.content>
</x-ui.context-menu>
Installation
php artisan ui:add context-menu
Note
This component ships an Alpine behavior module at
resources/js/ui/context-menu.js. Import it once from your bundle so it registers on alpine:init:
import './context-menu.js';
Registry contract
php artisan ui:add context-menu
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/context-menu.blade.php -
resources/views/components/ui/context-menu/trigger.blade.php -
resources/views/components/ui/context-menu/content.blade.php -
resources/views/components/ui/context-menu/item.blade.php -
resources/views/components/ui/context-menu/separator.blade.php -
resources/views/components/ui/context-menu/label.blade.php -
resources/js/ui/context-menu.js -
resources/views/components/ui/context-menu/shortcut.blade.php
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: Context Menu (`context-menu`)
A menu of actions revealed by right-clicking a region.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add context-menu
```
## Usage
```blade
{{-- A file tile: right-click (or Shift+F10 / Enter with focus) opens its actions. --}}
<x-ui.context-menu>
<x-ui.context-menu.trigger class="w-64 rounded-lg">
<div class="flex flex-col gap-4 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
<div class="flex h-24 items-center justify-center rounded-md bg-muted">
<svg class="size-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ __('Q3 report.pdf') }}</p>
<p class="truncate text-xs text-muted-foreground">{{ __('2.4 MB, edited yesterday') }}</p>
</div>
<p class="flex items-center gap-2 text-xs text-muted-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="3" width="12" height="18" rx="6" /><path d="M12 7v3M12 3v4" /></svg>
{{ __('Right-click for actions') }}
</p>
</div>
</x-ui.context-menu.trigger>
<x-ui.context-menu.content>
<x-ui.context-menu.label>{{ __('Q3 report.pdf') }}</x-ui.context-menu.label>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
{{ __('Open') }}
</x-ui.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
{{ __('Rename') }}
</x-ui.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
{{ __('Duplicate') }}
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
{{ __('Download') }}
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item class="!text-destructive-text">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg>
{{ __('Move to trash') }}
</x-ui.context-menu.item>
</x-ui.context-menu.content>
</x-ui.context-menu>
```
## Props
- `target` (string|null, default `null`) — On the root, a CSS selector for delegated rows inside the trigger (for example [data-row] or tr). One menu then serves every matching row: the row under the pointer, or the focused row for Shift+F10 and the ContextMenu key, becomes context (its data-* values) and contextEl, and the root dispatches context-menu:open with { target, data }. Null keeps the single-region trigger.
## Use when
- Use for secondary actions when compact access is more important than constant visibility.
- Offering a short list of actions for a specific region, such as a table row or canvas element, via right-click.
- Mirroring a native desktop-app right-click experience for power users, in addition to visible controls.
## Avoid when
- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- The actions must also be discoverable without right-clicking; pair with a visible trigger such as dropdown, or use dropdown alone.
- Building a persistent menu bar; use menubar instead.
## Anti-patterns
- Hiding primary actions in menus
## Rules
- Use the `<brok:context-menu>` tag (or `<x-ui.context-menu>`) 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/context-menu
- Registry JSON (files, props, contract): https://brokui.dev/r/open/context-menu.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- One menu for many rows: target="[data-row]" makes each task row own the
menu. Right-click a row, or focus its link and press Shift+F10 or the
ContextMenu key. Items read the row through `context`. --}}
@php
$tasks = [
['id' => 'ATL-12', 'title' => __('Draft the kickoff agenda')],
['id' => 'ATL-14', 'title' => __('Review the contract with Atlas Bouw')],
['id' => 'ATL-15', 'title' => __('Send the September invoice')],
];
@endphp
<div x-data="{ opened: '', chosen: '' }" class="flex w-full max-w-md flex-col gap-3">
<x-ui.context-menu target="[data-row]" x-on:context-menu:open="opened = $event.detail.data.id">
<x-ui.context-menu.trigger class="rounded-md">
<ul class="divide-y divide-border rounded-md border border-border bg-card text-card-foreground" aria-label="{{ __('Tasks') }}">
@foreach ($tasks as $task)
<li
data-row
data-id="{{ $task['id'] }}"
data-title="{{ $task['title'] }}"
class="flex min-w-0 items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-muted/50 data-[context-menu-open=true]:bg-muted motion-reduce:transition-none"
>
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">{{ $task['id'] }}</span>
<a href="#{{ $task['id'] }}" class="min-w-0 truncate rounded-sm font-medium text-foreground outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ $task['title'] }}</a>
</li>
@endforeach
</ul>
</x-ui.context-menu.trigger>
<x-ui.context-menu.content>
<x-ui.context-menu.label><span x-text="context.id"></span></x-ui.context-menu.label>
<x-ui.context-menu.item aria-keyshortcuts="S" x-on:click="chosen = 'status ' + context.id">
{{ __('Change status') }}
<x-ui.context-menu.shortcut keys="s" />
</x-ui.context-menu.item>
<x-ui.context-menu.item x-on:click="chosen = 'blocked ' + context.id">
{{ __('Mark as blocked') }}
<x-ui.context-menu.shortcut keys="m b" />
</x-ui.context-menu.item>
<x-ui.context-menu.item aria-keyshortcuts="Control+Period" x-on:click="chosen = 'copied ' + context.id">
{{ __('Copy ID') }}
<x-ui.context-menu.shortcut keys="mod+." />
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item variant="destructive" x-on:click="chosen = 'deleted ' + context.id">
{{ __('Delete task') }}
</x-ui.context-menu.item>
</x-ui.context-menu.content>
</x-ui.context-menu>
<p data-testid="context-menu-result" class="text-sm text-muted-foreground" role="status">
<span x-text="opened"></span> <span x-text="chosen"></span>
</p>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| target | string | null | null | On the root, a CSS selector for delegated rows inside the trigger (for example [data-row] or tr). One menu then serves every matching row: the row under the pointer, or the focused row for Shift+F10 and the ContextMenu key, becomes context (its data-* values) and contextEl, and the root dispatches context-menu:open with { target, data }. Null keeps the single-region trigger. |
Slots
default— Compositional parts: trigger, content, item, label, and separator.x-ui.context-menu.trigger— Installed subcomponent from the registry item.x-ui.context-menu.content— Installed subcomponent from the registry item.x-ui.context-menu.item— Installed subcomponent from the registry item.x-ui.context-menu.separator— Installed subcomponent from the registry item.x-ui.context-menu.label— Installed subcomponent from the registry item.x-ui.context-menu.shortcut— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Escape closes an open menu and stops there; while the menu is closed the root lets Escape bubble, so a surrounding dialog or sheet still closes.
- Right-clicking, or pressing Shift+F10 or the context-menu key while the trigger region is focused, opens the menu at the pointer or a keyboard-appropriate position.
- Selecting an item runs its action and closes the menu.
- Each item is a dropdown.item under the hood, so it shares the same keyboard navigation and row styling as the dropdown menu family.
- The panel is dropdown.content in anchored mode with anchor="point": teleported to the body and placed at the pointer by the shared overlay-position.js engine, so it flips above the pointer at the viewport edge (data-side reports the side) and is never clipped by an ancestor.
- With target set, a right-click outside a matching row keeps the browser's own menu, and the trigger region is not a tab stop: each row keeps its own link or tabindex, and Shift+F10 or the ContextMenu key on it opens the menu anchored to the row.
- While the menu is open the row carries data-context-menu-open="true" for a highlight; item handlers read the row through context (for example $wire.archive(context.id)). Closing returns focus to the element in the row that had it.
- Checkbox and radio rows (dropdown.checkbox-item, dropdown.radio-item) take part in roving focus and stay open on toggle, as in dropdown; plain items close the menu.
- 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.
- Offering a short list of actions for a specific region, such as a table row or canvas element, via right-click.
- Mirroring a native desktop-app right-click experience for power users, in addition to visible controls.
Avoid when
- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- The actions must also be discoverable without right-clicking; pair with a visible trigger such as dropdown, or use dropdown alone.
- Building a persistent menu bar; use menubar instead.
Use instead
- Visible button for frequent actions
- Inline action bar
Anti-patterns
- Hiding primary actions in menus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape ArrowUp ArrowDown Home End
- Focus
managed
- The trigger region is reachable and openable from the keyboard via Shift+F10 or the context-menu key, not just a right-click.
- Items follow standard menu keyboard navigation, so arrow keys move between rows and Escape closes the menu.
- Delegated rows keep one keyboard route per row: focus the row (or its link) and press Shift+F10 or the ContextMenu key; name shortcuts on the item with aria-keyshortcuts, since the hint itself is decorative.
- 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="context-menu-{{ $record->id }}">
{{-- A file tile: right-click (or Shift+F10 / Enter with focus) opens its actions. --}}
<x-ui.context-menu>
<x-ui.context-menu.trigger class="w-64 rounded-lg">
<div class="flex flex-col gap-4 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
<div class="flex h-24 items-center justify-center rounded-md bg-muted">
<svg class="size-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ __('Q3 report.pdf') }}</p>
<p class="truncate text-xs text-muted-foreground">{{ __('2.4 MB, edited yesterday') }}</p>
</div>
<p class="flex items-center gap-2 text-xs text-muted-foreground">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="3" width="12" height="18" rx="6" /><path d="M12 7v3M12 3v4" /></svg>
{{ __('Right-click for actions') }}
</p>
</div>
</x-ui.context-menu.trigger>
<x-ui.context-menu.content>
<x-ui.context-menu.label>{{ __('Q3 report.pdf') }}</x-ui.context-menu.label>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
{{ __('Open') }}
</x-ui.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
{{ __('Rename') }}
</x-ui.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
{{ __('Duplicate') }}
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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.context-menu.item>
<x-ui.context-menu.item>
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
{{ __('Download') }}
</x-ui.context-menu.item>
<x-ui.context-menu.separator />
<x-ui.context-menu.item class="!text-destructive-text">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg>
{{ __('Move to trash') }}
</x-ui.context-menu.item>
</x-ui.context-menu.content>
</x-ui.context-menu>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Context menu (interactive). Root holds open state and the pointer point.
A right-click on the trigger opens the menu at the pointer location.
Escape and outside click close the menu and return focus to the trigger
(or, with `target`, to the row that opened it).
Delegated rows: `<x-ui.context-menu target="[data-row]">` lets one menu
serve every matching row inside the trigger (a list or a table body)
instead of one menu per row.
The panel is <x-ui.dropdown.content> in anchored mode (teleported to
<body>, placed by the shared `overlay-position.js` engine at the pointer
point and flipped above it at the viewport edge). Because it is teleported,
the outside-click check extends to the panel (`$refs.menu`) exactly as the
anchored dropdown does, so a click inside the menu is never misread.
--}}
@props([
// Delegated rows: a CSS selector (`[data-row]`, `tr`) for the elements
// inside the trigger that each own the menu. A right-click, Shift+F10 or
// the ContextMenu key on one of them opens this one menu for it; the row
// is `context` (its data-* values) and `contextEl` in the Alpine scope,
// and the root dispatches `context-menu:open` { target, data }. Outside a
// matching row the browser's own menu shows. Null keeps the classic
// single-region trigger.
'target' => null,
])
@php
$target = filled($target) ? (string) $target : null;
@endphp
<div
@if ($target) x-data="uiContextMenu(@js(['target' => $target]))" @else x-data="uiContextMenu()" @endif
@if ($target) data-target="{{ $target }}" @endif
data-slot="context-menu"
{{-- Escape is consumed only while the menu is open. A closed menu lets
it bubble, so the dialog or sheet around the region still closes. --}}
@keydown.escape="if (open) { $event.stopPropagation(); closeAndFocus() }"
@click.outside="if (!$refs.menu?.contains($event.target)) close()"
{{ $attributes->merge(['class' => 'relative']) }}
>
{{ $slot }}
</div>
@aware([
// The root's delegated-row selector. With it, the rows are the focusable
// targets (their own link or tabindex), so the region itself is not a tab
// stop, a right-click outside a row keeps the browser menu, and Enter
// stays with the row.
'target' => null,
])
@if (blank($target))
<div
data-slot="context-menu-trigger"
x-ref="trigger"
tabindex="0"
@contextmenu.prevent="openAt($event)"
@keydown.shift.f10.prevent="openFromKeyboard($event)"
@keydown.contextmenu.prevent="openFromKeyboard($event)"
@keydown.enter.self.prevent="openFromKeyboard($event)"
{{ $attributes->merge(['class' => 'block rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring']) }}
>
{{ $slot }}
</div>
@else
<div
data-slot="context-menu-trigger"
x-ref="trigger"
@contextmenu="onContextMenu($event)"
@keydown.shift.f10="openFromKeyboard($event)"
@keydown.contextmenu="openFromKeyboard($event)"
{{ $attributes->merge(['class' => 'block rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring']) }}
>
{{ $slot }}
</div>
@endif
{{--
Context-menu content — the anchored dropdown panel under the context
menu's name, placed at the pointer point by context-menu.js through the
shared `overlay-position.js` engine (flips above the point at the
viewport edge; `data-side` reports which way it opened).
--}}
<x-ui.dropdown.content :anchored="true" anchor="point" data-slot="context-menu-content" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.content>
{{-- Context-menu item — a dropdown row under the context menu's name. Selecting an item closes the menu (see onItemClick in context-menu.js). --}}
<x-ui.dropdown.item data-slot="context-menu-item" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.item>
{{-- Context-menu separator — a dropdown separator under the context menu's name. --}}
<x-ui.dropdown.separator data-slot="context-menu-separator" {{ $attributes }} />
{{-- Context-menu label — a dropdown label under the context menu's name. --}}
<x-ui.dropdown.label data-slot="context-menu-label" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.label>
/**
* Context menu behavior (interactive).
*
* A right-click on the trigger opens the menu at the pointer location. The
* panel is the anchored dropdown panel (teleported to <body>, `position:
* fixed`), placed by the shared `overlay-position.js` engine against the
* pointer point: it flips above the point when it would overflow the viewport
* below (and there is more room above), is clamped into the viewport
* otherwise, and reports the side it opened on through `data-side`. Keyboard
* users open it with Shift+F10, the dedicated ContextMenu key, or Enter on
* the focused trigger; the menu then anchors to the trigger's box instead of
* a pointer position (WCAG 2.1.1 — every pointer-only action needs a keyboard
* route). ArrowUp/Down/Home/End move a roving focus across enabled items;
* Escape and outside click close the menu. Selecting an item closes the menu;
* Escape and item selection return focus to the trigger.
*
* Delegated rows (`target`, a CSS selector): one menu serves every matching
* row inside the trigger. The row under the pointer, or the row that holds
* focus for Shift+F10 / the ContextMenu key, becomes the context: `contextEl`
* and `context` (a copy of its data-* values) in the Alpine scope, a
* `data-context-menu-open` attribute on the row while the menu is open, and a
* `context-menu:open` event { target, data } from the root. A right-click
* outside a matching row leaves the browser's own menu alone. Closing returns
* focus to the row (or the element inside it that had focus).
*/
import { placeOverlay, trackOverlayPosition } from './overlay-position.js';
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiContextMenu', (config = {}) => ({
open: false,
x: 0,
y: 0,
target: config.target || null,
// The delegated row the menu is open for, and its data-* values.
contextEl: null,
context: {},
// Where focus goes back to on close (the row, or the trigger).
_returnTo: null,
// Teardown for the resize/scroll tracker attached while open.
_untrack: null,
/** The delegated row for an event, or null. */
rowFor(node) {
if (!this.target || !(node instanceof Element)) return null;
const row = node.closest(this.target);
return row && this.$refs.trigger?.contains(row) ? row : null;
},
/** Right-click: with `target`, only a matching row opens this menu. */
onContextMenu(e) {
if (this.target) {
const row = this.rowFor(e.target);
if (!row) return;
this.setContext(row, row.contains(document.activeElement) ? document.activeElement : row);
}
e.preventDefault();
this.openAt(e);
},
setContext(row, returnTo) {
this.clearContext();
this.contextEl = row;
this.context = { ...row.dataset };
this._returnTo = returnTo;
row.setAttribute('data-context-menu-open', 'true');
this.$el.dispatchEvent(new CustomEvent('context-menu:open', {
bubbles: true,
detail: { target: row, data: { ...row.dataset } },
}));
},
clearContext() {
this.contextEl?.removeAttribute('data-context-menu-open');
this.contextEl = null;
this.context = {};
},
openAt(e) {
this.x = e.clientX;
this.y = e.clientY;
this.open = true;
this.$nextTick(() => {
this.reposition();
this.teardownReposition();
this._untrack = trackOverlayPosition(() => this.reposition());
this.items()[0]?.focus();
});
},
// Keyboard activation has no pointer: anchor the menu just inside the
// trigger's start/bottom corner so it visibly belongs to it.
openFromKeyboard(e) {
let anchor = e.currentTarget || this.$refs.trigger;
if (this.target) {
const row = this.rowFor(e.target);
if (!row) return;
this.setContext(row, e.target instanceof HTMLElement ? e.target : row);
anchor = row;
e.preventDefault();
}
const rect = anchor?.getBoundingClientRect();
if (!rect) return;
const rtl = getComputedStyle(this.$el).direction === 'rtl';
this.openAt({ clientX: rtl ? rect.right - 8 : rect.left + 8, clientY: rect.bottom - 4 });
},
/** Place the panel's start corner on the pointer point (no gap). */
reposition() {
const panel = this.$refs.menu;
if (!panel || !this.open) {
return;
}
placeOverlay(panel, { x: this.x, y: this.y }, { gap: 0 });
},
teardownReposition() {
if (this._untrack) {
this._untrack();
this._untrack = null;
}
},
close() {
this.open = false;
this.teardownReposition();
this.contextEl?.removeAttribute('data-context-menu-open');
},
closeAndFocus() {
const returnTo = this._returnTo;
this.close();
this.$nextTick(() => {
const el = returnTo?.isConnected ? returnTo : this.$refs.trigger;
// A row without a tab stop of its own takes focus programmatically.
if (el && el !== this.$refs.trigger && !el.matches('a[href], button, input, select, textarea, [tabindex]')) {
el.setAttribute('tabindex', '-1');
}
el?.focus();
});
},
// A click on a plain menu item 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="context-menu-item"]');
if (!item || item.dataset.disabled === 'true' || event.defaultPrevented) return;
this.closeAndFocus();
},
items() {
const m = this.$refs.menu;
// Checkbox and radio rows borrowed from dropdown are menu items too.
return m
? Array.from(m.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"]), [role="menuitemcheckbox"]:not([aria-disabled="true"]), [role="menuitemradio"]:not([aria-disabled="true"])'))
: [];
},
onKey(e) {
const items = this.items();
if (!items.length) return;
const cur = items.indexOf(document.activeElement);
let next;
switch (e.key) {
case 'ArrowDown':
next = (cur + 1) % items.length;
break;
case 'ArrowUp':
next = (cur - 1 + items.length) % items.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = items.length - 1;
break;
default:
return;
}
e.preventDefault();
items[next].focus();
},
destroy() {
this.teardownReposition();
this.clearContext();
},
}));
});
{{-- Context-menu shortcut — a dropdown shortcut under the context menu's name. `keys="mod+c"` renders platform labels through hotkeys.hint; the slot is the free-text fallback. --}}
<x-ui.dropdown.shortcut data-slot="context-menu-shortcut" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.shortcut>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.2 - 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