<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.
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.
<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.
Payment received
Your trial ends in 3 days
Payout failed
Show code Hide code
<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
Show code Hide code
<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.
Show code Hide code
<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.
Show code Hide code
<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 Hide code
<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
@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
@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
// 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
<?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
<?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());
}
}