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
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
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([
    '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
item.blade.php
@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
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;
    }
}