<x-widget.command>
Command palette Blade component for Laravel and Livewire
A command palette: Ctrl+K (Cmd+K on a Mac) opens a search box over the page that finds pages and actions as you type, by label or keywords, in groups. With suggest-url it adds your app's own results, and recent keeps the last few choices. The arrow keys move through it with focus staying in the box; Enter opens a page or clicks an action, so wire:click works. command.open() from scripts, and command-open events from Livewire.
php artisan bladewell:add command
- Also adds
- Icon
Usage
Livewire
Inside a Livewire component, an item without href takes wire:click: choosing it by Enter or a click runs the action and closes the palette. Open it from the component with $this->dispatch('command-open', id: 'palette').
<x-widget.command id="palette" label="Search pages and actions">
<x-widget.command.item wire:click="archiveAll" icon="inbox">Archive all read</x-widget.command.item>
<x-widget.command.item href="/settings" icon="settings">Settings</x-widget.command.item>
</x-widget.command>
Examples
Basic
Ctrl+K (Cmd+K on a Mac) or the button opens it. Typing filters by label and keywords, every word in any order: try "new inv". An item with href opens that page; one without is clicked, so give it wire:click or listen for the click, as the script beside this does.
Show code Hide code
<x-widget.command id="palette" label="Search pages and actions">
<x-widget.command.group label="Pages">
<x-widget.command.item href="#dashboard" icon="home" keywords="start overview">Dashboard</x-widget.command.item>
<x-widget.command.item href="#invoices" icon="file" keywords="billing bills">Invoices</x-widget.command.item>
<x-widget.command.item href="#customers" icon="users" keywords="clients people">Customers</x-widget.command.item>
<x-widget.command.item href="#settings" icon="settings" keywords="account profile preferences" shortcut="G S">Settings</x-widget.command.item>
</x-widget.command.group>
<x-widget.command.group label="Actions">
<x-widget.command.item icon="plus" keywords="create add" data-palette-action="New invoice">New invoice</x-widget.command.item>
<x-widget.command.item icon="copy" keywords="share url" data-palette-action="Link copied">Copy page link</x-widget.command.item>
</x-widget.command.group>
</x-widget.command>
<p id="palette-action" role="status" class="text-foreground/70 mt-3 text-sm"></p>
// An item without href is clicked when chosen, by the pointer or Enter: here, it says what it did.
document.addEventListener('click', (event) => {
const item = event.target.closest('[data-palette-action]');
if (item) {
document.getElementById('palette-action').textContent = item.dataset.paletteAction;
}
});
Search your app
suggest-url asks your app as people type (GET ?q=…, JSON), with the same answer the search widget's suggestions take; its results follow the items that match. recent keeps the last five pages chosen, in this browser only, and lists them before anything is typed. trigger="false" leaves out the button: here a link opens it, with data-command-open.
, or press Ctrl+J.
Show code Hide code
<x-widget.command id="product-palette" label="Search products" placeholder="Search products…" :suggest-url="route('products.suggest')" :trigger="false" shortcut="j" recent>
<x-widget.command.group label="Browse">
<x-widget.command.item href="#new-arrivals" icon="star">New arrivals</x-widget.command.item>
<x-widget.command.item href="#on-sale" icon="tag" keywords="discount offers">On sale</x-widget.command.item>
</x-widget.command.group>
</x-widget.command>
<p class="text-sm">
<button type="button" data-command-open="product-palette" class="text-link hover:text-link-hover underline underline-offset-4">Search products</button>, or press Ctrl+J.
</p>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.command>
| Prop | Default | Description |
|---|---|---|
| id |
null
|
The palette's id: anything with data-command-open="{id}" opens it, and so does command.open('{id}'). Made up when left out. |
| label |
'Search'
|
Its name for screen readers, and the trigger button's text. |
| placeholder |
'Type a command or search…'
|
The search box's placeholder. |
| shortcut |
'k'
|
The key that opens it with Ctrl (Cmd on a Mac): Ctrl+K by default. false for none. |
| trigger |
true
|
A button that opens it, styled like a search box, with the shortcut on it. false to open it only by the shortcut or your own data-command-open element. |
| suggest-url |
null
|
Your app's search, asked as people type (GET ?q=…, JSON): the same answer the search widget's suggest-url takes. Its results are listed after the items that match. |
| recent |
false
|
Keeps the last few items chosen, in this browser only (localStorage), and shows them before anything is typed. |
| empty |
'No results'
|
What it says when nothing matches. |
<x-widget.command.group>
| Prop | Default | Description |
|---|---|---|
| label | Required | The heading over the group's items, which also names the group for screen readers. |
<x-widget.command.item>
| Prop | Default | Description |
|---|---|---|
| href |
null
|
A page to open when it's chosen. Without one, choosing it clicks it: give it wire:click, or listen for the click. |
| icon |
null
|
An icon before the label, by name, as the icon widget takes it. |
| keywords |
null
|
More words it's found by, besides its label: "settings account profile". |
| shortcut |
null
|
A key hint shown at the end of the row, such as "G D". Only shown: the palette doesn't bind it. |
Accessibility
All 2 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 bladewell:add command 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/command/group.blade.php Show
@props([
// The heading over the group's items, which also names the group for screen readers.
'label',
])
@php
$headingId = 'command-group-'.\Illuminate\Support\Str::random(8);
@endphp
{{-- Hidden with its items when none of them matches what's typed. --}}
<div role="group" aria-labelledby="{{ $headingId }}" data-command-group {{ $attributes->class(['py-1']) }}>
<div id="{{ $headingId }}" class="text-muted px-3 pt-2 pb-1 text-xs font-medium">{{ $label }}</div>
{{ $slot }}
</div>
resources/views/components/widget/command/index.blade.php Show
@props([
// The palette's id: anything with data-command-open="{id}" opens it, and so does command.open('{id}'). Made up when
// left out.
'id' => null,
// Its name for screen readers, and the trigger button's text.
'label' => 'Search',
// The search box's placeholder.
'placeholder' => 'Type a command or search…',
// The key that opens it with Ctrl (Cmd on a Mac): Ctrl+K by default. false for none.
'shortcut' => 'k',
// A button that opens it, styled like a search box, with the shortcut on it. false to open it only by the shortcut or
// your own data-command-open element.
'trigger' => true,
// Your app's search, asked as people type (GET ?q=…, JSON): the same answer the search widget's suggest-url takes.
// Its results are listed after the items that match.
'suggestUrl' => null,
// Keeps the last few items chosen, in this browser only (localStorage), and shows them before anything is typed.
'recent' => false,
// What it says when nothing matches.
'empty' => 'No results',
])
@php
// Scripts open it 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 ?? 'command', explicit: $id !== null);
$shortcut = $shortcut === false || $shortcut === null ? null : strtolower((string) $shortcut);
@endphp
@if ($trigger)
{{-- Looks like a search box, but it's a button: the box is in the dialog. --}}
<button
type="button"
data-command-open="{{ $id }}"
aria-haspopup="dialog"
{{ $attributes->class(['bg-field text-foreground/60 hover:text-foreground focus-visible:ring-primary inline-flex h-10 min-w-56 items-center gap-2 rounded-xl px-3 text-sm outline-none transition-colors focus-visible:ring-2']) }}
>
<x-widget.icon name="search" class="size-4 shrink-0" />
<span class="flex-1 text-start">{{ $label }}</span>
@if ($shortcut)
{{-- Ctrl here; the script shows ⌘ on a Mac. --}}
<kbd data-command-key="{{ $shortcut }}" class="border-line bg-surface text-foreground/60 rounded-md border px-1.5 py-0.5 font-sans text-xs">Ctrl {{ strtoupper($shortcut) }}</kbd>
@endif
</button>
@endif
{{--
A native modal <dialog>: the browser keeps focus inside it, closes it on Esc and puts focus back where it was. The
search box is a combobox: focus stays in it while the arrow keys move the highlight through the options
(aria-activedescendant), as in any search-as-you-type list. resources/js/widget/command filters, fetches and
activates.
wire:ignore.self: whether it's open is the person's; a Livewire render would put back the server's and close it.
--}}
<dialog
id="{{ $id }}"
data-command
wire:ignore.self
aria-label="{{ $label }}"
@if ($shortcut) data-shortcut="{{ $shortcut }}" @endif
@if ($suggestUrl) data-suggest-url="{{ $suggestUrl }}" @endif
@if ($recent) data-recent @endif
class="group fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none bg-transparent px-3 pt-[12dvh] outline-none open:flex open:flex-col open:items-center backdrop:bg-foreground/40"
>
<div class="bg-surface text-foreground flex max-h-[min(32rem,76dvh)] w-full max-w-xl flex-col overflow-hidden rounded-2xl shadow-xl">
<div class="border-line flex items-center gap-2 border-b px-4">
<x-widget.icon name="search" class="text-foreground/60 size-5 shrink-0" />
<input
type="text"
role="combobox"
aria-expanded="true"
aria-controls="{{ $id }}-list"
aria-autocomplete="list"
aria-label="{{ $label }}"
autocomplete="off"
spellcheck="false"
enterkeyhint="go"
placeholder="{{ $placeholder }}"
data-command-input
class="placeholder:text-muted h-14 min-w-0 flex-1 bg-transparent text-base outline-none"
>
<span data-command-busy hidden class="border-line border-t-primary size-4 shrink-0 animate-spin rounded-full border-2"></span>
<kbd aria-hidden="true" class="border-line text-foreground/60 hidden rounded-md border px-1.5 py-0.5 font-sans text-xs sm:inline">Esc</kbd>
</div>
<div id="{{ $id }}-list" role="listbox" aria-label="{{ $label }}" data-command-list class="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]">
{{-- Filled by the script: recent choices before anything is typed, then your app's results. --}}
<div role="group" aria-label="Recent" data-command-recent hidden></div>
{{ $slot }}
<div role="group" aria-label="Results" data-command-results hidden></div>
</div>
<p data-command-empty hidden class="text-muted px-4 py-8 text-center text-sm">{{ $empty }}</p>
<p role="status" data-command-status class="sr-only"></p>
</div>
</dialog>
resources/views/components/widget/command/item.blade.php Show
@props([
// A page to open when it's chosen. Without one, choosing it clicks it: give it wire:click, or listen for the click.
'href' => null,
// An icon before the label, by name, as the icon widget takes it.
'icon' => null,
// More words it's found by, besides its label: "settings account profile".
'keywords' => null,
// A key hint shown at the end of the row, such as "G D". Only shown: the palette doesn't bind it.
'shortcut' => null,
])
{{--
An option of the palette's list: not focused itself (focus stays in the search box), but highlighted with the arrow
keys or the pointer and chosen with Enter or a click. Your attributes go on it, so wire:click works.
--}}
<div
role="option"
id="command-item-{{ \Illuminate\Support\Str::random(8) }}"
aria-selected="false"
data-command-item
@if ($href) data-href="{{ $href }}" @endif
@if ($keywords) data-keywords="{{ $keywords }}" @endif
{{ $attributes->class(['text-foreground aria-selected:bg-field flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm']) }}
>
@if ($icon)
<x-widget.icon :name="$icon" class="text-foreground/60 size-4 shrink-0" />
@endif
<span data-command-label class="min-w-0 flex-1 truncate">{{ $slot }}</span>
@if ($shortcut)
<kbd aria-hidden="true" class="border-line text-foreground/60 rounded-md border px-1.5 py-0.5 font-sans text-xs">{{ $shortcut }}</kbd>
@endif
</div>
resources/js/widget/command/index.js Show
// Drives <x-widget.command>, a command palette in a native modal <dialog>. Ctrl+K (Cmd+K on a Mac), a click on
// [data-command-open="{id}"] or command.open('{id}') opens it. Typing filters the items by their label and keywords (every
// word must match), and with suggest-url your app's results follow them. Up and Down move the highlight while focus stays
// in the box; Enter or a click chooses: an item with an href opens that page (with wire:navigate, through Livewire), any
// other is clicked, so wire:click and your own listeners run. Esc or a click outside closes it.
// Events on the dialog: command:opened, command:closed, and command:chosen (detail.item).
const DIALOG = 'dialog[data-command]';
const ITEM = '[data-command-item]';
const WAIT_MS = 200;
const RECENT_MAX = 5;
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
const partsOf = (dialog) => ({
input: dialog.querySelector('[data-command-input]'),
list: dialog.querySelector('[data-command-list]'),
recent: dialog.querySelector('[data-command-recent]'),
results: dialog.querySelector('[data-command-results]'),
empty: dialog.querySelector('[data-command-empty]'),
status: dialog.querySelector('[data-command-status]'),
busy: dialog.querySelector('[data-command-busy]'),
});
const visibleItems = (dialog) => [...dialog.querySelectorAll(ITEM)].filter((item) => !item.hidden && !item.closest('[hidden]'));
const labelOf = (item) => item.querySelector('[data-command-label]')?.textContent.trim() ?? item.textContent.trim();
// --- Highlight -----------------------------------------------------------------------------------------------------------
function highlight(dialog, item) {
const { input } = partsOf(dialog);
dialog.querySelectorAll(`${ITEM}[aria-selected="true"]`).forEach((other) => other.setAttribute('aria-selected', 'false'));
if (item) {
item.setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', item.id);
item.scrollIntoView({ block: 'nearest' });
} else {
input.removeAttribute('aria-activedescendant');
}
}
function move(dialog, step) {
const items = visibleItems(dialog);
if (items.length === 0) {
return;
}
const at = items.findIndex((item) => item.getAttribute('aria-selected') === 'true');
highlight(dialog, items[(at + step + items.length) % items.length]);
}
// --- Filtering ---------------------------------------------------------------------------------------------------------
// Every word typed must appear in the label or the keywords, in any order: "inv new" finds "New invoice".
function filter(dialog) {
const { input, recent, results, empty, status } = partsOf(dialog);
const words = input.value.toLowerCase().split(/\s+/).filter(Boolean);
dialog.querySelectorAll(ITEM).forEach((item) => {
if (item.closest('[data-command-results], [data-command-recent]')) {
return;
}
const text = `${labelOf(item)} ${item.dataset.keywords ?? ''}`.toLowerCase();
item.hidden = !words.every((word) => text.includes(word));
});
dialog.querySelectorAll('[data-command-group]').forEach((group) => {
group.hidden = !group.querySelector(`${ITEM}:not([hidden])`);
});
// Recent choices only before anything is typed; your app's results only after.
recent.hidden = words.length > 0 || !recent.querySelector(ITEM);
if (words.length === 0) {
results.replaceChildren();
}
results.hidden = !results.querySelector(ITEM);
const count = visibleItems(dialog).length;
empty.hidden = count > 0 || dialog.hasAttribute('data-searching');
status.textContent = words.length === 0 ? '' : count === 1 ? '1 result' : `${count} results`;
highlight(dialog, visibleItems(dialog)[0] ?? null);
}
// --- Your app's results (suggest-url) ----------------------------------------------------------------------------------
const timers = new WeakMap();
const requests = new WeakMap();
// The same answer the search widget's suggest-url takes: a list (or {"data": [...]}) of strings, or of label with an
// optional meta, href and group. Built with textContent, so nothing in it can inject markup.
function showResults(dialog, answer) {
const { results } = partsOf(dialog);
const rows = (Array.isArray(answer) ? answer : answer?.data ?? []).map((row) => (typeof row === 'string' ? { label: row } : row));
results.replaceChildren(...rows.filter((row) => row?.label).map((row) => {
const item = Object.assign(document.createElement('div'), {
className: 'text-foreground aria-selected:bg-field flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm',
id: `command-result-${Math.random().toString(36).slice(2, 10)}`,
});
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
item.dataset.commandItem = '';
if (row.href) {
item.dataset.href = row.href;
}
const label = Object.assign(document.createElement('span'), { className: 'min-w-0 flex-1 truncate', textContent: row.label });
label.dataset.commandLabel = '';
item.append(label);
if (row.meta || row.group) {
item.append(Object.assign(document.createElement('span'), { className: 'text-muted shrink-0 text-xs', textContent: row.meta ?? row.group }));
}
return item;
}));
}
function search(dialog) {
const url = dialog.dataset.suggestUrl;
const query = partsOf(dialog).input.value.trim();
clearTimeout(timers.get(dialog));
requests.get(dialog)?.abort();
if (!url || query === '') {
dialog.removeAttribute('data-searching');
partsOf(dialog).busy.hidden = true;
return;
}
dialog.setAttribute('data-searching', '');
partsOf(dialog).busy.hidden = false;
timers.set(dialog, setTimeout(async () => {
const controller = new AbortController();
requests.set(dialog, controller);
try {
const target = new URL(url, location.href);
target.searchParams.set('q', query);
const response = await fetch(target, { headers: { Accept: 'application/json' }, credentials: 'same-origin', signal: controller.signal });
showResults(dialog, response.ok ? await response.json() : []);
} catch (error) {
if (error.name === 'AbortError') {
return;
}
showResults(dialog, []);
}
dialog.removeAttribute('data-searching');
partsOf(dialog).busy.hidden = true;
filter(dialog);
}, WAIT_MS));
}
// --- Recent choices (recent) -------------------------------------------------------------------------------------------
const recentKey = (dialog) => `bladewell-command-recent:${dialog.id}`;
function readRecent(dialog) {
try {
return JSON.parse(localStorage.getItem(recentKey(dialog)) ?? '[]');
} catch {
return [];
}
}
// Only items that open a page are remembered: a click's action may not make sense to repeat out of its context.
function remember(dialog, item) {
if (!dialog.hasAttribute('data-recent') || !item.dataset.href) {
return;
}
const entry = { label: labelOf(item), href: item.dataset.href };
const kept = readRecent(dialog).filter((other) => other.href !== entry.href);
try {
localStorage.setItem(recentKey(dialog), JSON.stringify([entry, ...kept].slice(0, RECENT_MAX)));
} catch {
// Storage full or blocked: the palette works the same, without recent choices.
}
}
function showRecent(dialog) {
if (!dialog.hasAttribute('data-recent')) {
return;
}
const { recent } = partsOf(dialog);
const heading = Object.assign(document.createElement('div'), { className: 'text-muted px-3 pt-2 pb-1 text-xs font-medium', textContent: 'Recent' });
showResults(dialog, readRecent(dialog));
const { results } = partsOf(dialog);
recent.replaceChildren(heading, ...results.children);
}
// --- Opening, choosing -------------------------------------------------------------------------------------------------
function open(target) {
const dialog = typeof target === 'string' ? document.getElementById(target) : target;
if (!dialog?.matches(DIALOG) || dialog.open) {
return;
}
const { input } = partsOf(dialog);
input.value = '';
showRecent(dialog);
filter(dialog);
dialog.showModal();
input.focus();
dialog.dispatchEvent(new CustomEvent('command:opened', { bubbles: true }));
}
function close(target) {
const dialog = typeof target === 'string' ? document.getElementById(target) : target;
if (dialog?.open) {
dialog.close();
}
}
// Set while a keyboard choice clicks an item, so the click listener below doesn't choose it a second time.
let choosing = false;
function choose(dialog, item, { viaClick = false } = {}) {
remember(dialog, item);
dialog.dispatchEvent(new CustomEvent('command:chosen', { bubbles: true, detail: { item } }));
close(dialog);
const href = item.dataset.href;
if (href) {
if (item.hasAttribute('wire:navigate') && window.Livewire?.navigate) {
window.Livewire.navigate(href);
} else {
window.location.assign(href);
}
return;
}
// A keyboard choice clicks the item, so wire:click and your own listeners run as they do for a pointer.
if (!viaClick) {
choosing = true;
item.click();
choosing = false;
}
}
document.addEventListener('click', (event) => {
const opener = event.target.closest?.('[data-command-open]');
if (opener) {
open(opener.dataset.commandOpen);
return;
}
const dialog = event.target.closest?.(DIALOG);
if (!dialog) {
return;
}
// The dimmed area around the panel is the dialog itself: a click there closes it.
if (event.target === dialog) {
close(dialog);
return;
}
const item = event.target.closest(ITEM);
if (item && !choosing) {
choose(dialog, item, { viaClick: true });
}
});
// The pointer moves the highlight too, so Enter after pointing chooses what's under it.
document.addEventListener('pointermove', (event) => {
const item = event.target.closest?.(`${DIALOG} ${ITEM}`);
if (item && item.getAttribute('aria-selected') !== 'true') {
highlight(item.closest(DIALOG), item);
}
});
document.addEventListener('input', (event) => {
const dialog = event.target.matches?.('[data-command-input]') ? event.target.closest(DIALOG) : null;
if (dialog) {
search(dialog);
filter(dialog);
}
});
document.addEventListener('keydown', (event) => {
// The shortcut: Ctrl (Cmd on a Mac) and the palette's key, from anywhere. Again, it closes.
if ((isMac ? event.metaKey : event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.length === 1) {
const dialog = document.querySelector(`${DIALOG}[data-shortcut="${CSS.escape(event.key.toLowerCase())}"]`);
if (dialog) {
event.preventDefault();
dialog.open ? close(dialog) : open(dialog);
return;
}
}
const dialog = event.target.matches?.('[data-command-input]') ? event.target.closest(DIALOG) : null;
if (!dialog) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
move(dialog, event.key === 'ArrowDown' ? 1 : -1);
} else if (event.key === 'Enter' && !event.isComposing) {
const item = dialog.querySelector(`${ITEM}[aria-selected="true"]`);
if (item) {
event.preventDefault();
choose(dialog, item);
}
}
});
// `close` doesn't bubble, so listen in the capture phase.
document.addEventListener('close', (event) => {
if (event.target.matches?.(DIALOG)) {
clearTimeout(timers.get(event.target));
requests.get(event.target)?.abort();
event.target.dispatchEvent(new CustomEvent('command:closed', { bubbles: true }));
}
}, true);
// The shortcut's key reads ⌘ on a Mac.
function labelKeys(scope = document) {
if (isMac) {
scope.querySelectorAll('kbd[data-command-key]').forEach((kbd) => {
kbd.textContent = `⌘${kbd.dataset.commandKey.toUpperCase()}`;
});
}
}
// From a Livewire component ($this->dispatch('command-open', id: 'palette')) or any script.
window.addEventListener('command-open', (event) => open(event.detail?.id));
window.addEventListener('command-close', (event) => close(event.detail?.id));
// After a Livewire render the items may have changed: filter them again by what's typed, and relabel the keys.
const hookIntoLivewire = (Livewire) => Livewire.hook('morphed', ({ el }) => {
labelKeys(el);
document.querySelectorAll(`${DIALOG}[open]`).forEach(filter);
});
if (window.Livewire) {
hookIntoLivewire(window.Livewire);
} else {
document.addEventListener('livewire:init', () => hookIntoLivewire(window.Livewire));
}
export const command = { open, close };
labelKeys();
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;
}
}