Skip to content
LarawellUi

<x-widget.alert>

Alert

Messages that stay on the page, in info, success, warning and error, with a title, actions and an optional dismiss button. flash="status" shows the session message Laravel's auth screens set. Includes an error summary for the top of a form that links each validation error to its field. Works inside Livewire components.

php artisan larawell:add alert
Also adds
Icon

Usage

From a controller

flash="status" reads the key Laravel's password reset and email verification screens already use.

PHP
return back()->with('status', 'We have emailed your password reset link.');

// Some starter kits flash a code instead of a sentence. Put the words in the view:
// @if (session('status') === 'verification-link-sent')
//     <x-widget.alert tone="success">A new verification link is on its way.</x-widget.alert>
// @endif

// For 'success', 'error', 'warning' and 'info', use <x-widget.toast>; the alert leaves those keys alone.
return back()->with('success', 'Profile updated.');

Livewire

Inside a Livewire component: a render updates an alert's text and tone in place. A dismissed one stays hidden through later renders while it says the same thing, and shows again when its message changes. flash="status" picks up session()->flash('status', …) from an action and reads it out like after a redirect. After a failed wire:submit the error summary gets focus, as after an ordinary form, though never while someone is typing, so validateOnly() in updated() can't pull them out of a field.

Blade
<form wire:submit="save" class="space-y-5">
    <x-widget.alert flash="status" tone="success" />

    <x-widget.alert.errors />

    <x-widget.text-input label="Name" wire:model="name" />
    <x-widget.text-input label="Email" type="email" wire:model="email" />

    <button type="submit">Save</button>
</form>

Examples

Tones

Four tones, each with its own icon, so colour is never the only signal. The title is optional.

Exports run overnight and are ready by 6am.

Payment received

We've emailed a receipt to ana@example.com.

Your trial ends in 3 days

Add a card to keep your projects running.

Payout failed

The bank rejected the transfer. Check the account details and try again.
Show code
Blade
<div class="grid max-w-2xl gap-3">
    <x-widget.alert>Exports run overnight and are ready by 6am.</x-widget.alert>
    <x-widget.alert tone="success" title="Payment received">We've emailed a receipt to ana@example.com.</x-widget.alert>
    <x-widget.alert tone="warning" title="Your trial ends in 3 days">Add a card to keep your projects running.</x-widget.alert>
    <x-widget.alert tone="error" title="Payout failed">The bank rejected the transfer. Check the account details and try again.</x-widget.alert>
</div>

With actions

The actions slot holds links or buttons that act on the message.

Your trial ends in 3 days

Add a card to keep your projects running after 30 September.
Show code
Blade
<x-widget.alert tone="warning" title="Your trial ends in 3 days" class="max-w-2xl">
    Add a card to keep your projects running after 30 September.
    <x-slot:actions>
        <a href="#billing" class="text-foreground underline underline-offset-4">Add a card</a>
        <a href="#plans" class="text-foreground/70 hover:text-foreground">Compare plans</a>
    </x-slot:actions>
</x-widget.alert>

Dismissible

dismissible adds a close button. It hides the alert for this page view; to keep it hidden, remember the choice on the server.

Exports have moved to the Reports page.
Show code
Blade
<x-widget.alert dismissible class="max-w-2xl">Exports have moved to the Reports page.</x-widget.alert>

Flash status

flash="status" shows session('status') when there is one and nothing otherwise, so it can live in your layout. Laravel's auth screens flash under status; see Usage. It's announced to screen readers, as a message after an action should be.

We have emailed your password reset link.
Show code
Blade
<x-widget.alert flash="status" tone="success" dismissible class="max-w-2xl" />

Error summary

Put it at the top of a form. It lists each field's first error, and each links to its field. After a failed submit it takes focus, so screen readers read the problems out. For a named error bag, pass bag="…".

Show code
Blade
<x-widget.alert.errors class="max-w-2xl" />

Props

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

<x-widget.alert>

Prop Default Description
tone 'info'
title null
flash null
icon null
dismissible false
dismiss-label null

<x-widget.alert.errors>

Prop Default Description
title null
bag 'default'

Source

What larawell:add alert 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/alert/errors.blade.php Show
errors.blade.php
@props([
    'title' => null,
    'bag' => 'default',
])

@php
    $bagged = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors->getBag($bag) : null;
    // One line per field (its first message), in the order the rules ran; each field still shows all of its own.
    $problems = $bagged === null ? [] : array_map(static fn (array $messages): string => (string) $messages[0], $bagged->messages());
    $count = count($problems);
    // Plain English like every component; pass title="…" for another language or wording.
    $title ??= $count === 1 ? 'There is a problem' : "There are {$count} problems";
    // Focus only after a failed submit (errors flashed to the session), not whenever it's rendered, so a
    // page that shows errors some other way doesn't jump to it on load.
    $focusOnLoad = session()->has('errors');
@endphp

{{--
    Goes at the top of a form. Each message links to its field by the id the field derives from its
    name, so it works with the input widgets as they are; the script moves focus into the field on click.
--}}
@if ($count > 0)
    <div
        data-alert
        data-alert-errors
        role="alert"
        tabindex="-1"
        @if ($focusOnLoad) data-focus-on-load @endif
        {{ $attributes->class(['border-error/30 bg-error/5 focus-visible:ring-error flex gap-3 rounded-2xl border p-4 text-sm outline-none focus-visible:ring-2']) }}
    >
        <x-widget.icon name="circle-alert" class="text-error mt-px size-5" />
        <div class="min-w-0 flex-1">
            <p class="text-foreground font-semibold">{{ $title }}</p>
            <ul class="text-foreground/80 mt-1 list-disc space-y-1 ps-5 leading-6">
                @foreach ($problems as $key => $message)
                    <li>
                        <a href="#{{ \App\View\Widget\FormField::idFor($key) }}" data-alert-field="{{ $key }}" class="hover:text-error underline underline-offset-2">{{ $message }}</a>
                    </li>
                @endforeach
            </ul>
        </div>
    </div>
@endif
resources/views/components/widget/alert/index.blade.php Show
index.blade.php
@props([
    'tone' => 'info',
    'title' => null,
    'flash' => null,
    'icon' => null,
    'dismissible' => false,
    'dismissLabel' => null,
])

@php
    // [box, icon colour, default icon]. Tints and text follow the table badge's mixes, so each tone stays AA.
    // Warning's yellow is too light to colour an icon on its own tint, so it's mixed towards the text colour.
    $tones = [
        'info' => ['border-link/25 bg-link/5', 'text-link', 'info'],
        'success' => ['border-success/30 bg-success/5', 'text-[color-mix(in_oklab,var(--color-success)_75%,var(--color-foreground))]', 'circle-check'],
        'warning' => ['border-warning/60 bg-warning/10', 'text-[color-mix(in_oklab,var(--color-warning)_60%,var(--color-foreground))]', 'triangle-alert'],
        'error' => ['border-error/30 bg-error/5', 'text-error', 'circle-alert'],
    ];
    $tone = array_key_exists($tone, $tones) ? $tone : 'info';
    [$box, $iconColour, $defaultIcon] = $tones[$tone];
    // An icon name swaps the tone's icon; false drops it.
    $icon = $icon === false ? null : (is_string($icon) && $icon !== '' ? $icon : $defaultIcon);

    // flash="status" shows session('status'), the key Laravel's own auth screens use. It reads a key of its
    // own on purpose: 'success', 'error' and friends belong to the toast, and would otherwise show twice.
    $flashed = $flash !== null ? session($flash) : null;
    $message = is_string($flashed) && $flashed !== '' ? $flashed : null;
    $hasBody = $slot->isNotEmpty() || $message !== null;
    // A flash alert with nothing flashed renders nothing, so it can sit in a layout permanently.
    $render = $flash === null || $message !== null;
    // Only a message that arrives after an action is announced; a banner that's always on the page isn't news.
    // resources/js/widget/alert reads it out: a live region that already holds its text when the page loads is
    // announced by no screen reader for role=status and only by some for role=alert.
    $announce = $flash === null ? null : (in_array($tone, ['error', 'warning'], true) ? 'alert' : 'status');
    $dismissLabel ??= 'Dismiss';
@endphp

@if ($render)
    <div
        data-alert
        @if ($announce) data-announce="{{ $announce }}" @endif
        {{ $attributes->class(['flex gap-3 rounded-2xl border p-4 text-sm transition-opacity duration-200 data-leaving:opacity-0 motion-reduce:transition-none', $box]) }}
    >
        @if ($icon)
            <x-widget.icon :name="$icon" @class(['mt-px size-5', $iconColour]) />
        @endif

        <div class="min-w-0 flex-1">
            @if ($title)
                <p class="text-foreground font-semibold">{{ $title }}</p>
            @endif
            @if ($hasBody)
                <div @class(['text-foreground/80 leading-6', 'mt-1' => $title])>{{ $slot->isNotEmpty() ? $slot : $message }}</div>
            @endif
            {{-- Links or buttons that act on the message: "Add a card", "Undo". --}}
            @isset($actions)
                <div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-medium">{{ $actions }}</div>
            @endisset
        </div>

        @if ($dismissible)
            {{-- Hides it for this page view. To keep it hidden, remember the choice on the server. --}}
            <button type="button" data-alert-dismiss aria-label="{{ $dismissLabel }}" class="text-foreground/60 hover:text-foreground hover:bg-foreground/5 focus-visible:ring-primary -m-1.5 grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2">
                <x-widget.icon name="x" class="size-4" />
            </button>
        @endif
    </div>
@endif
resources/js/widget/alert/index.js Show
index.js
// Drives <x-widget.alert> and <x-widget.alert.errors>: the dismiss button, focusing the error summary
// after a failed submit so screen readers read it out, and moving focus into a field from its summary link.

// Matches the alert's duration-200 fade.
const LEAVE_MS = 200;
const FOCUSABLE = 'input:not([type="hidden"]), select, textarea, button, [tabindex]:not([tabindex="-1"])';

document.addEventListener('click', (event) => {
    const dismiss = event.target.closest?.('[data-alert-dismiss]');
    if (dismiss) {
        const alert = dismiss.closest('[data-alert]');
        // The focused button is about to go; hand focus to whatever comes next on the page, not to <body>.
        if (alert.contains(document.activeElement)) {
            focusNextAfter(alert);
        }
        alert.dataset.leaving = '';
        setTimeout(() => putAway(alert), window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : LEAVE_MS);

        return;
    }

    const link = event.target.closest?.('[data-alert-field]');
    if (link) {
        const field = fieldFor(link);
        if (field) {
            event.preventDefault();
            // Centre it so its label, above the box, is in view too.
            field.scrollIntoView({ block: 'center' });
            field.focus({ preventScroll: true });
        }
    }
});

const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';

// The next thing on the page that can really take focus: checkVisibility() also rules out the inside of a closed
// <details>, where focus() quietly does nothing. Tried in order until one takes it.
function focusNextAfter(alert) {
    const all = [...document.querySelectorAll(TABBABLE)].filter((el) => !alert.contains(el) && el.checkVisibility({ visibilityProperty: true }));
    const after = all.filter((el) => alert.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
    for (const candidate of [...after, ...all.reverse()]) {
        candidate.focus();
        if (document.activeElement === candidate) {
            return;
        }
    }
}

// The link points at the id a field gets from its name. Failing that (a second field of the same name got
// a -2 suffix, or the control is a hidden input behind a custom picker), find it by name instead.
function fieldFor(link) {
    const byId = document.getElementById(link.hash.slice(1));
    if (byId?.matches(FOCUSABLE)) {
        return byId;
    }

    // items.0.date → items[0][date]
    const [first, ...rest] = link.dataset.alertField.split('.');
    const name = first + rest.map((part) => `[${part}]`).join('');
    const named = document.querySelector(`[name="${CSS.escape(name)}"], [name="${CSS.escape(name)}[]"]`) ?? byId;

    return named?.matches(FOCUSABLE) ? named : named?.closest('[data-field]')?.querySelector(FOCUSABLE) ?? null;
}

// A flashed alert (data-announce) is read out once through a live region that was already on the page, the
// only kind screen readers announce reliably. Errors and warnings interrupt; the rest wait their turn.
// Each alert is read out once; one that arrives with a Livewire render is announced then (see afterRender).
const regions = {};
const announced = new WeakSet();
const textOf = (alert) => alert.textContent.trim().replace(/\s+/g, ' ');

function announce(alerts) {
    const fresh = alerts.filter((alert) => !announced.has(alert));
    if (fresh.length === 0) {
        return;
    }
    fresh.forEach((alert) => announced.add(alert));
    for (const role of ['status', 'alert']) {
        if (!regions[role]?.isConnected) {
            regions[role] = Object.assign(document.createElement('div'), { className: 'sr-only' });
            regions[role].setAttribute('role', role);
            document.body.append(regions[role]);
        }
    }
    // Emptied now and filled a moment later, so the same message flashed twice in a row is read twice.
    const roleOf = (alert) => (alert.dataset.announce === 'alert' ? 'alert' : 'status');
    new Set(fresh.map(roleOf)).forEach((role) => { regions[role].textContent = ''; });
    setTimeout(() => {
        for (const alert of fresh) {
            const region = regions[roleOf(alert)];
            region.textContent = [region.textContent, textOf(alert)].filter(Boolean).join(' ');
        }
    }, 150);
}

announce([...document.querySelectorAll('[data-alert][data-announce]')]);

// Module scripts run after the page is parsed, so the summary is already there. Leave autofocus alone.
const summary = document.querySelector('[data-alert-errors][data-focus-on-load]');
if (summary && (document.activeElement === document.body || document.activeElement === null)) {
    summary.focus();
}

// --- Livewire ---------------------------------------------------------------------------------------------

// Removing a dismissed alert inside a Livewire component doesn't last: the next render puts back what the server
// still renders. So there it's hidden instead, and hidden again after each render for as long as it says the same
// thing; a different message in its place is news, and shows.
const dismissedText = new WeakMap();

function putAway(alert) {
    if (!alert.closest('[wire\\:id]')) {
        alert.remove();

        return;
    }
    alert.hidden = true;
    dismissedText.set(alert, textOf(alert));
}

// A form inside a Livewire component that was just submitted, until the person types again: if the render that answers
// it brings an error summary, focus moves there, as it does after a failed submit of an ordinary form. Not while
// they're typing, so live validation can't pull focus out of a field.
let submitted = null;
document.addEventListener('submit', (event) => {
    submitted = event.target.closest?.('[wire\\:id]') ? event.target : null;
}, true);
document.addEventListener('input', () => {
    submitted = null;
}, true);

function afterRender(scope) {
    for (const alert of scope.querySelectorAll?.('[data-alert]') ?? []) {
        if (!dismissedText.has(alert)) {
            continue;
        }
        if (dismissedText.get(alert) === textOf(alert)) {
            alert.hidden = true;
        } else {
            dismissedText.delete(alert);
            alert.hidden = false;
        }
    }

    // A flash set in a Livewire action arrives with the render, after the page has loaded.
    announce([...(scope.querySelectorAll?.('[data-alert][data-announce]') ?? [])]);

    if (submitted && scope.contains?.(submitted)) {
        const form = submitted;
        submitted = null;
        const errors = form.querySelector('[data-alert-errors]') ?? form.closest('[wire\\:id]')?.querySelector('[data-alert-errors]');
        errors?.focus();
    }
}

// Not '../field''s onLivewireMorph: the alert doesn't require the field, and this is all it needs of it.
const hook = (Livewire) => Livewire.hook('morphed', ({ el }) => afterRender(el));
if (window.Livewire) {
    hook(window.Livewire);
} else {
    document.addEventListener('livewire:init', () => hook(window.Livewire));
}
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());
    }
}