<x-widget.slider>
Slider Blade component for Laravel and Livewire
A slider on the native range input, so its keyboard (arrows, Page Up and Down, Home, End) and screen reader support stay: one thumb, or two on one track for a span such as a price range. Min, max and step, a prefix or suffix on the value shown beside the label and read out per thumb, in the page's number format. Range values submit as name[] and bind with wire:model. Right to left fills from the right.
php artisan bladewell:add slider
Usage
Livewire
In a Livewire component, bind with wire:model (deferred) or wire:model.live; no name is needed. One thumb binds a number: public int $volume = 40. A range binds an array, public array $price = [200, 750], each thumb its own end (price.0 and price.1). The slider shows the property after every render, so setting it in PHP moves the thumbs.
<div class="space-y-6">
<x-widget.slider label="Volume" wire:model.live="volume" suffix="%" />
<x-widget.slider label="Price" range wire:model="price" :max="1000" :step="10" prefix="$" />
</div>
Examples
Basic
A native range input underneath: the arrow keys move it a step, Page Up and Down move it further, Home and End jump to the ends. suffix (or prefix) goes on the value shown under the label and on what a screen reader hears.
Show code Hide code
<x-widget.slider name="volume" label="Volume" :value="60" suffix="%" class="max-w-sm" />
Range
range puts two thumbs on one track. They can't cross, and the one moved last stays on top. It submits [from, to] as name[] (here price[]): validate 'price' => ['array', 'size:2'], 'price.*' => ['integer', 'between:0,1000'].
Per night, before taxes.
Show code Hide code
<x-widget.slider name="price" label="Price" range :value="[200, 750]" :min="0" :max="1000" :step="10" prefix="$" info="Per night, before taxes." class="max-w-sm" />
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.slider>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
What it submits as; its error and old input are found under it. With range, two values as name[]. Optional with wire:model, which then names it. |
| id |
null
|
Defaults to one made from the name (or the wire:model property). |
| label |
null
|
Shown above the slider, and its name for screen readers. |
| value |
null
|
The value, or with range [from, to]. Old input wins after a failed submit; with wire:model and no value, the bound Livewire property. Defaults to min (and max, for range). |
| min |
0
|
The lowest value. |
| max |
100
|
The highest value. |
| step |
1
|
How far one move goes: the arrow keys move a step, and values snap to steps from min. |
| range |
false
|
Two thumbs on one track, for a span such as a price range. Submits [from, to] as name[]. |
| prefix |
''
|
Shown before each value, under the label and to screen readers: "$". |
| suffix |
''
|
Shown after each value: " km", "%". |
| from-label |
'Minimum'
|
With range, the first thumb's name for screen readers, after the field's label: "Price Minimum". |
| to-label |
'Maximum'
|
With range, the second thumb's. |
| error |
null
|
An error message of your own; otherwise the validation error for the name, from the session or Livewire. |
| info |
null
|
A hint under the slider. |
| bag |
'default'
|
Which error bag to read the error from. |
| disabled |
false
|
Greyed out: it can't be changed. |
Accessibility
All 2 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What bladewell:add slider writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
Field, and the theme and base CSS.
resources/views/components/widget/slider/index.blade.php Show
@props([
// What it submits as; its error and old input are found under it. With range, two values as name[]. Optional with
// wire:model, which then names it.
'name' => null,
// Defaults to one made from the name (or the wire:model property).
'id' => null,
// Shown above the slider, and its name for screen readers.
'label' => null,
// The value, or with range [from, to]. Old input wins after a failed submit; with wire:model and no value, the
// bound Livewire property. Defaults to min (and max, for range).
'value' => null,
// The lowest value.
'min' => 0,
// The highest value.
'max' => 100,
// How far one move goes: the arrow keys move a step, and values snap to steps from min.
'step' => 1,
// Two thumbs on one track, for a span such as a price range. Submits [from, to] as name[].
'range' => false,
// Shown before each value, under the label and to screen readers: "$".
'prefix' => '',
// Shown after each value: " km", "%".
'suffix' => '',
// With range, the first thumb's name for screen readers, after the field's label: "Price Minimum".
'fromLabel' => 'Minimum',
// With range, the second thumb's.
'toLabel' => 'Maximum',
// An error message of your own; otherwise the validation error for the name, from the session or Livewire.
'error' => null,
// A hint under the slider.
'info' => null,
// Which error bag to read the error from.
'bag' => 'default',
// Greyed out: it can't be changed.
'disabled' => false,
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'slider', attributes: $attributes);
$min = (float) $min;
$max = (float) $max;
$step = (float) $step > 0 ? (float) $step : 1.0;
// A typo fails loudly, instead of a slider that can't move.
if ($max <= $min) {
throw new \InvalidArgumentException("<x-widget.slider> needs max greater than min; got min [{$min}] and max [{$max}].");
}
// Into range and onto a step, as the browser would: a value it couldn't show isn't shown.
$fit = static function (mixed $raw, float $fallback) use ($min, $max, $step): float {
$number = is_numeric($raw) ? (float) $raw : $fallback;
$number = $min + round(($number - $min) / $step) * $step;
return max($min, min($max, $number));
};
$current = $field->old($value);
$values = $range
? [$fit(is_array($current) ? ($current[0] ?? null) : null, $min), $fit(is_array($current) ? ($current[1] ?? null) : null, $max)]
: [$fit(is_array($current) ? null : $current, $min)];
if ($range && $values[0] > $values[1]) {
$values = [$values[1], $values[0]];
}
// Whole numbers print without ".0".
$plain = static fn (float $number): string => rtrim(rtrim(number_format($number, 6, '.', ''), '0'), '.');
$percent = static fn (float $number): int => (int) round(($number - $min) / ($max - $min) * 100);
$fromPercent = $range ? $percent($values[0]) : 0;
$toPercent = $percent($values[$range ? 1 : 0]);
$show = static fn (float $number): string => $prefix.$plain($number).$suffix;
// With range, a binding to the whole array becomes one to each end: wire:model="price" → price.0 and price.1.
$bindings = $field->bindings($attributes)->getAttributes();
$bindingFor = static fn (int $end): array => collect($bindings)
->mapWithKeys(fn (mixed $property, string $key): array => [$key => ($range && $key !== 'form') ? "{$property}.{$end}" : $property])
->all();
$inputAttributes = $field->forwarded($attributes)->except([...array_keys($bindings), 'required'])
->merge($field->aria($attributes, (bool) $info)->getAttributes());
$inputName = $name ? ($range ? "{$name}[]" : $name) : null;
// One thumb: the native range input, restyled, so its own keyboard (arrows, Page Up and Down, Home, End) and screen
// reader support stay.
$thumb = 'appearance-none bg-transparent absolute inset-0 m-0 h-full w-full outline-none disabled:cursor-not-allowed [&::-moz-range-track]:bg-transparent '
.'[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-surface [&::-webkit-slider-thumb]:shadow-sm '
.'[&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-surface [&::-moz-range-thumb]:shadow-sm '
.'focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-primary focus-visible:[&::-webkit-slider-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-offset-surface '
.'focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-primary '
.'group-data-invalid/field:[&::-webkit-slider-thumb]:border-error group-data-invalid/field:[&::-moz-range-thumb]:border-error';
// Two thumbs: two native inputs would lie on top of each other, one target over the other, so each thumb is its own
// element instead (role="slider", as in the ARIA two-thumb slider), moved by the script, with a hidden input each
// carrying its value. Each thumb's span ends at the other.
$knob = 'bg-surface border-primary absolute top-1/2 size-5 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface data-front:z-10 group-data-invalid/field:border-error aria-disabled:cursor-not-allowed';
// Where each sits: its share of the track, which runs between the two thumbs' centres at the ends.
$knobPlace = ['from' => 'start-[calc((100%-1.25rem)*var(--slider-from)/100)]', 'to' => 'start-[calc((100%-1.25rem)*var(--slider-to)/100)]'];
$locale = str_replace('_', '-', app()->getLocale());
@endphp
<x-widget.field :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :disabled="$disabled" :required="$attributes->has('required')" :labels-control="! $range" bare :class="$attributes->get('class')">
{{-- Under the label: the value, or from – to, as people read it. Screen readers hear each thumb's own value instead. --}}
<x-slot:before>
<output data-slider-output for="{{ $range ? "{$field->id}-from {$field->id}" : $field->id }}" aria-hidden="true" class="text-foreground -mt-1.5 mb-2 block text-sm font-medium tabular-nums">{{ $range ? $show($values[0]).' – '.$show($values[1]) : $show($values[0]) }}</output>
</x-slot:before>
<div
data-slider
data-min="{{ $plain($min) }}"
data-max="{{ $plain($max) }}"
data-prefix="{{ $prefix }}"
data-suffix="{{ $suffix }}"
data-locale="{{ $locale }}"
data-step="{{ $plain($step) }}"
@if ($range) data-range @endif
@class(['relative h-5', "[--slider-from:{$fromPercent}] [--slider-to:{$toPercent}]", 'opacity-50' => $disabled])
>
{{-- The track, and the filled part between the ends (or from the start), placed from the CSS variables: the
server sets where they start, the script moves them. Logical sides, so right to left fills from the right. --}}
<div aria-hidden="true" class="bg-line pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full">
<div class="bg-primary-fill absolute inset-y-0 start-[calc(var(--slider-from)*1%)] end-[calc(100%-var(--slider-to)*1%)] rounded-full group-data-invalid/field:bg-error"></div>
</div>
@if ($range)
@foreach ([['from', 0, $fromLabel, "{$field->id}-from"], ['to', 1, $toLabel, $field->id]] as [$end, $at, $endLabel, $thumbId])
<div
role="slider"
id="{{ $thumbId }}"
data-slider-thumb="{{ $end }}"
tabindex="{{ $disabled ? -1 : 0 }}"
aria-label="{{ trim(($label ?? '').' '.$endLabel) }}"
aria-orientation="horizontal"
aria-valuemin="{{ $plain($at === 0 ? $min : $values[0]) }}"
aria-valuemax="{{ $plain($at === 0 ? $values[1] : $max) }}"
aria-valuenow="{{ $plain($values[$at]) }}"
aria-valuetext="{{ $show($values[$at]) }}"
@if ($disabled) aria-disabled="true" @endif
{{ $field->aria($attributes, (bool) $info)->class([$knob, $knobPlace[$end]]) }}
></div>
<input type="hidden" data-slider-value="{{ $end }}" @if ($inputName) name="{{ $inputName }}" @endif value="{{ $plain($values[$at]) }}" @disabled($disabled) {{ new \Illuminate\View\ComponentAttributeBag($bindingFor($at)) }}>
@endforeach
@else
<input type="range" id="{{ $field->id }}" data-slider-to @if ($inputName) name="{{ $inputName }}" @endif min="{{ $plain($min) }}" max="{{ $plain($max) }}" step="{{ $plain($step) }}" value="{{ $plain($values[0]) }}" aria-valuetext="{{ $show($values[0]) }}" @disabled($disabled) {{ $inputAttributes->merge($bindingFor(0))->class([$thumb]) }}>
@endif
</div>
{{-- The ends of the scale. --}}
<div aria-hidden="true" class="text-muted mt-2 flex justify-between text-xs tabular-nums">
<span data-slider-end="{{ $plain($min) }}">{{ $show($min) }}</span>
<span data-slider-end="{{ $plain($max) }}">{{ $show($max) }}</span>
</div>
</x-widget.field>
resources/js/widget/slider/index.js Show
// Drives <x-widget.slider>. One thumb is a native range input, whose keyboard and screen reader support stay; this
// redraws the filled track, the value under the label and what a screen reader hears, in the page's number format. Two
// thumbs (range) are role="slider" elements this moves itself, as the ARIA two-thumb slider does: Right and Up step up,
// Left and Down step down (Left and Right follow the reading direction), Page Up and Down move ten steps, Home and End
// go as far as they can. Each stops at the other. Dragging a thumb moves it; pressing the track moves the nearer one
// there. A hidden input per thumb carries its value, for the form and wire:model.
import { onLivewireMorph } from '../field';
const ROOT = '[data-slider]';
const PAGE = 10;
const fieldOf = (root) => root.closest('[data-field]');
const number = (value) => Number(value);
const bounds = (root) => ({ min: number(root.dataset.min), max: number(root.dataset.max), step: number(root.dataset.step) || 1 });
// "$1,250" in en, "1.250 €" in de: the number in the page's format, with the widget's prefix and suffix.
function show(root, value) {
const formatted = new Intl.NumberFormat(root.dataset.locale || document.documentElement.lang || undefined, { maximumFractionDigits: 6 }).format(number(value));
return `${root.dataset.prefix ?? ''}${formatted}${root.dataset.suffix ?? ''}`;
}
const percent = (root, value) => {
const { min, max } = bounds(root);
return ((number(value) - min) / (max - min)) * 100;
};
// The values as they stand: the native input's, or each thumb's hidden input.
function valuesOf(root) {
if (root.hasAttribute('data-range')) {
return [number(root.querySelector('[data-slider-value="from"]').value), number(root.querySelector('[data-slider-value="to"]').value)];
}
return [number(root.querySelector('[data-slider-to]').value)];
}
function draw(root) {
const values = valuesOf(root);
const range = values.length === 2;
// Through the CSSOM, which a Content Security Policy allows, unlike a style attribute in the markup.
root.style.setProperty('--slider-from', range ? String(percent(root, values[0])) : '0');
root.style.setProperty('--slider-to', String(percent(root, values.at(-1))));
if (range) {
const { min, max } = bounds(root);
const [from, to] = ['from', 'to'].map((end) => root.querySelector(`[data-slider-thumb="${end}"]`));
from.setAttribute('aria-valuenow', String(values[0]));
from.setAttribute('aria-valuemin', String(min));
from.setAttribute('aria-valuemax', String(values[1]));
to.setAttribute('aria-valuenow', String(values[1]));
to.setAttribute('aria-valuemin', String(values[0]));
to.setAttribute('aria-valuemax', String(max));
from.setAttribute('aria-valuetext', show(root, values[0]));
to.setAttribute('aria-valuetext', show(root, values[1]));
} else {
root.querySelector('[data-slider-to]').setAttribute('aria-valuetext', show(root, values[0]));
}
const output = fieldOf(root)?.querySelector('[data-slider-output]');
if (output) {
output.textContent = range ? `${show(root, values[0])} – ${show(root, values[1])}` : show(root, values[0]);
}
// The ends of the scale, in the same format.
fieldOf(root)?.querySelectorAll('[data-slider-end]').forEach((end) => {
end.textContent = show(root, end.dataset.sliderEnd);
});
}
// --- One thumb: the native input ----------------------------------------------------------------------------------------
document.addEventListener('input', (event) => {
if (event.target.matches?.('input[type="range"][data-slider-to]')) {
draw(event.target.closest(ROOT));
}
});
// --- Two thumbs ---------------------------------------------------------------------------------------------------------
const disabled = (thumb) => thumb.getAttribute('aria-disabled') === 'true';
// Onto a step from min, and between the scale's end and the other thumb.
function place(root, end, value, { commit = false } = {}) {
const { min, max, step } = bounds(root);
const [from, to] = valuesOf(root);
const low = end === 'from' ? min : from;
const high = end === 'from' ? to : max;
const snapped = Math.min(high, Math.max(low, min + Math.round((value - min) / step) * step));
// Floating steps (0.1) would otherwise leave 0.30000000000000004.
const tidy = Number(snapped.toFixed(10));
const input = root.querySelector(`[data-slider-value="${end}"]`);
root.querySelectorAll('[data-slider-thumb]').forEach((thumb) => thumb.toggleAttribute('data-front', thumb.dataset.sliderThumb === end));
if (number(input.value) !== tidy) {
input.value = String(tidy);
draw(root);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
if (commit) {
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
document.addEventListener('keydown', (event) => {
const thumb = event.target.matches?.('[data-slider-thumb]') ? event.target : null;
if (!thumb || disabled(thumb)) {
return;
}
const root = thumb.closest(ROOT);
const { min, max, step } = bounds(root);
const end = thumb.dataset.sliderThumb;
const value = valuesOf(root)[end === 'from' ? 0 : 1];
const rtl = getComputedStyle(root).direction === 'rtl';
const moves = {
ArrowRight: rtl ? -step : step,
ArrowLeft: rtl ? step : -step,
ArrowUp: step,
ArrowDown: -step,
PageUp: step * PAGE,
PageDown: -step * PAGE,
};
let target = null;
if (event.key in moves) {
target = value + moves[event.key];
} else if (event.key === 'Home') {
target = min;
} else if (event.key === 'End') {
target = max;
}
if (target === null) {
return;
}
event.preventDefault();
place(root, end, target, { commit: true });
});
// The value under a pointer, from where it is along the track (whose ends are the thumbs' centres at min and max).
function valueAt(root, clientX) {
const { min, max } = bounds(root);
const box = root.getBoundingClientRect();
const knob = root.querySelector('[data-slider-thumb]').offsetWidth;
let ratio = (clientX - box.left - knob / 2) / (box.width - knob);
if (getComputedStyle(root).direction === 'rtl') {
ratio = 1 - ratio;
}
return min + Math.min(1, Math.max(0, ratio)) * (max - min);
}
let dragging = null;
document.addEventListener('pointerdown', (event) => {
const root = event.target.closest?.(`${ROOT}[data-range]`);
if (!root || event.button !== 0) {
return;
}
let thumb = event.target.closest('[data-slider-thumb]');
if (!thumb) {
// The track: the nearer thumb comes to the pointer, and carries on from there.
const value = valueAt(root, event.clientX);
const [from, to] = valuesOf(root);
const end = Math.abs(value - from) <= Math.abs(value - to) && !(value > to) ? 'from' : 'to';
thumb = root.querySelector(`[data-slider-thumb="${end}"]`);
if (disabled(thumb)) {
return;
}
place(root, end, value);
}
if (disabled(thumb)) {
return;
}
event.preventDefault();
thumb.focus();
// Capture keeps the moves coming when the pointer leaves the thumb; the drag goes on without it if it can't be had.
try {
thumb.setPointerCapture(event.pointerId);
} catch {
// A pointer the browser doesn't know as active (a synthetic one): moves still reach the document.
}
dragging = { root, thumb, pointerId: event.pointerId };
});
document.addEventListener('pointermove', (event) => {
if (dragging?.pointerId === event.pointerId) {
place(dragging.root, dragging.thumb.dataset.sliderThumb, valueAt(dragging.root, event.clientX));
}
});
const stop = (event) => {
if (dragging?.pointerId === event.pointerId) {
const { root, thumb } = dragging;
dragging = null;
place(root, thumb.dataset.sliderThumb, valuesOf(root)[thumb.dataset.sliderThumb === 'from' ? 0 : 1], { commit: true });
}
};
document.addEventListener('pointerup', stop);
document.addEventListener('pointercancel', stop);
// --- Redrawing --------------------------------------------------------------------------------------------------------
// A Livewire render (or a form reset) may change the values without an input event: draw them again.
const drawAll = (scope = document) => {
if (scope instanceof Element && scope.matches(ROOT)) {
draw(scope);
}
scope.querySelectorAll?.(ROOT).forEach(draw);
};
// A frame later: Livewire 3 sets a bound input's value just after the render it reports (checked in a browser against
// Livewire 3.8 and 4.4), and drawing straight away would show the old one.
onLivewireMorph((element) => requestAnimationFrame(() => drawAll(element)));
document.addEventListener('reset', (event) => requestAnimationFrame(() => drawAll(event.target)));
drawAll();
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 or the hint, and the caller's own
* ids. Two separate aria-describedby attributes would make the browser silently drop one.
*/
public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
// Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a
// screen reader would hear two messages that often say the same thing. resources/js/field brings the hint
// back when the error clears.
$describedBy = array_filter([
$this->hasError() ? $this->errorId() : null,
$hasInfo && ! $this->hasError() ? $this->infoId() : null,
$attributes->get('aria-describedby'),
]);
return new ComponentAttributeBag([
'aria-invalid' => $this->hasError() ? 'true' : null,
'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
]);
}
/**
* For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
* hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
*/
public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
}
/**
* The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
* placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
*/
public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => ! self::isBinding($key)), $hasInfo);
}
private static function isBinding(string $key): bool
{
return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
}
/**
* forwarded() and aria() together, for widgets whose visible control is also the submitted one.
*/
public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
{
return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
}
}