Skip to content
LarawellUi

<x-widget.search>

Search

A search box that fills itself from the query string, in two sizes, with a clear button, the icon at either end and an optional keyboard shortcut. With suggest-url it suggests as you type, like a store's search: from your own endpoint, with details, links straight to a page, and groups. It searches as you type in a table's filters slot or with Livewire's wire:model.live; on its own it submits its form.

php artisan larawell:add search
Also adds
Icon
Used by
Phone, Select, Table

Usage

Suggestions endpoint

The URL in suggest-url answers GET ?q=… with JSON: a list, or a resource collection ({"data": [...]}). Each suggestion is a string, or a label with an optional value (what fills the box), meta, href (a page to open) and group. Keep it short, and throttle it: it runs as people type.

PHP
Route::get('/products/suggest', function (Request $request) {
    $q = $request->validate(['q' => ['required', 'string', 'max:100']])['q'];

    return Product::query()
        ->where('name', 'like', '%'.addcslashes($q, '%_\\').'%')
        ->orderBy('name')
        ->limit(8)
        ->get()
        ->map(fn (Product $product): array => [
            'label' => $product->name,
            'meta' => $product->category->name,
            'href' => route('products.show', $product),
        ]);
})->middleware('throttle:120,1')->name('products.suggest');

Livewire

In a Livewire component, bind with wire:model.live (add .debounce.300ms to wait for a pause in typing); no name is needed. The box shows the property after every render, so clearing it in PHP empties it too, and the × button empties the property as if the text had been deleted. Filter in render(): ->where('name', 'like', "%{$this->search}%").

Blade
<div class="space-y-4">
    <x-widget.search placeholder="Search customers" shortcut="/" wire:model.live.debounce.300ms="search" />

    <ul>
        @foreach ($customers as $customer)
            <li wire:key="{{ $customer->id }}">{{ $customer->name }}</li>
        @endforeach
    </ul>
</div>

Examples

Sizes and widths

size sets the height, matching the button and field sizes: sm (32px) beside compact controls, md (48px, the default) beside form fields. The width is the layout's: the box fills its container, so narrow it with a class such as max-w-xs. The × clears the text.

Show code
Blade
<div class="flex flex-col gap-4">
    <x-widget.search name="find" placeholder="Search transactions" />
    <x-widget.search name="find_small" placeholder="Small search" size="sm" />
    <x-widget.search name="find_narrow" placeholder="Narrow search" class="max-w-xs" />
</div>

Search form

In a GET form, Enter submits and the page reloads with ?q=…; the box fills itself back in from the query string, so the results can be shared and bookmarked. That's all it does on its own. To search as you type without a reload, put it in a table's filters slot, or bind it with wire:model.live in Livewire.

Show code
Blade
<form method="GET" role="search" class="flex max-w-md items-center gap-2">
    <x-widget.search name="q" placeholder="Search the help centre" label="Search the help centre" />
    <button type="submit" class="bg-primary-fill text-on-primary focus-visible:ring-primary h-12 shrink-0 rounded-[20px] px-5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface">Search</button>
</form>

Icon at the start

icon-position="start" puts the magnifier before the text. value fills it in (the query string wins, when it has one); :clearable="false" leaves out the × button.

Show code
Blade
<div class="flex flex-col gap-4">
    <x-widget.search name="team" placeholder="Search people" icon-position="start" />
    <x-widget.search name="team_fixed" value="Design" icon-position="start" :clearable="false" size="sm" class="max-w-xs" />
</div>

Keyboard shortcut

shortcut="/" jumps to the box from anywhere on the page, as on GitHub: press / (not while typing in another field). The key shows as a hint while the box is empty and unfocused, and screen readers hear it from aria-keyshortcuts.

Show code
Blade
<x-widget.search name="docs" placeholder="Search the docs" shortcut="/" class="max-w-md" />

Suggestions

suggest-url lists suggestions as you type, after two characters (suggest-min). The arrow keys move through them and Enter takes one, filling the box; in a form, it then searches. Try "wire" or "desk".

Show code
Blade
<x-widget.search name="q" placeholder="Search products" class="max-w-md" suggest-url="{{ route('products.suggest') }}" />

Suggestions with details

A suggestion can carry small text at the end (meta): a category, a price, a count. The typed text is shown in bold. Try "key".

Show code
Blade
<x-widget.search name="q" placeholder="Search products" class="max-w-md" suggest-url="{{ route('products.suggest', ['style' => 'details']) }}" />

Suggestions that open a page

A suggestion with an href opens that page when chosen, instead of searching: a store's product, a docs article. Try "phone".

Show code
Blade
<x-widget.search name="q" placeholder="Search products" class="max-w-md" suggest-url="{{ route('products.suggest', ['style' => 'links']) }}" />

Grouped suggestions

Suggestions with a group are listed under its heading, in the order the groups first appear. Try "wireless".

Show code
Blade
<x-widget.search name="q" placeholder="Search products" class="max-w-md" suggest-url="{{ route('products.suggest', ['style' => 'grouped']) }}" />

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.search>

Prop Default Description
name null What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
id null Defaults to one made from the name (or the wire:model property).
value null Defaults to the query string under the name, so a search form fills back in; or the bound Livewire property.
placeholder 'Search' Shown while it's empty.
label null Its name for screen readers; defaults to the placeholder.
size 'md' sm (32px) or md (48px).
clearable true An × button, shown while there's text, that empties the box. It fires input and change, so whatever listens (a table's filters, wire:model) reacts as if the text had been deleted.
icon-position 'end' Where the magnifier sits: end (after the text) or start (before it).
shortcut null A key that jumps to the box from anywhere on the page, e.g. "/", shown as a hint while the box is empty and unfocused. One character, without modifiers; it doesn't fire while you're typing in another field.
suggest-url null Suggestions as you type, like a store's search: a URL that answers GET ?q=… with JSON, a list or {"data": [...]}. Each suggestion is a string, or ['label' => …, 'value' => what fills the box, 'meta' => small text at the end, 'href' => a page to open instead, 'group' => a heading to list it under].
suggest-min 2 How many characters to type before asking for suggestions.
messages [] Rewords what screen readers hear about the suggestions, by key: count (:count), none, failed.

Accessibility

All 8 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 search 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 Field, and the theme and base CSS.

resources/views/components/widget/search/index.blade.php Show
index.blade.php
@props([
    // What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
    'name' => null,
    // Defaults to one made from the name (or the wire:model property).
    'id' => null,
    // Defaults to the query string under the name, so a search form fills back in; or the bound Livewire property.
    'value' => null,
    // Shown while it's empty.
    'placeholder' => 'Search',
    // Its name for screen readers; defaults to the placeholder.
    'label' => null,
    // sm (32px) or md (48px).
    'size' => 'md',
    // An × button, shown while there's text, that empties the box. It fires input and change, so whatever listens (a
    // table's filters, wire:model) reacts as if the text had been deleted.
    'clearable' => true,
    // Where the magnifier sits: end (after the text) or start (before it).
    'iconPosition' => 'end',
    // A key that jumps to the box from anywhere on the page, e.g. "/", shown as a hint while the box is empty and
    // unfocused. One character, without modifiers; it doesn't fire while you're typing in another field.
    'shortcut' => null,
    // Suggestions as you type, like a store's search: a URL that answers GET ?q=… with JSON, a list or {"data": [...]}. Each
    // suggestion is a string, or ['label' => …, 'value' => what fills the box, 'meta' => small text at the end,
    // 'href' => a page to open instead, 'group' => a heading to list it under].
    'suggestUrl' => null,
    // How many characters to type before asking for suggestions.
    'suggestMin' => 2,
    // Rewords what screen readers hear about the suggestions, by key: count (:count), none, failed.
    'messages' => [],
])

@php
    $field = \App\View\Widget\FormField::make($name, $id, null, idPrefix: 'search', attributes: $attributes);
    $id = $field->id;
    // Search forms submit with GET, so the current query string is the natural default (dot key: filter[q] works too).
    $fromQuery = $field->key !== null ? request()->query($field->key) : null;
    // Otherwise the value prop, or a bound Livewire property (see FormField::old).
    $value = is_string($fromQuery) ? $fromQuery : $field->old($value);
    $sizes = ['sm' => 'h-8 text-xs', 'md' => 'h-12 text-sm'];
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! array_key_exists($size, $sizes)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.search>. Use one of: ".implode(', ', array_keys($sizes)).'.');
    }
    if (! in_array($iconPosition, ['end', 'start'], true)) {
        throw new \InvalidArgumentException("Unknown icon-position [{$iconPosition}] for <x-widget.search>. Use one of: end, start.");
    }
    $start = $iconPosition === 'start';
    $shortcut = is_string($shortcut) && mb_strlen($shortcut) === 1 && trim($shortcut) !== '' ? $shortcut : null;
    // Plain English like every component; pass messages="[…]" to reword or translate any of them.
    $messages = [
        'count' => ':count suggestions. Use the arrow keys to choose one.',
        'none' => 'No suggestions.',
        'failed' => 'Couldn\'t load suggestions.',
        ...$messages,
    ];
    $suggest = $suggestUrl !== null && $suggestUrl !== '';
@endphp

<div {{ $attributes->only('class')->class([
    'bg-field text-foreground hover:border-primary focus-within:border-primary relative flex w-full items-center overflow-hidden rounded-[20px] border border-transparent transition-colors',
    $sizes[$size],
    'pe-3' => $start,
]) }}>
    @if ($start)
        <x-widget.icon name="search" class="text-muted ms-4 size-4 shrink-0" />
    @endif
    <input
        type="search"
        id="{{ $id }}"
        @if ($name) name="{{ $name }}" @endif
        value="{{ $value }}"
        {{-- A space when there's no placeholder: the clear button hides on :placeholder-shown, which needs one. --}}
        placeholder="{{ $placeholder !== '' ? $placeholder : ' ' }}"
        aria-label="{{ $label ?? $placeholder }}"
        @if ($shortcut) aria-keyshortcuts="{{ $shortcut }}" data-search-shortcut="{{ $shortcut }}" @endif
        {{-- With suggestions it's a combobox: the list below is its popup, and the arrow keys move through it while focus
             stays in the box (aria-activedescendant). The browser's own autofill list would cover it. --}}
        @if ($suggest)
            role="combobox"
            aria-autocomplete="list"
            aria-expanded="false"
            aria-controls="{{ $id }}-suggestions"
            autocomplete="off"
            data-search-suggest="{{ $suggestUrl }}"
            data-suggest-min="{{ max(1, (int) $suggestMin) }}"
            data-suggest-messages="{{ json_encode($messages) }}"
        @endif
        {{ $attributes->except('class')->class([
            'peer h-full w-full min-w-0 bg-transparent outline-none placeholder:text-muted [&::-webkit-search-cancel-button]:appearance-none',
            $start ? 'ps-3' : 'ps-5',
        ]) }}
    >
    @if ($clearable)
        {{-- Hidden by CSS while the box is empty (its placeholder showing), so it needs no script to stay right, even
             when something else empties the box. The browser's own clear button is hidden above: it differs in every
             browser and Firefox has none. --}}
        <button type="button" data-search-clear aria-label="Clear search" aria-controls="{{ $id }}" @class([
            'text-muted hover:text-foreground focus-visible:ring-primary grid shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 peer-placeholder-shown:hidden',
            'me-1 size-6' => $size === 'sm',
            'me-2 size-7' => $size === 'md',
        ])>
            <x-widget.icon name="x" @class(['size-3.5' => $size === 'sm', 'size-4' => $size === 'md']) />
        </button>
    @endif
    @if ($shortcut)
        {{-- The key, until the box has focus or text; screen readers get it from aria-keyshortcuts instead. --}}
        <kbd aria-hidden="true" @class([
            'border-line-strong text-muted font-code grid shrink-0 place-items-center rounded-md border text-xs peer-focus:hidden peer-[:not(:placeholder-shown)]:hidden',
            'me-2 size-5' => $size === 'sm',
            'me-3 size-6' => $size === 'md',
        ])>{{ $shortcut }}</kbd>
    @endif
    @unless ($start)
        <x-widget.icon name="search" class="text-muted me-5 size-4 shrink-0" />
    @endunless
    @if ($suggest)
        {{-- In the top layer (a popover), so the box's rounded clip and a table or modal can't cut it off; the script
             places it under the box and fills it. wire:ignore: a Livewire render would empty it while it's open. --}}
        <div id="{{ $id }}-suggestions" role="listbox" aria-label="Suggestions" popover="manual" wire:ignore data-search-suggestions class="border-line bg-surface text-foreground fixed inset-auto m-0 max-h-80 overflow-y-auto overscroll-contain rounded-2xl border p-1.5 text-sm shadow-lg [scrollbar-width:thin]"></div>
        {{-- What a screen reader hears as suggestions come and go. --}}
        <p data-search-status role="status" class="sr-only"></p>
        {{-- One suggestion and one group heading, filled in by the script. Here rather than in the JS so you can restyle
             them in the Blade you own. The typed text is shown in bold within each label. --}}
        <template data-search-suggestion>
            <div role="option" aria-selected="false" class="aria-selected:bg-field flex cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2">
                <span data-suggestion-label class="text-foreground/80 min-w-0 truncate [&_mark]:text-foreground [&_mark]:bg-transparent [&_mark]:font-semibold"></span>
                <span data-suggestion-meta class="text-muted shrink-0 text-xs empty:hidden"></span>
            </div>
        </template>
        <template data-search-group>
            <div role="group" class="not-first:border-line not-first:mt-1 not-first:border-t not-first:pt-1">
                <div data-group-label class="text-muted px-3 pt-1.5 pb-1 text-xs font-medium"></div>
            </div>
        </template>
    @endif
</div>
resources/js/widget/search/index.js Show
index.js
// Behaviour for <x-widget.search>: the clear button, the keyboard shortcut, and suggestions as you type (suggest-url). Delegated from `document`, so fields added later work without
// re-initialising. Client-side filtering is only for convenience; the Form Request must validate the same rules.
import { closeIfOutOfView, on } from '../field';

// --- Search: the clear button ----------------------------------------------------------

// Empties the box and tells listeners, as deleting the text would: a table's filters refilter, wire:model updates.
// Showing and hiding the button is CSS (see search.blade.php).
on('click', '[data-search-clear]', (event, button) => {
    const input = document.getElementById(button.getAttribute('aria-controls'));
    if (!input || input.value === '') {
        return;
    }
    input.value = '';
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.dispatchEvent(new Event('change', { bubbles: true }));
    input.focus();
});

// shortcut="/": the key jumps to the box, unless you're typing somewhere else or a dialog is open over it (then only
// a box inside that dialog counts). The first visible box with that key wins.
document.addEventListener('keydown', (event) => {
    if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
        return;
    }
    const target = event.target;
    if (target instanceof Element && target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')) {
        return;
    }
    const dialog = document.querySelector('dialog[open]');
    const box = [...document.querySelectorAll('[data-search-shortcut]')].find((input) => input.dataset.searchShortcut.toLowerCase() === event.key.toLowerCase()
        && input.offsetParent !== null && (!dialog || dialog.contains(input)));
    if (box) {
        event.preventDefault();
        box.focus();
        box.select();
    }
});

// --- Suggestions (suggest-url) ---------------------------------------------------------

// As you type, the box asks suggest-url (GET ?q=…) and lists what comes back under it. The arrow keys move through the
// list while focus stays in the box; Enter takes the highlighted one, or with none highlighted submits what was typed,
// as a search box does. A suggestion with an href opens that page; otherwise its value fills the box and, in a form,
// searches. Requests wait for a pause in typing, an earlier one still running is dropped, and answers are kept per
// query for as long as the page is open.
const WAIT_MS = 200;
const GAP = 6;
const VIEWPORT_EDGE = 8;
const boxes = new WeakMap();

function suggestionsFor(input) {
    if (!boxes.has(input)) {
        // The box around the input holds the list, the status line and the templates.
        const root = input.parentElement;
        boxes.set(input, {
            input,
            anchor: root,
            list: root.querySelector('[data-search-suggestions]'),
            status: root.querySelector('[data-search-status]'),
            template: root.querySelector('template[data-search-suggestion]'),
            groupTemplate: root.querySelector('template[data-search-group]'),
            messages: JSON.parse(input.dataset.suggestMessages ?? '{}'),
            cache: new Map(),
            options: [],
            active: -1,
            timer: null,
            request: null,
            choosing: false,
        });
    }

    return boxes.get(input);
}

const isOpen = (box) => box.list.matches(':popover-open');

function say(box, text) {
    box.status.textContent = text;
}

function close(box) {
    if (isOpen(box)) {
        box.list.hidePopover();
    }
    box.input.setAttribute('aria-expanded', 'false');
    box.input.removeAttribute('aria-activedescendant');
    box.active = -1;
}

function position(box) {
    if (closeIfOutOfView(box.anchor, box.list)) {
        box.input.setAttribute('aria-expanded', 'false');

        return;
    }
    const rect = box.anchor.getBoundingClientRect();
    box.list.style.width = `${rect.width}px`;
    const height = box.list.offsetHeight;
    const below = rect.bottom + GAP;
    const above = rect.top - GAP - height;
    box.list.style.top = `${below + height <= window.innerHeight - VIEWPORT_EDGE || above < VIEWPORT_EDGE ? below : above}px`;
    box.list.style.left = `${rect.left}px`;
}

function setActive(box, index) {
    box.options.forEach((option, i) => option.setAttribute('aria-selected', String(i === index)));
    box.active = index;
    if (index === -1) {
        box.input.removeAttribute('aria-activedescendant');

        return;
    }
    box.input.setAttribute('aria-activedescendant', box.options[index].id);
    box.options[index].scrollIntoView({ block: 'nearest' });
}

// The label, with the typed text in bold wherever it appears. Built from text nodes, never markup, so a suggestion
// can't inject anything.
function fillLabel(element, label, query) {
    element.replaceChildren();
    const lower = label.toLowerCase();
    const needle = query.toLowerCase();
    let at = 0;
    for (let found = lower.indexOf(needle); needle !== '' && found !== -1; found = lower.indexOf(needle, at)) {
        element.append(label.slice(at, found), Object.assign(document.createElement('mark'), { textContent: label.slice(found, found + needle.length) }));
        at = found + needle.length;
    }
    element.append(label.slice(at));
}

// A suggestion as the server sent it: a string, or an object with label (value, meta, href and group optional).
function normalise(item) {
    const entry = typeof item === 'string' ? { label: item } : (item ?? {});
    const label = String(entry.label ?? entry.value ?? '');

    return { label, value: String(entry.value ?? label), meta: entry.meta ?? '', href: entry.href ?? null, group: entry.group ?? null };
}

function render(box, items, query) {
    const suggestions = items.map(normalise).filter((item) => item.label !== '');
    box.list.replaceChildren();
    box.options = [];
    const groups = new Map();
    suggestions.forEach((item, i) => {
        const option = box.template.content.firstElementChild.cloneNode(true);
        option.id = `${box.list.id}-${i}`;
        fillLabel(option.querySelector('[data-suggestion-label]'), item.label, query);
        option.querySelector('[data-suggestion-meta]').textContent = item.meta;
        option.suggestion = item;
        let parent = box.list;
        if (item.group) {
            if (!groups.has(item.group)) {
                const group = box.groupTemplate.content.firstElementChild.cloneNode(true);
                const heading = group.querySelector('[data-group-label]');
                heading.id = `${box.list.id}-group-${groups.size}`;
                heading.textContent = item.group;
                group.setAttribute('aria-labelledby', heading.id);
                box.list.append(group);
                groups.set(item.group, group);
            }
            parent = groups.get(item.group);
        }
        parent.append(option);
        box.options.push(option);
    });
    // Grouped lists show group by group, so the arrow keys follow what's on screen.
    box.options = [...box.list.querySelectorAll('[role="option"]')];
    box.active = -1;
    box.input.removeAttribute('aria-activedescendant');

    if (box.options.length === 0) {
        close(box);
        say(box, box.messages.none ?? '');

        return;
    }
    if (!isOpen(box)) {
        box.list.showPopover();
    }
    box.input.setAttribute('aria-expanded', 'true');
    position(box);
    say(box, (box.messages.count ?? '').replace(':count', String(box.options.length)));
}

async function ask(box) {
    const query = box.input.value.trim();
    box.request?.abort();
    if (query.length < Number(box.input.dataset.suggestMin ?? 2)) {
        close(box);
        say(box, '');

        return;
    }
    if (box.cache.has(query)) {
        render(box, box.cache.get(query), query);

        return;
    }
    const request = new AbortController();
    box.request = request;
    const url = new URL(box.input.dataset.searchSuggest, window.location.href);
    url.searchParams.set('q', query);
    try {
        const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: request.signal });
        if (!response.ok) {
            throw new Error(String(response.status));
        }
        const body = await response.json();
        const items = Array.isArray(body) ? body : (Array.isArray(body?.data) ? body.data : []);
        box.cache.set(query, items);
        // The box may have changed while this was on its way; only the latest answer counts.
        if (box.input.value.trim() === query) {
            render(box, items, query);
        }
    } catch (error) {
        if (error.name !== 'AbortError') {
            close(box);
            say(box, box.messages.failed ?? '');
        }
    }
}

function choose(box, option) {
    const { value, href } = option.suggestion;
    close(box);
    if (href) {
        window.location.assign(href);

        return;
    }
    box.choosing = true;
    box.input.value = value;
    box.input.dispatchEvent(new Event('input', { bubbles: true }));
    box.input.dispatchEvent(new Event('change', { bubbles: true }));
    box.choosing = false;
    box.input.form?.requestSubmit();
}

document.addEventListener('input', (event) => {
    const input = event.target;
    if (!(input instanceof HTMLInputElement) || !input.dataset.searchSuggest) {
        return;
    }
    const box = suggestionsFor(input);
    if (box.choosing) {
        return;
    }
    clearTimeout(box.timer);
    box.timer = setTimeout(() => ask(box), WAIT_MS);
});

document.addEventListener('keydown', (event) => {
    const input = event.target;
    if (!(input instanceof HTMLInputElement) || !input.dataset.searchSuggest) {
        return;
    }
    const box = suggestionsFor(input);
    const count = box.options.length;
    switch (event.key) {
        case 'ArrowDown':
        case 'ArrowUp':
            if (count === 0) {
                return;
            }
            event.preventDefault();
            if (!isOpen(box)) {
                box.list.showPopover();
                input.setAttribute('aria-expanded', 'true');
                position(box);
            }
            setActive(box, event.key === 'ArrowDown' ? (box.active + 1) % count : (box.active <= 0 ? count - 1 : box.active - 1));
            break;
        case 'Enter':
            if (isOpen(box) && box.active !== -1) {
                event.preventDefault();
                choose(box, box.options[box.active]);
            }
            break;
        case 'Escape':
            // First Esc closes the list; the next one is the browser's, which empties a search box.
            if (isOpen(box)) {
                event.preventDefault();
                close(box);
            }
            break;
        case 'Tab':
            close(box);
            break;
    }
});

// A press on a suggestion mustn't take focus from the box (the list would close on blur first).
document.addEventListener('pointerdown', (event) => {
    if (event.target.closest?.('[data-search-suggestions] [role="option"]')) {
        event.preventDefault();
    }
});

on('click', '[data-search-suggestions] [role="option"]', (event, option) => {
    const input = document.querySelector(`[aria-controls="${CSS.escape(option.closest('[data-search-suggestions]').id)}"][data-search-suggest]`);
    if (input) {
        choose(suggestionsFor(input), option);
    }
});

document.addEventListener('focusout', (event) => {
    const input = event.target;
    if (input instanceof HTMLInputElement && input.dataset.searchSuggest) {
        close(suggestionsFor(input));
    }
});

// Kept under the box as the page scrolls or resizes; closed when the box scrolls out of sight.
const reposition = () => document.querySelectorAll('[data-search-suggestions]:popover-open').forEach((list) => {
    const input = document.querySelector(`[aria-controls="${CSS.escape(list.id)}"][data-search-suggest]`);
    if (input) {
        position(suggestionsFor(input));
    }
});
window.addEventListener('scroll', reposition, { passive: true, capture: true });
window.addEventListener('resize', reposition);
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;
    }
}
app/View/Widget/FormField.php Show
FormField.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;

/**
 * Server-side state of one form widget: its dot-notation key, a valid id, its validation
 * messages and its old input. Every <x-widget.input.*> and the date picker resolve through
 * here, so array names (items[0][date]) and named error bags behave the same everywhere.
 */
final class FormField
{
    /**
     * @param  list<string>  $errors
     */
    private function __construct(
        public readonly ?string $name,
        public readonly string $id,
        public readonly ?string $key,
        public readonly array $errors,
        // The property a wire:model or x-model attribute binds it to, if any.
        public readonly ?string $bound = null,
    ) {}

    /**
     * @param  mixed  $errorBag  the view's shared $errors (absent outside a web request)
     * @param  string|array<int, string>|null  $error  an explicit message from the caller; overrides the bag
     */
    public static function make(
        ?string $name,
        ?string $id,
        mixed $errorBag,
        string|array|null $error = null,
        string $bag = 'default',
        string $idPrefix = 'field',
        ?ComponentAttributeBag $attributes = null,
    ): self {
        // With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
        // that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
        // (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
        $bound = $attributes === null ? null : self::boundTo($attributes);
        $key = match (true) {
            $name !== null && $name !== '' => self::key($name),
            $bound !== null => self::key($bound),
            default => null,
        };

        $messages = match (true) {
            $error !== null => Arr::wrap($error),
            $key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
            default => [],
        };

        return new self(
            $name,
            app(ElementIds::class)->claim(
                $id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
                explicit: $id !== null,
            ),
            $key,
            array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
            $bound,
        );
    }

    /**
     * The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
     * reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
     * numbered children count, so a field named address doesn't take errors meant for address[city].
     *
     * @return list<string>
     */
    private static function messagesFor(MessageBag $bag, string $key): array
    {
        $items = array_filter(
            $bag->getMessages(),
            static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );

        return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
    }

    /**
     * items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
     */
    public static function key(string $name): string
    {
        return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
    }

    /**
     * The id a field named $name gets when it's the first of that name on the page, for links to it
     * (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
     */
    public static function idFor(string $name): string
    {
        return self::idFrom(self::key($name));
    }

    /**
     * The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
     */
    public static function boundTo(ComponentAttributeBag $attributes): ?string
    {
        return array_values(self::binding($attributes))[0] ?? null;
    }

    private static function idFrom(string $key): string
    {
        return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
    }

    public function hasError(): bool
    {
        return $this->errors !== [];
    }

    public function errorId(): string
    {
        return $this->id.'-error';
    }

    public function infoId(): string
    {
        return $this->id.'-info';
    }

    /**
     * Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
     * property: a re-render then draws the field as it is, which Livewire morphs onto the page.
     */
    public function old(mixed $default = null): mixed
    {
        if ($default === null) {
            [$found, $live] = $this->fromLivewire();
            $default = $found ? $live : null;
        }

        return $this->key === null ? $default : old($this->key, $default);
    }

    /**
     * The bound property's value while Livewire renders the component that holds it: Livewire shares that component
     * with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
     * $key reads inside it: a range bound to period reads period.start.
     *
     * @return array{0: bool, 1: mixed}
     */
    public function fromLivewire(?string $key = null): array
    {
        $component = $this->bound === null ? null : view()->shared('__livewire');

        return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
    }

    /**
     * The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
     * picker binds period.start and period.end with the same modifiers. Empty without one.
     *
     * @return array<string, string>
     */
    public static function binding(ComponentAttributeBag $attributes): array
    {
        foreach ($attributes->getAttributes() as $attribute => $value) {
            if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
                return [$attribute => $value];
            }
        }

        return [];
    }

    /**
     * Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
     * failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
     * with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
     *
     * @param  bool  $alwaysSent  it has an unchecked-value, so its form always sends something under its name
     * @param  mixed  $errorBag  the view's shared $errors
     */
    public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
    {
        // Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
        [$found, $live] = $this->fromLivewire();
        if ($found) {
            return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
        }
        if ($this->key === null || $disabled || ! session()->hasOldInput()) {
            return $default;
        }

        $old = old($this->key);
        if ($old !== null) {
            return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
        }
        // Nothing under this name, though this box always sends something: its form wasn't the one submitted.
        if ($alwaysSent) {
            return $default;
        }
        // A form with its own error bag: no errors in that bag means the failed submit was a different form.
        if ($bag !== 'default') {
            return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
        }

        // One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
        return false;
    }

    /** Values cast to backed enums (Plan::Pro) compare as their backing value. */
    private static function text(mixed $value): string
    {
        return (string) ($value instanceof \BackedEnum ? $value->value : $value);
    }

    /**
     * What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
     * this widget manages itself. Goes on the element that is actually submitted.
     */
    public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
    }

    /**
     * aria-invalid plus one aria-describedby that joins the error or the hint, and the caller's own
     * ids. Two separate aria-describedby attributes would make the browser silently drop one.
     */
    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        // Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a
        // screen reader would hear two messages that often say the same thing. resources/js/field brings the hint
        // back when the error clears.
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo && ! $this->hasError() ? $this->infoId() : null,
            $attributes->get('aria-describedby'),
        ]);

        return new ComponentAttributeBag([
            'aria-invalid' => $this->hasError() ? 'true' : null,
            'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
        ]);
    }

    /**
     * For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
     * hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
     */
    public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
    }

    /**
     * The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
     * placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
     */
    public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => ! self::isBinding($key)), $hasInfo);
    }

    private static function isBinding(string $key): bool
    {
        return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
    }

    /**
     * forwarded() and aria() together, for widgets whose visible control is also the submitted one.
     */
    public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
    }
}