Skip to content
LarawellUi

<x-widget.tabs>

Tabs

Tabs that switch between panels on the page: underlined, underlined and tinted, pills, a segmented track, or stacked beside the panel. Icons and counts on tabs, disabled ones, links as tabs for page navigation, sideways scrolling when there are too many, and remember to keep the open tab in the URL. The arrow keys move between them. Works inside Livewire components, with wire:model for the open tab.

php artisan larawell:add tabs
Also adds
Icon

Usage

Livewire

Inside a Livewire component the open tab stays as the person left it through every render, and the panels' content updates. Bind it with wire:model to a property to know it in PHP, or to open one from PHP: public string $tab = 'profile'. Bound, the property decides which tab is open after each render.

Blade
<x-widget.tabs id="account" label="Account" wire:model.live="tab" :tabs="['profile' => 'Profile', 'security' => 'Security']">
    <x-slot:profile>
        <livewire:profile-form />
    </x-slot:profile>
    <x-slot:security>
        @if ($tab === 'security')
            {{-- Only load what the open tab needs. --}}
            <livewire:sessions-list />
        @endif
    </x-slot:security>
</x-widget.tabs>

Examples

Underline

The default: text tabs on a rule. Each tab's panel is the named slot of its key. The arrow keys move between tabs, Home and End jump to the ends, and only the open tab is in the Tab order.

Your name, photo and the email we write to.
Show code
Blade
<x-widget.tabs id="account" label="Account" :tabs="['profile' => 'Profile', 'security' => 'Security', 'notifications' => 'Notifications']">
    <x-slot:profile>Your name, photo and the email we write to.</x-slot:profile>
    <x-slot:security>Password, two-factor authentication and signed-in devices.</x-slot:security>
    <x-slot:notifications>What we email you about, and how often.</x-slot:notifications>
</x-widget.tabs>

Pills

variant="pills": rounded buttons, the open one filled. Good for filters above a list. value opens one other than the first.

Orders waiting to ship.
Show code
Blade
<x-widget.tabs id="order-status" label="Orders" variant="pills" value="open" :tabs="['all' => 'All', 'open' => 'Open', 'shipped' => 'Shipped', 'returned' => 'Returned']">
    <x-slot:all>Every order.</x-slot:all>
    <x-slot:open>Orders waiting to ship.</x-slot:open>
    <x-slot:shipped>Orders on their way.</x-slot:shipped>
    <x-slot:returned>Orders sent back.</x-slot:returned>
</x-widget.tabs>

Segmented

variant="segmented": one track with the open tab raised in it, for two to four short choices.

$12 a month

Show code
Blade
<x-widget.tabs id="billing" label="Billing period" variant="segmented" :tabs="['monthly' => 'Monthly', 'yearly' => 'Yearly']">
    <x-slot:monthly><p class="text-2xl font-semibold">$12 <span class="text-foreground/60 text-sm font-normal">a month</span></p></x-slot:monthly>
    <x-slot:yearly><p class="text-2xl font-semibold">$120 <span class="text-foreground/60 text-sm font-normal">a year, two months free</span></p></x-slot:yearly>
</x-widget.tabs>

Vertical

variant="vertical": tabs stacked beside the panel, for settings pages. The arrow keys go up and down.

Workspace name, language and time zone.
Show code
Blade
<x-widget.tabs id="settings" label="Settings" variant="vertical" :tabs="['general' => 'General', 'team' => 'Team', 'billing' => 'Billing', 'api' => 'API keys']">
    <x-slot:general>Workspace name, language and time zone.</x-slot:general>
    <x-slot:team>Who's in the workspace, and what they can do.</x-slot:team>
    <x-slot:billing>Plan, invoices and payment method.</x-slot:billing>
    <x-slot name="api">Keys for the API, and when each was last used.</x-slot>
</x-widget.tabs>

Icons and badges

A tab can be an array: an icon before its label, a badge after it (a count), or disabled, which the arrow keys skip. variant="tinted" underlines the open tab and puts it on a light wash of the primary colour, like the vertical tabs.

12 unread messages.
Show code
Blade
<x-widget.tabs id="mailbox" label="Mailbox" variant="tinted" :tabs="[
    'inbox' => ['label' => 'Inbox', 'icon' => 'inbox', 'badge' => 12],
    'drafts' => ['label' => 'Drafts', 'icon' => 'pencil', 'badge' => 2],
    'archive' => ['label' => 'Archive', 'icon' => 'file'],
    'spam' => ['label' => 'Spam', 'icon' => 'triangle-alert', 'disabled' => true],
]">
    <x-slot:inbox>12 unread messages.</x-slot:inbox>
    <x-slot:drafts>2 drafts.</x-slot:drafts>
    <x-slot:archive>Everything you've filed away.</x-slot:archive>
    <x-slot:spam>Nothing here.</x-slot:spam>
</x-widget.tabs>

Remember

remember keeps the open tab in the URL (#plans=team), so sharing the link, reloading or going Back opens it again. Several sets on one page share the hash.

For one person: unlimited projects.
Show code
Blade
<x-widget.tabs id="plans" label="Plans" variant="pills" remember :tabs="['personal' => 'Personal', 'team' => 'Team', 'enterprise' => 'Enterprise']">
    <x-slot:personal>For one person: unlimited projects.</x-slot:personal>
    <x-slot:team>For up to 50 people: shared projects and roles.</x-slot:team>
    <x-slot:enterprise>For larger companies: SSO, audit log and support.</x-slot:enterprise>
</x-widget.tabs>

Many tabs

More tabs than fit scroll sideways rather than wrapping, and the open one is scrolled into view as it changes.

Reports for October.
Show code
Blade
<x-widget.tabs id="months" label="Month" value="october" class="max-w-md" :tabs="[
    'january' => 'January', 'february' => 'February', 'march' => 'March', 'april' => 'April',
    'may' => 'May', 'june' => 'June', 'july' => 'July', 'august' => 'August',
    'september' => 'September', 'october' => 'October', 'november' => 'November', 'december' => 'December',
]">
    @foreach (['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'] as $month)
        <x-slot :name="$month">Reports for {{ ucfirst($month) }}.</x-slot>
    @endforeach
</x-widget.tabs>

Props

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

<x-widget.tabs>

Prop Default Description
id Required Required: names the tabs, their panels and their place in the URL (with remember). Unique on the page.
label null Names the tab list for screen readers ("Account settings").
tabs [] key => label, or key => ['label' => …, 'icon' => …, 'badge' => …, 'disabled' => bool, 'href' => …]. Each tab's panel is the named slot of the same key: <x-slot:profile>…</x-slot:profile>. With href on every tab they're links to other pages instead, and there are no panels.
value null The open tab's key; the first that isn't disabled by default. With wire:model, the bound property.
variant 'underline' underline, tinted (underlined, the open tab on a wash of the primary colour), pills, segmented (a track with the open tab raised in it), or vertical (stacked beside the panel).
remember false Keep the open tab in the URL (#settings=security), so a link, a reload or Back opens it.
name null What the tabs submit as, with a form around them: the open tab's key. Optional with wire:model.

Source

What larawell:add tabs 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/tabs/index.blade.php Show
index.blade.php
@props([
    // Required: names the tabs, their panels and their place in the URL (with remember). Unique on the page.
    'id',
    // Names the tab list for screen readers ("Account settings").
    'label' => null,
    // key => label, or key => ['label' => …, 'icon' => …, 'badge' => …, 'disabled' => bool, 'href' => …]. Each tab's
    // panel is the named slot of the same key: <x-slot:profile>…</x-slot:profile>. With href on every tab they're links
    // to other pages instead, and there are no panels.
    'tabs' => [],
    // The open tab's key; the first that isn't disabled by default. With wire:model, the bound property.
    'value' => null,
    // underline, tinted (underlined, the open tab on a wash of the primary colour), pills, segmented (a track with the
    // open tab raised in it), or vertical (stacked beside the panel).
    'variant' => 'underline',
    // Keep the open tab in the URL (#settings=security), so a link, a reload or Back opens it.
    'remember' => false,
    // What the tabs submit as, with a form around them: the open tab's key. Optional with wire:model.
    'name' => null,
])

@php
    $field = \App\View\Widget\FormField::make($name, $id, null, idPrefix: 'tabs', attributes: $attributes);
    $id = $field->id;
    $variant = in_array($variant, ['underline', 'tinted', 'pills', 'segmented', 'vertical'], true) ? $variant : 'underline';
    $vertical = $variant === 'vertical';
    $items = collect($tabs)->map(static fn (mixed $tab, int|string $key): array => [
        'key' => (string) $key,
        'label' => (string) (is_array($tab) ? ($tab['label'] ?? $key) : $tab),
        'icon' => is_array($tab) ? ($tab['icon'] ?? null) : null,
        'badge' => is_array($tab) && isset($tab['badge']) && $tab['badge'] !== '' ? (string) $tab['badge'] : null,
        'disabled' => is_array($tab) && ! empty($tab['disabled']),
        'href' => is_array($tab) ? ($tab['href'] ?? null) : null,
    ])->values();
    // Links when every tab has an href: navigation between pages, so a <nav> of links, not a tab list.
    $links = $items->isNotEmpty() && $items->every(static fn (array $tab): bool => $tab['href'] !== null);
    $chosen = (string) ($field->old($value) ?? '');
    $selected = $items->first(static fn (array $tab): bool => $tab['key'] === $chosen && ! $tab['disabled'])['key']
        ?? $items->first(static fn (array $tab): bool => ! $tab['disabled'])['key']
        ?? null;
    // Bound with wire:model, the server's property says which tab is open, so a render may change it. Otherwise the
    // person does, and renders leave their choice alone (wire:ignore.self below).
    $bound = \App\View\Widget\FormField::binding($attributes) !== [];
    $slug = static fn (string $key): string => trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');

    $list = [
        'underline' => 'border-line flex gap-6 border-b',
        'tinted' => 'border-line flex gap-1 border-b',
        'pills' => 'flex gap-1.5',
        'segmented' => 'bg-field inline-flex gap-1 rounded-2xl p-1',
        'vertical' => 'border-line flex flex-col gap-0.5 border-s',
    ][$variant];
    $tab = [
        'underline' => '-mb-px border-b-2 border-transparent py-3 text-foreground/70 hover:text-foreground aria-selected:border-primary aria-selected:text-foreground aria-[current=page]:border-primary aria-[current=page]:text-foreground',
        // Underlined and tinted: the open tab on a light wash of the primary colour, as in the vertical tabs, its underline
        // in the primary colour.
        'tinted' => '-mb-px rounded-t-xl border-b-2 border-transparent px-4 py-3 text-foreground/70 hover:text-foreground hover:not-aria-selected:not-aria-[current=page]:bg-field/70 aria-selected:border-primary aria-selected:bg-primary/5 aria-selected:text-foreground aria-[current=page]:border-primary aria-[current=page]:bg-primary/5 aria-[current=page]:text-foreground',
        'pills' => 'rounded-full px-4 py-2 text-foreground/70 hover:bg-field hover:text-foreground aria-selected:bg-primary aria-selected:text-on-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary',
        'segmented' => 'rounded-xl px-4 py-2 text-foreground/70 hover:text-foreground aria-selected:bg-surface aria-selected:text-foreground aria-selected:shadow-sm aria-[current=page]:bg-surface aria-[current=page]:text-foreground aria-[current=page]:shadow-sm',
        'vertical' => '-ms-px border-s-2 border-transparent px-4 py-2 text-start text-foreground/70 hover:text-foreground aria-selected:border-primary aria-selected:bg-primary/5 aria-selected:text-foreground aria-[current=page]:border-primary aria-[current=page]:bg-primary/5 aria-[current=page]:text-foreground',
    ][$variant];
    $tabBase = 'focus-visible:ring-primary inline-flex shrink-0 items-center gap-2 font-medium whitespace-nowrap outline-none transition-colors select-none focus-visible:ring-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-40 '.$tab;
@endphp

{{--
    The ARIA tabs pattern: one tab in the Tab order (the open one), the arrow keys move between them, Home and End jump
    to the ends, and each panel is labelled by its tab. Panels are on the page from the server, the closed ones hidden,
    so it works before (and without) the script, which only switches them. A long row of tabs scrolls sideways.
--}}
<div
    data-tabs="{{ $id }}"
    data-variant="{{ $variant }}"
    @if ($remember && ! $links) data-tabs-remember @endif
    {{ $attributes->whereDoesntStartWith('wire:model')->whereDoesntStartWith('x-model')->except(['form'])->class(['flex gap-6' => $vertical, 'flex flex-col gap-5' => ! $vertical]) }}
>
    @if ($links)
        <nav @if ($label) aria-label="{{ $label }}" @endif class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-width:thin]">
            <div @class([$list, 'w-max min-w-full' => ! $vertical && $variant !== 'segmented'])>
                @foreach ($items as $item)
                    <a
                        href="{{ $item['href'] }}"
                        @if ($item['key'] === $selected) aria-current="page" @endif
                        @if ($item['disabled']) aria-disabled="true" tabindex="-1" @endif
                        class="{{ $tabBase }}"
                    >
                        @if ($item['icon'])
                            <x-widget.icon :name="$item['icon']" class="size-4 shrink-0" />
                        @endif
                        <span>{{ $item['label'] }}</span>
                        @if ($item['badge'] !== null)
                            <span class="bg-foreground/10 rounded-full px-2 py-0.5 text-xs leading-none tabular-nums">{{ $item['badge'] }}</span>
                        @endif
                    </a>
                @endforeach
            </div>
        </nav>
    @else
        <div @class(['max-w-full overflow-x-auto overscroll-x-contain [scrollbar-width:thin]' => ! $vertical, 'shrink-0' => $vertical])>
            <div
                role="tablist"
                @if ($label) aria-label="{{ $label }}" @endif
                @if ($vertical) aria-orientation="vertical" @endif
                data-tabs-list
                @class([$list, 'w-max min-w-full' => ! $vertical && $variant !== 'segmented'])
            >
                @foreach ($items as $item)
                    @php($open = $item['key'] === $selected)
                    {{-- wire:ignore.self, unless bound: which tab is open is the person's, and a render would put back the
                         server's. The label and badge inside still update. --}}
                    <button
                        type="button"
                        role="tab"
                        id="{{ $id }}-tab-{{ $slug($item['key']) }}"
                        aria-controls="{{ $id }}-panel-{{ $slug($item['key']) }}"
                        aria-selected="{{ $open ? 'true' : 'false' }}"
                        tabindex="{{ $open ? '0' : '-1' }}"
                        @if ($item['disabled']) aria-disabled="true" @endif
                        data-tab="{{ $item['key'] }}"
                        @unless ($bound) wire:ignore.self @endunless
                        class="{{ $tabBase }}"
                    >
                        @if ($item['icon'])
                            <x-widget.icon :name="$item['icon']" class="size-4 shrink-0" />
                        @endif
                        <span>{{ $item['label'] }}</span>
                        @if ($item['badge'] !== null)
                            <span class="bg-foreground/10 rounded-full px-2 py-0.5 text-xs leading-none tabular-nums">{{ $item['badge'] }}</span>
                        @endif
                    </button>
                @endforeach
            </div>
        </div>

        <div @class(['min-w-0 flex-1' => $vertical])>
            @foreach ($items as $item)
                @php($open = $item['key'] === $selected)
                <div
                    role="tabpanel"
                    id="{{ $id }}-panel-{{ $slug($item['key']) }}"
                    aria-labelledby="{{ $id }}-tab-{{ $slug($item['key']) }}"
                    {{-- Focusable, so a panel with nothing focusable in it can still be reached with Tab. --}}
                    tabindex="0"
                    data-tab-panel="{{ $item['key'] }}"
                    @unless ($open) hidden @endunless
                    @unless ($bound) wire:ignore.self @endunless
                    class="focus-visible:ring-primary rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-offset-4"
                >{{ $__laravel_slots[$item['key']] ?? '' }}</div>
            @endforeach
        </div>

        {{-- The open tab's key, for a form around the tabs and for wire:model / x-model. --}}
        @if ($name || $bound)
            <input type="hidden" @if ($name) name="{{ $name }}" @endif value="{{ $selected }}" data-tabs-input {{ $field->bindings($attributes) }}>
        @endif
    @endif
</div>
resources/js/widget/tabs/index.js Show
index.js
// Drives <x-widget.tabs>: switching panels, the arrow keys, keeping the open tab in the URL (remember) and in a hidden
// input (a form, wire:model). The server already rendered the open tab and hid the rest, so this only switches them.
// Delegated from `document`, so tabs added later (a Livewire render, fetched HTML) work without setting up.
// Event: tabs:change on the tabs, detail { tab } (its key), when the open tab changes.

const ROOT = '[data-tabs]';
const TAB = '[role="tab"]';

const tabsOf = (root) => [...root.querySelector('[data-tabs-list]')?.querySelectorAll(TAB) ?? []];
const usable = (tabs) => tabs.filter((tab) => tab.getAttribute('aria-disabled') !== 'true');

// --- Remember: #settings=security, several sets joined with & ------------------------------------------------

function hashPairs() {
    const pairs = new Map();
    for (const part of location.hash.slice(1).split('&')) {
        const at = part.indexOf('=');
        if (at > 0) {
            pairs.set(decodeURIComponent(part.slice(0, at)), decodeURIComponent(part.slice(at + 1)));
        }
    }

    return pairs;
}

function rememberTab(root, key) {
    const pairs = hashPairs();
    pairs.set(root.dataset.tabs, key);
    const hash = [...pairs].map(([id, tab]) => `${encodeURIComponent(id)}=${encodeURIComponent(tab)}`).join('&');
    // replaceState, not location.hash: no extra Back step per click, and no jump to an element with that id.
    history.replaceState(history.state, '', `${location.pathname}${location.search}#${hash}`);
}

// A long row of tabs scrolls sideways: keep the open one in view, without scrolling the page. Instantly on load, so a
// tab the server opened (value, remember) isn't left out of sight.
function reveal(tab, behavior = 'instant') {
    const strip = tab.closest('[data-tabs-list]')?.parentElement;
    if (!strip || strip.scrollWidth <= strip.clientWidth) {
        return;
    }
    const left = tab.getBoundingClientRect().left - strip.getBoundingClientRect().left + strip.scrollLeft;
    if (left < strip.scrollLeft || left + tab.offsetWidth > strip.scrollLeft + strip.clientWidth) {
        strip.scrollTo({ left: left - (strip.clientWidth - tab.offsetWidth) / 2, behavior });
    }
}

const revealOpen = (scope) => scope.querySelectorAll(`${ROOT} [data-tabs-list] ${TAB}[aria-selected="true"]`).forEach((tab) => reveal(tab));

// --- Switching ------------------------------------------------------------------------------------------

function activate(root, tab, { focus = false, remember = true } = {}) {
    if (!tab || tab.getAttribute('aria-disabled') === 'true') {
        return;
    }
    const key = tab.dataset.tab;
    const changed = tab.getAttribute('aria-selected') !== 'true';
    for (const other of tabsOf(root)) {
        const open = other === tab;
        other.setAttribute('aria-selected', String(open));
        other.tabIndex = open ? 0 : -1;
    }
    root.querySelectorAll(':scope > div > [role="tabpanel"]').forEach((panel) => {
        panel.hidden = panel.dataset.tabPanel !== key;
    });
    if (focus) {
        tab.focus();
    }
    reveal(tab, 'smooth');
    if (!changed) {
        return;
    }
    const input = root.querySelector(':scope > [data-tabs-input]');
    if (input && input.value !== key) {
        input.value = key;
        // Both: Alpine x-model and Livewire wire:model listen for `input`, plain forms for `change`.
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.dispatchEvent(new Event('change', { bubbles: true }));
    }
    if (remember && 'tabsRemember' in root.dataset) {
        rememberTab(root, key);
    }
    root.dispatchEvent(new CustomEvent('tabs:change', { bubbles: true, detail: { tab: key } }));
}

document.addEventListener('click', (event) => {
    const tab = event.target.closest?.(`${ROOT} ${TAB}`);
    if (tab) {
        activate(tab.closest(ROOT), tab);
    }
});

// Automatic activation: moving to a tab opens it, which the pattern recommends when the panels are already on the page.
document.addEventListener('keydown', (event) => {
    const tab = event.target.closest?.(`${ROOT} ${TAB}`);
    if (!tab) {
        return;
    }
    const root = tab.closest(ROOT);
    const list = tab.closest('[role="tablist"]');
    const vertical = list.getAttribute('aria-orientation') === 'vertical';
    const rtl = getComputedStyle(list).direction === 'rtl';
    const tabs = usable(tabsOf(root));
    const index = tabs.indexOf(tab);
    const next = vertical ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight';
    const previous = vertical ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft';
    const target = {
        [next]: tabs[(index + 1) % tabs.length],
        [previous]: tabs[(index - 1 + tabs.length) % tabs.length],
        Home: tabs[0],
        End: tabs.at(-1),
    }[event.key];
    if (target) {
        event.preventDefault();
        activate(root, target, { focus: true });
    }
});

// --- Opening the remembered tab ---------------------------------------------------------------------------

function openFromHash(scope = document) {
    const pairs = hashPairs();
    scope.querySelectorAll(`${ROOT}[data-tabs-remember]`).forEach((root) => {
        const key = pairs.get(root.dataset.tabs);
        const tab = key === undefined ? null : tabsOf(root).find((candidate) => candidate.dataset.tab === key);
        if (tab) {
            activate(root, tab, { remember: false });
        }
    });
}

openFromHash();
revealOpen(document);
window.addEventListener('hashchange', () => openFromHash());

// Tabs added to the page later (a Livewire render or wire:navigate, fetched HTML) open their remembered tab and show
// the open one too.
new MutationObserver((records) => {
    for (const node of records.flatMap((record) => [...record.addedNodes])) {
        if (node instanceof Element && (node.matches(ROOT) || node.querySelector(ROOT))) {
            openFromHash(node.parentElement ?? document);
            revealOpen(node.parentElement ?? document);
        }
    }
}).observe(document.documentElement, { childList: true, subtree: true });

export const tabs = {
    // tabs.open('settings', 'security'): switch from your own script.
    open(id, key) {
        const root = document.querySelector(`${ROOT}[data-tabs="${CSS.escape(id)}"]`);
        const tab = root && tabsOf(root).find((candidate) => candidate.dataset.tab === key);
        if (tab) {
            activate(root, tab);
        }
    },
};
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, 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
    {
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo ? $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());
    }
}