<x-widget.dropdown>
Dropdown
Menu button on the native popover: row actions, account menus, "More" buttons. Items are links, buttons, or forms that submit with any method (a delete comes with its CSRF token), and confirm="…" asks with modal.confirm() first. Arrow keys, Home/End and type-to-jump, flips above when there's no room, and never clipped by a table or a modal. Works inside Livewire components: items take wire:click, confirm included.
php artisan larawell:add dropdown
Usage
In a table
In a table, give each row's trigger a label that names the row, so a screen reader's list of buttons isn't twenty "Actions". Hiding an item with @can is only for looks: the controller must still authorize the request itself.
@foreach ($orders as $order)
<tr>
<td>#{{ $order->number }}</td>
<td>{{ $order->customer }}</td>
<td class="text-end">
<x-widget.dropdown :label="'Actions for order #'.$order->number" align="end">
<x-widget.dropdown.item :href="route('orders.edit', $order)" icon="pencil">Edit</x-widget.dropdown.item>
@can('delete', $order)
<x-widget.dropdown.divider />
<x-widget.dropdown.item :action="route('orders.destroy', $order)" method="delete" icon="trash" danger :confirm="'Delete order #'.$order->number.'?'">Delete</x-widget.dropdown.item>
@endcan
</x-widget.dropdown>
</td>
</tr>
@endforeach
Livewire
Inside a Livewire component, an item takes wire:click like any button. confirm="…" asks first and only then runs the action, and a disabled item never runs it. A render while the menu is open leaves it open and in place, with its items updated. Choosing an item closes the menu and puts focus back on the trigger.
@foreach ($orders as $order)
<div wire:key="order-{{ $order->id }}" class="flex items-center justify-between">
<span>#{{ $order->number }}</span>
<x-widget.dropdown :label="'Actions for order #'.$order->number" align="end">
<x-widget.dropdown.item icon="copy" wire:click="duplicate({{ $order->id }})">Duplicate</x-widget.dropdown.item>
<x-widget.dropdown.item icon="lock" wire:click="lock({{ $order->id }})" :disabled="$order->locked">Lock</x-widget.dropdown.item>
<x-widget.dropdown.divider />
<x-widget.dropdown.item icon="trash" danger wire:click="delete({{ $order->id }})" :confirm="'Delete order #'.$order->number.'?'">Delete</x-widget.dropdown.item>
</x-widget.dropdown>
</div>
@endforeach
Examples
Row actions
Actions for one record. With no trigger it's an icon-only button, so it needs a label naming the record. Edit is a link, Copy is a plain button for your own script (beside this), and Delete is a form that sends DELETE with its CSRF token once confirm has been answered. Uses orders.edit and orders.destroy routes from your app.
Order #1042
Ana Silva · $129.00
Show code Hide code
<div class="border-line flex max-w-md items-center justify-between gap-4 rounded-2xl border p-4">
<div>
<p class="font-medium">Order #1042</p>
<p class="text-foreground/60 text-sm">Ana Silva · $129.00</p>
</div>
<x-widget.dropdown label="Actions for order #1042" align="end">
<x-widget.dropdown.item :href="route('orders.edit', 1042)" icon="pencil">Edit</x-widget.dropdown.item>
<x-widget.dropdown.item icon="copy" data-clipboard="1042">Copy order number</x-widget.dropdown.item>
<x-widget.dropdown.divider />
<x-widget.dropdown.item :action="route('orders.destroy', 1042)" method="delete" icon="trash" danger confirm="Delete order #1042?" confirm-message="The order and its invoice are removed for good.">Delete</x-widget.dropdown.item>
</x-widget.dropdown>
</div>
// The menu closes itself after a choice; the copy is yours.
document.addEventListener('click', (event) => {
const item = event.target.closest('[data-clipboard]');
if (item) {
navigator.clipboard?.writeText(item.dataset.clipboard);
}
});
Text trigger
A text trigger gets a chevron, and takes the button's variant and size. Here the items are links that keep the page's other query parameters. align="end" lines the menu up with the trigger's end edge; either way it flips above when there's no room below. A disabled item stays in view but can't be chosen, and the arrow keys skip it.
Show code Hide code
<div class="flex flex-wrap items-center gap-3">
<x-widget.dropdown trigger="Export" variant="secondary">
<x-widget.dropdown.item :href="request()->fullUrlWithQuery(['export' => 'csv'])">CSV</x-widget.dropdown.item>
<x-widget.dropdown.item :href="request()->fullUrlWithQuery(['export' => 'xlsx'])">Excel</x-widget.dropdown.item>
<x-widget.dropdown.item disabled>PDF (on the Pro plan)</x-widget.dropdown.item>
</x-widget.dropdown>
<x-widget.dropdown trigger="Sort" align="end" size="sm">
<x-widget.dropdown.item :href="request()->fullUrlWithQuery(['sort' => 'newest'])">Newest first</x-widget.dropdown.item>
<x-widget.dropdown.item :href="request()->fullUrlWithQuery(['sort' => 'oldest'])">Oldest first</x-widget.dropdown.item>
<x-widget.dropdown.item :href="request()->fullUrlWithQuery(['sort' => 'amount'])">Largest amount</x-widget.dropdown.item>
</x-widget.dropdown>
</div>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.dropdown>
| Prop | Default | Description |
|---|---|---|
| id |
null
|
|
| trigger |
null
|
|
| label |
null
|
|
| icon |
'ellipsis'
|
|
| variant |
'neutral'
|
|
| size |
'md'
|
|
| align |
'start'
|
<x-widget.dropdown.item>
| Prop | Default | Description |
|---|---|---|
| href |
null
|
|
| action |
null
|
|
| method |
'post'
|
|
| icon |
null
|
|
| danger |
false
|
|
| disabled |
false
|
|
| confirm |
null
|
|
| confirm-message |
null
|
|
| confirm-label |
null
|
|
| confirm-cancel |
null
|
Source
What larawell:add dropdown writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/dropdown/divider.blade.php Show
{{-- Separates groups of items, e.g. everyday actions from a destructive one. --}}
<div role="separator" {{ $attributes->class(['bg-line -mx-1.5 my-1.5 h-px shrink-0']) }}></div>
resources/views/components/widget/dropdown/index.blade.php Show
@props([
'id' => null,
'trigger' => null,
'label' => null,
'icon' => 'ellipsis',
'variant' => 'neutral',
'size' => 'md',
'align' => 'start',
])
@php
// Scripts may open the menu by its id, so an explicit id must be unique; a derived one gets a suffix.
$id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'dropdown', explicit: $id !== null);
$menuId = "{$id}-menu";
// Which edge of the trigger the menu lines up with: start (left in left-to-right pages) or end.
$align = $align === 'end' ? 'end' : 'start';
// The trigger slot is your own markup (an avatar and name, say); a string is a button with a chevron;
// neither is an icon-only button, which needs a label.
$custom = $trigger instanceof \Illuminate\View\ComponentSlot;
$triggerAttributes = new \Illuminate\View\ComponentAttributeBag([
'id' => $id,
'popovertarget' => $menuId,
'aria-haspopup' => 'menu',
'aria-expanded' => 'false',
'aria-controls' => $menuId,
'data-dropdown-trigger' => '',
// The script keeps aria-expanded in step with the menu; a Livewire render would put back the server's "false".
'wire:ignore.self' => '',
]);
@endphp
{{--
A menu button on the native popover: the browser opens it from popovertarget and closes it on Esc
or a click outside, and it sits in the top layer, so a table's overflow or a modal can't clip it.
resources/js/widget/dropdown adds the menu keyboard and positioning. data-no-row-click keeps a
click in the menu from also opening a clickable table row.
--}}
<div data-dropdown data-no-row-click data-align="{{ $align }}" {{ $attributes->class(['relative inline-flex']) }}>
@if ($custom)
{{-- It is one button, so keep links and other buttons out of the slot. --}}
<button
type="button"
@if ($label) aria-label="{{ $label }}" @endif
{{ $trigger->attributes->class(['focus-visible:ring-primary inline-flex items-center gap-2 rounded-xl text-start outline-none focus-visible:ring-2 focus-visible:ring-offset-2'])->merge($triggerAttributes->getAttributes()) }}
>{{ $trigger }}</button>
@elseif (is_string($trigger) && $trigger !== '')
<x-widget.button :variant="$variant" :size="$size" icon-end="chevron-down" :attributes="$triggerAttributes">{{ $trigger }}</x-widget.button>
@else
<x-widget.button :variant="$variant" :size="$size" :icon="$icon" :label="$label" :attributes="$triggerAttributes" />
@endif
<div
id="{{ $menuId }}"
popover
role="menu"
{{-- The script positions the open menu (inline top/left, data-side); a Livewire render would wipe that and the
menu would jump. Its items still update. --}}
wire:ignore.self
aria-labelledby="{{ $id }}"
data-dropdown-menu
@class([
// hidden until open: a display utility on a popover beats the browser's own rule that hides it while closed, and
// the closed menu would sit there invisible, catching clicks meant for what's under it.
'border-line bg-surface text-foreground fixed inset-auto m-0 hidden open:flex max-h-[min(24rem,calc(100dvh-2rem))] min-w-48 max-w-[calc(100vw-1rem)] flex-col overflow-y-auto overscroll-contain rounded-2xl border p-1.5 text-sm shadow-lg',
// Fades and grows in from the trigger's side (data-side, set by the script when it flips above).
'origin-top opacity-0 scale-95 transition-[opacity,scale,display,overlay] transition-discrete duration-150 open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-95 data-[side=top]:origin-bottom motion-reduce:transition-none',
])
>
{{ $slot }}
</div>
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/dropdown/item.blade.php Show
@props([
'href' => null,
'action' => null,
'method' => 'post',
'icon' => null,
'danger' => false,
'disabled' => false,
'confirm' => null,
'confirmMessage' => null,
'confirmLabel' => null,
'confirmCancel' => null,
])
@php
// A link (href), a form that submits (action + method, e.g. a delete), or a plain button for your own script.
$kind = match (true) {
$disabled => 'button',
$action !== null => 'form',
$href !== null => 'link',
default => 'button',
};
$method = strtoupper($method);
// Forms only speak GET and POST; anything else rides along as Laravel's _method field.
$formMethod = $method === 'GET' ? 'GET' : 'POST';
$classes = [
'flex w-full shrink-0 cursor-pointer items-center gap-2.5 rounded-xl px-3 py-2 text-start whitespace-nowrap outline-none select-none transition-colors',
'aria-disabled:cursor-not-allowed aria-disabled:opacity-40',
// The same text-on-tint mix as the table's error badge, so a danger item stays readable (AA).
'text-[color-mix(in_oklab,var(--color-error)_80%,var(--color-foreground))] not-aria-disabled:hover:bg-error/10 focus-visible:bg-error/10' => $danger,
'not-aria-disabled:hover:bg-field focus-visible:bg-field' => ! $danger,
];
// Roving focus: the script moves focus between items with the arrow keys, so none sits in the Tab order.
$itemAttributes = [
'role' => 'menuitem',
'tabindex' => '-1',
'aria-disabled' => $disabled ? 'true' : null,
'data-danger' => $danger ? '' : null,
'data-confirm' => $confirm,
'data-confirm-message' => $confirmMessage,
'data-confirm-label' => $confirmLabel,
'data-confirm-cancel' => $confirmCancel,
];
@endphp
@if ($kind === 'form')
<form method="{{ $formMethod }}" action="{{ $action }}" class="contents">
@if ($formMethod === 'POST')
@csrf
@endif
@if (! in_array($method, ['GET', 'POST'], true))
@method($method)
@endif
<button type="submit" {{ $attributes->class($classes)->merge($itemAttributes) }}>
@elseif ($kind === 'link')
<a href="{{ $href }}" {{ $attributes->class($classes)->merge($itemAttributes) }}>
@else
<button type="button" {{ $attributes->class($classes)->merge($itemAttributes) }}>
@endif
@if ($icon)
<x-widget.icon :name="$icon" @class(['size-4', 'opacity-60' => ! $danger]) />
@endif
<span class="min-w-0 flex-1 truncate">{{ $slot }}</span>
@if ($kind === 'link')
</a>
@else
</button>
@if ($kind === 'form')
</form>
@endif
@endif
resources/js/widget/dropdown/index.js Show
// Drives <x-widget.dropdown>. Opening, Esc and click-outside come from the native popover (popovertarget);
// this adds the menu keyboard (arrows, Home/End, type-to-jump), positioning next to the trigger, closing
// after a choice, and confirm="…" on items via modal.confirm().
// Everything is delegated from `document`, so menus added to the page later work without setup.
import { modal } from '../modal';
const VIEWPORT_EDGE = 8;
const GAP = 6;
const MENU = '[data-dropdown-menu]';
const ITEM = '[role="menuitem"]:not([aria-disabled="true"])';
let openMenu = null;
let focusLastOnOpen = false;
let typed = '';
let typedTimer = null;
const parts = (menu) => {
const root = menu.closest('[data-dropdown]');
return { root, trigger: root.querySelector('[data-dropdown-trigger]') };
};
const itemsOf = (menu) => [...menu.querySelectorAll(ITEM)];
const isOpen = (menu) => menu.matches(':popover-open');
// Wraps around, so ArrowDown on the last item lands on the first.
function focusItem(menu, index) {
const items = itemsOf(menu);
if (items.length > 0) {
items[(index + items.length) % items.length].focus();
}
}
// Whether any of the trigger can still be seen: not scrolled out of a container that clips it (a table that scrolls
// inside itself, a modal's body) or the window, and not covered there (a table's sticky header).
function inView(trigger, menu) {
const box = trigger.getBoundingClientRect();
let [top, right, bottom, left] = [Math.max(box.top, 0), Math.min(box.right, window.innerWidth), Math.min(box.bottom, window.innerHeight), Math.max(box.left, 0)];
for (let parent = trigger.parentElement; parent; parent = parent.parentElement) {
const style = getComputedStyle(parent);
if (/auto|scroll|hidden|clip/.test(`${style.overflowX} ${style.overflowY}`)) {
const clip = parent.getBoundingClientRect();
[top, right, bottom, left] = [Math.max(top, clip.top), Math.min(right, clip.right), Math.min(bottom, clip.bottom), Math.max(left, clip.left)];
}
}
if (bottom - top < 1 || right - left < 1) {
return false;
}
// The middle of what's left of it, under anything but the menu itself.
const hit = document.elementsFromPoint((left + right) / 2, (top + bottom) / 2).find((element) => !menu.contains(element));
return !hit || trigger.contains(hit);
}
function position() {
if (!openMenu) {
return;
}
const menu = openMenu;
const { root, trigger } = parts(menu);
// The menu sits in the top layer, so nothing clips it: scrolled out of view, the trigger would leave it hanging
// there, pointing at nothing. Close it instead (focus goes back to the trigger without scrolling to it).
if (!inView(trigger, menu)) {
menu.hidePopover();
return;
}
// The trigger shrinks while it's pressed (active:scale-95), and the menu opens mid-press. Its layout box
// (offsetWidth/Height around the same centre) is where it settles, so line the menu up with that instead.
const scaled = trigger.getBoundingClientRect();
const centreX = scaled.left + scaled.width / 2;
const centreY = scaled.top + scaled.height / 2;
const box = {
left: centreX - trigger.offsetWidth / 2,
right: centreX + trigger.offsetWidth / 2,
top: centreY - trigger.offsetHeight / 2,
bottom: centreY + trigger.offsetHeight / 2,
};
const width = menu.offsetWidth;
const height = menu.offsetHeight;
// align="end" lines the menu up with the trigger's end edge, which is the left one in right-to-left pages.
const rtl = getComputedStyle(root).direction === 'rtl';
const toRight = (root.dataset.align === 'end') !== rtl;
const left = toRight ? box.right - width : box.left;
menu.style.left = `${Math.min(Math.max(left, VIEWPORT_EDGE), window.innerWidth - width - VIEWPORT_EDGE)}px`;
// Below by default; above only when it doesn't fit below and does fit above. When it fits neither, the
// side with more room wins and the menu is held to that room (it scrolls), so no item ends up off-screen.
menu.style.maxHeight = '';
const roomBelow = window.innerHeight - VIEWPORT_EDGE - (box.bottom + GAP);
const roomAbove = box.top - GAP - VIEWPORT_EDGE;
const up = height > roomBelow && (height <= roomAbove || roomAbove > roomBelow);
const room = up ? roomAbove : roomBelow;
if (height > room) {
menu.style.maxHeight = `${Math.max(room, 0)}px`;
}
menu.style.top = `${up ? box.top - GAP - Math.min(height, room) : box.bottom + GAP}px`;
menu.dataset.side = up ? 'top' : 'bottom';
}
// Native <select> behaviour: typing letters jumps to the next item that starts with them.
function typeahead(menu, character) {
typed += character.toLowerCase();
clearTimeout(typedTimer);
typedTimer = setTimeout(() => { typed = ''; }, 500);
const items = itemsOf(menu);
const start = items.indexOf(document.activeElement);
// A repeated single letter cycles through the items that start with it.
const offset = typed.length === 1 ? 1 : 0;
for (let step = 0; step < items.length; step++) {
const item = items[(start + offset + step + items.length) % items.length];
if (item.textContent.trim().toLowerCase().startsWith(typed)) {
item.focus();
return;
}
}
}
document.addEventListener('keydown', (event) => {
const trigger = event.target.closest?.('[data-dropdown-trigger]');
if (trigger && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
event.preventDefault();
const menu = document.getElementById(trigger.getAttribute('aria-controls'));
focusLastOnOpen = event.key === 'ArrowUp';
isOpen(menu) ? focusItem(menu, focusLastOnOpen ? -1 : 0) : menu.showPopover();
return;
}
const menu = event.target.closest?.(MENU);
if (!menu) {
return;
}
const current = itemsOf(menu).indexOf(document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
focusItem(menu, current + 1);
break;
case 'ArrowUp':
event.preventDefault();
focusItem(menu, current === -1 ? -1 : current - 1);
break;
case 'Home':
event.preventDefault();
focusItem(menu, 0);
break;
case 'End':
event.preventDefault();
focusItem(menu, -1);
break;
case 'Tab':
// Back to the trigger first, so Tab carries on from there rather than from the top of the page.
menu.hidePopover();
parts(menu).trigger.focus();
break;
case ' ':
// Space activates a button natively; on a link it would scroll the page instead.
if (event.target.matches('a[role="menuitem"]')) {
event.preventDefault();
event.target.click();
}
break;
default:
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
typeahead(menu, event.key);
}
}
});
// Capture phase, so a disabled item or one still waiting on confirm stops the click before the item's own listeners
// see it: wire:click, Alpine's @click or yours would otherwise act on it, since preventDefault() only stops a link
// or a form.
document.addEventListener('click', (event) => {
const item = event.target.closest?.(`${MENU} [role="menuitem"]`);
if (!item) {
return;
}
const disabled = item.getAttribute('aria-disabled') === 'true';
const unconfirmed = item.dataset.confirm && !item.hasAttribute('data-confirmed');
if (!disabled && !unconfirmed) {
return;
}
event.preventDefault();
event.stopPropagation();
if (disabled) {
return;
}
const menu = item.closest(MENU);
if (isOpen(menu)) {
menu.hidePopover();
}
// Ask first, then replay the click, which follows the link, submits the form or runs the action as it would have.
modal.confirm({
title: item.dataset.confirm,
message: item.dataset.confirmMessage ?? '',
confirm: item.dataset.confirmLabel || item.textContent.trim(),
cancel: item.dataset.confirmCancel || undefined,
danger: item.hasAttribute('data-danger'),
}).then((confirmed) => {
if (confirmed) {
item.setAttribute('data-confirmed', '');
item.click();
item.removeAttribute('data-confirmed');
}
});
}, true);
// A choice closes the menu.
document.addEventListener('click', (event) => {
const menu = event.target.closest?.(`${MENU} [role="menuitem"]`)?.closest(MENU);
if (menu && isOpen(menu)) {
menu.hidePopover();
}
});
// Popover toggle events don't bubble, so listen in the capture phase.
document.addEventListener('beforetoggle', (event) => {
if (event.target.matches?.(MENU) && event.newState === 'open') {
// Hidden until positioned, otherwise it flashes at the popover default (screen centre).
event.target.style.visibility = 'hidden';
}
}, true);
document.addEventListener('toggle', (event) => {
const menu = event.target;
if (!menu.matches?.(MENU)) {
return;
}
const { trigger } = parts(menu);
const open = event.newState === 'open';
trigger.setAttribute('aria-expanded', String(open));
if (open) {
openMenu = menu;
position();
menu.style.visibility = '';
focusItem(menu, focusLastOnOpen ? -1 : 0);
focusLastOnOpen = false;
// Capture phase so scrolling any ancestor (a table, a modal) also moves it.
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
return;
}
if (openMenu === menu) {
openMenu = null;
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
}
// Esc or choosing an item leaves focus nowhere; put it back on the trigger. A click elsewhere keeps its own focus.
if (document.activeElement === document.body || menu.contains(document.activeElement)) {
trigger.focus({ preventScroll: true });
}
}, true);
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}