Skip to content
LarawellUi

<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
Also adds
Button, Icon, Modal
Used by
Table

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.

Blade
@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.

Blade
@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
Blade
<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>
JavaScript, in your own JS file
// 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);
    }
});

Account menu

The trigger slot takes your own markup, such as an avatar and a name. Other content can sit between the items, like who is signed in, but screen readers only move between items inside a menu, so say it in the trigger's label too. Sign out is a POST form. Uses profile.edit and logout routes from your app.

Show code
Blade
<x-widget.dropdown align="end" label="Account: Ana Silva, ana@example.com">
    <x-slot:trigger class="hover:bg-field py-1.5 ps-1.5 pe-3">
        <span aria-hidden="true" class="bg-primary/10 text-primary grid size-8 place-items-center rounded-full text-xs font-semibold">AS</span>
        <span class="text-sm font-medium">Ana Silva</span>
        <x-widget.icon name="chevron-down" class="size-4 opacity-60" />
    </x-slot:trigger>

    <div aria-hidden="true" class="px-3 pt-1.5 pb-2">
        <p class="text-foreground/60 text-xs">Signed in as</p>
        <p class="truncate font-medium">ana@example.com</p>
    </div>
    <x-widget.dropdown.divider />
    <x-widget.dropdown.item :href="route('profile.edit')" icon="user">Profile</x-widget.dropdown.item>
    <x-widget.dropdown.item :action="route('logout')" icon="log-out">Sign out</x-widget.dropdown.item>
</x-widget.dropdown>

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
Blade
<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 The trigger button's id; scripts can open the menu by it, so it must be unique. Made up when left out.
trigger null Text for a button with a chevron, or an <x-slot:trigger> with your own markup (an avatar and name, say). Without it, the trigger is an icon-only button.
label null The trigger's name for screen readers. Required for the icon-only trigger; the slot trigger uses it too.
icon 'ellipsis' The icon-only trigger's icon.
variant 'neutral' The trigger's look, as on the button: primary, secondary, tertiary, danger, neutral or link.
size 'md' The trigger's size: sm, md or lg.
align 'start' Which edge of the trigger the menu lines up with: start (left in left-to-right pages) or end.

<x-widget.dropdown.item>

Prop Default Description
href null Makes the item a link to this URL.
action null Makes the item a form that submits to this URL, with its CSRF token. Wins over href.
method 'post' With action: the form's method, GET, POST, PUT, PATCH or DELETE, in any case.
icon null An icon before the text.
danger false Red, for destructive actions such as delete; its confirm button is red too.
disabled false Greyed out: it can't be chosen, and the arrow keys skip it.
confirm null A question, e.g. "Delete this order?": asked in a dialog first, and the item only acts once it's confirmed.
confirm-message null With confirm: more text under the question.
confirm-label null With confirm: the confirm button's text. Defaults to the item's own text.
confirm-cancel null With confirm: the cancel button's text. Defaults to "Cancel".

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

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
divider.blade.php
{{-- 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
index.blade.php
@props([
    // The trigger button's id; scripts can open the menu by it, so it must be unique. Made up when left out.
    'id' => null,
    // Text for a button with a chevron, or an <x-slot:trigger> with your own markup (an avatar and name, say).
    // Without it, the trigger is an icon-only button.
    'trigger' => null,
    // The trigger's name for screen readers. Required for the icon-only trigger; the slot trigger uses it too.
    'label' => null,
    // The icon-only trigger's icon.
    'icon' => 'ellipsis',
    // The trigger's look, as on the button: primary, secondary, tertiary, danger, neutral or link.
    'variant' => 'neutral',
    // The trigger's size: sm, md or lg.
    'size' => 'md',
    // Which edge of the trigger the menu lines up with: start (left in left-to-right pages) or end.
    '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.
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($align, ['start', 'end'], true)) {
        throw new \InvalidArgumentException("Unknown align [{$align}] for <x-widget.dropdown>. Use one of: start, end.");
    }
    // The trigger's look, passed to the button; checked here too, so the error names the tag that was written.
    if (! in_array($variant, ['primary', 'secondary', 'tertiary', 'danger', 'neutral', 'link'], true)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.dropdown>. Use one of: primary, secondary, tertiary, danger, neutral, link.");
    }
    if (! in_array($size, ['sm', 'md', 'lg'], true)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.dropdown>. Use one of: sm, md, lg.");
    }

    // 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 focus-visible:ring-offset-surface'])->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
item.blade.php
@props([
    // Makes the item a link to this URL.
    'href' => null,
    // Makes the item a form that submits to this URL, with its CSRF token. Wins over href.
    'action' => null,
    // With action: the form's method, GET, POST, PUT, PATCH or DELETE, in any case.
    'method' => 'post',
    // An icon before the text.
    'icon' => null,
    // Red, for destructive actions such as delete; its confirm button is red too.
    'danger' => false,
    // Greyed out: it can't be chosen, and the arrow keys skip it.
    'disabled' => false,
    // A question, e.g. "Delete this order?": asked in a dialog first, and the item only acts once it's confirmed.
    'confirm' => null,
    // With confirm: more text under the question.
    'confirmMessage' => null,
    // With confirm: the confirm button's text. Defaults to the item's own text.
    'confirmLabel' => null,
    // With confirm: the cancel button's text. Defaults to "Cancel".
    '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',
    };
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array(strtoupper((string) $method), ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) {
        throw new \InvalidArgumentException("Unknown method [{$method}] for <x-widget.dropdown.item>. Use one of: GET, POST, PUT, PATCH, DELETE.");
    }
    $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
index.js
// 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
ElementIds.php
<?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;
    }
}