<x-widget.number>
Number
Digits only, with a set number of decimals, prefix, suffix and an action slot. Optional -/+ stepper within min and max, negatives, and thousands grouping. Accepts a decimal comma and still submits 12.50. Works with Livewire wire:model, which always gets the plain number.
php artisan larawell:add number
- Also adds
- Icon
Usage
Livewire
In a Livewire component, bind with wire:model (deferred) or wire:model.live; no name is needed. The property always gets the plain number (1234.5), even with grouped showing 1,234.50 or a decimal comma typed in, and the stepper's -/+ update it too. The field shows the property after every render, so setting it in PHP updates both. Keep it a string or a float: public ?string $amount = null.
<div class="space-y-5">
<x-widget.number label="Amount" grouped suffix="USD" wire:model.live="amount" />
<x-widget.number label="Seats" stepper :decimals="0" min="1" max="20" wire:model.live="seats" />
</div>
Examples
Number
Keeps input to digits and the given decimals: 2 by default, 0 for whole numbers, or as many as you need (4 for exchange rates, 8 for crypto). Extra places are cut, not rounded. A decimal comma works too: 12,50 becomes 12.50. prefix and suffix sit inside the box; the action slot sits at the end. negative allows a leading minus. Max fills in the balance from the script beside this.
Show code Hide code
<div class="grid gap-6 sm:grid-cols-2">
<x-widget.number name="amount" label="Amount" placeholder="0.00" suffix="usdt">
<x-slot:action>
<button type="button" class="text-primary text-sm font-medium" data-fill-max="1250.50" aria-controls="amount">Max</button>
</x-slot:action>
</x-widget.number>
<x-widget.number name="price" label="Price" placeholder="0.00" prefix="$" />
<x-widget.number name="adjustment" label="Balance adjustment" prefix="$" value="-25.00" negative />
<x-widget.number name="quantity" label="Quantity" placeholder="0" :decimals="0" />
<x-widget.number name="rate" label="Exchange rate" placeholder="0.0000" :decimals="4" suffix="usd/myr" />
<x-widget.number name="btc" label="Bitcoin" placeholder="0.00000000" :decimals="8" suffix="btc" />
</div>
// Fills the field the button controls. The input event lets the number field re-format it, like typing would.
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-fill-max]');
const field = button && document.getElementById(button.getAttribute('aria-controls'));
if (field) {
field.value = button.dataset.fillMax;
field.dispatchEvent(new Event('input', { bubbles: true }));
}
});
Number stepper
stepper adds - and + buttons (ArrowUp and ArrowDown work too), moving by step within min and max. A typed value outside the range is pulled back in when the field is left.
Show code Hide code
<div class="flex flex-wrap gap-6">
<x-widget.number name="guests" label="Guests" :decimals="0" value="2" :min="1" :max="10" stepper class="max-w-44" />
<x-widget.number name="nights" label="Nights" :decimals="0" value="3" :min="1" :max="30" stepper class="max-w-44" />
</div>
Number grouped
grouped shows thousands separators while typing, the locale's way (1.250.000,50 in de), and still submits 1250000.50.
Show code Hide code
<x-widget.number name="loan" label="Loan amount" prefix="RM" value="1250000" grouped 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.number>
| Prop | Default | Description |
|---|---|---|
| name |
null
|
What it submits as; its error and old input are found under it. Optional with wire:model, which then names it. |
| id |
null
|
Defaults to one made from the name (or the wire:model property). |
| label |
null
|
Shown above the field, and its name for screen readers. |
| value |
null
|
The value to show. Old input wins after a failed submit; with wire:model and no value, the bound Livewire property. |
| placeholder |
null
|
Shown while it's empty. |
| 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 field. |
| bag |
'default'
|
Which error bag to read the error from. |
| disabled |
false
|
Greyed out: it can't be changed. |
| readonly |
false
|
Shown, and submitted, but it can't be edited. |
| decimals |
2
|
Decimal places allowed (0 for whole numbers). Comma or dot both type the decimal point. |
| mobile |
false
|
Digits only, for phone-style numbers: no decimals, no minus, and the phone keypad. |
| prefix |
null
|
Text at the start, inside the field (e.g. https://). |
| suffix |
null
|
Text or a small control at the end, inside the field (e.g. a unit). |
| uppercase |
true
|
Shows the suffix in capitals (usdt → USDT). |
| negative |
false
|
Allows a leading minus. |
| min |
null
|
The lowest value: leaving the field pulls it into range, and the stepper stops there. |
| max |
null
|
The highest value, in the same way. |
| step |
null
|
How much the stepper buttons and arrow keys change it (1 by default). |
| stepper |
false
|
− and + buttons, plus ArrowUp and ArrowDown; announced as a spin button. |
| grouped |
false
|
Thousands separators while typing (1,234,567.5); it still submits the plain number. |
| locale |
null
|
The locale for grouping and the decimal point; defaults to the app's. |
Source
What larawell:add number writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/number/index.blade.php Show
@props([
// What it submits as; its error and old input are found under it. Optional with wire:model, which then names it.
'name' => null,
// Defaults to one made from the name (or the wire:model property).
'id' => null,
// Shown above the field, and its name for screen readers.
'label' => null,
// The value to show. Old input wins after a failed submit; with wire:model and no value, the bound Livewire
// property.
'value' => null,
// Shown while it's empty.
'placeholder' => null,
// An error message of your own; otherwise the validation error for the name, from the session or Livewire.
'error' => null,
// A hint under the field.
'info' => null,
// Which error bag to read the error from.
'bag' => 'default',
// Greyed out: it can't be changed.
'disabled' => false,
// Shown, and submitted, but it can't be edited.
'readonly' => false,
// Decimal places allowed (0 for whole numbers). Comma or dot both type the decimal point.
'decimals' => 2,
// Digits only, for phone-style numbers: no decimals, no minus, and the phone keypad.
'mobile' => false,
// Text at the start, inside the field (e.g. https://).
'prefix' => null,
// Text or a small control at the end, inside the field (e.g. a unit).
'suffix' => null,
// Shows the suffix in capitals (usdt → USDT).
'uppercase' => true,
// Allows a leading minus.
'negative' => false,
// The lowest value: leaving the field pulls it into range, and the stepper stops there.
'min' => null,
// The highest value, in the same way.
'max' => null,
// How much the stepper buttons and arrow keys change it (1 by default).
'step' => null,
// − and + buttons, plus ArrowUp and ArrowDown; announced as a spin button.
'stepper' => false,
// Thousands separators while typing (1,234,567.5); it still submits the plain number.
'grouped' => false,
// The locale for grouping and the decimal point; defaults to the app's.
'locale' => null,
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'number', attributes: $attributes);
$value = $field->old($value);
// A mobile number is digits only, so it never takes a decimal point (for full phone numbers, see input.phone).
$decimals = $mobile ? 0 : max(0, (int) $decimals);
$negative = $negative && ! $mobile;
$step ??= 1;
// grouped shows 1,250,000.50 while typing and submits 1250000.50 from a hidden input. It writes and reads
// numbers the locale's way (1.250.000,50 in de), so the separators come from the locale here and in the JS.
$locale = str_replace('_', '-', $locale ?? app()->getLocale());
$groupedDisplay = null;
if ($grouped && ! $mobile && is_numeric($value) && class_exists(\NumberFormatter::class)) {
[$whole, $fraction] = array_pad(explode('.', ltrim((string) $value, '-'), 2), 2, null);
// @numbers=latn: Arabic, Persian or Devanagari locales still write 0-9, as the JS parses and the form submits.
$formatter = new \NumberFormatter(str_replace('-', '_', $locale).'@numbers=latn', \NumberFormatter::DECIMAL);
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, 0);
$groupedDisplay = (str_starts_with((string) $value, '-') ? '-' : '')
.$formatter->format((float) $whole)
.($fraction !== null ? $formatter->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL).$fraction : '');
}
$grouped = $grouped && ! $mobile;
$stepButton = 'text-foreground/70 hover:bg-line hover:text-foreground focus-visible:ring-primary grid size-8 shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 disabled:opacity-40';
@endphp
{{-- type="text" rather than "number": number inputs allow "e", scroll-wheel changes and lose leading zeros.
min and max are therefore enforced by resources/js/widget/number (kept in range when the field is left). --}}
<x-widget.field :required="$attributes->has('required')" :id="$field->id" :label="$label" :error="$field->errors" :info="$info" :disabled="$disabled" :readonly="$readonly" box="h-12 items-center pe-5" :class="$attributes->get('class')">
@if ($stepper)
<button type="button" data-number-step="-1" aria-label="Decrease" aria-controls="{{ $field->id }}" tabindex="-1" @disabled($disabled || $readonly) class="{{ $stepButton }} ms-2">
<x-widget.icon name="minus" class="size-4" />
</button>
@endif
@if ($mobile)
<span class="shrink-0 ps-5 pe-2 select-none">+</span>
@elseif ($prefix)
<span @class(['text-muted shrink-0 select-none', 'ps-5 pe-2' => ! $stepper, 'ps-1 pe-2' => $stepper])>{{ $prefix }}</span>
@endif
<input
type="text"
id="{{ $field->id }}"
@if ($name && ! $grouped) name="{{ $name }}" @endif
value="{{ $grouped ? ($groupedDisplay ?? $value) : $value }}"
placeholder="{{ $placeholder }}"
inputmode="{{ $mobile ? 'tel' : ($decimals > 0 ? 'decimal' : 'numeric') }}"
autocomplete="{{ $mobile ? 'tel' : 'off' }}"
{{-- With a stepper, ArrowUp/ArrowDown step the value (the buttons are pointer-only), so say so the ARIA way. --}}
@if ($stepper)
role="spinbutton"
@if ($min !== null) aria-valuemin="{{ $min }}" @endif
@if ($max !== null) aria-valuemax="{{ $max }}" @endif
@if (is_numeric($value)) aria-valuenow="{{ $value }}" @endif
@endif
data-number-input
data-decimals="{{ $decimals }}"
data-step="{{ $step }}"
@if ($negative) data-negative @endif
@if ($min !== null) data-min="{{ $min }}" @endif
@if ($max !== null) data-max="{{ $max }}" @endif
@if ($grouped) data-grouped data-locale="{{ $locale }}" @endif
@disabled($disabled)
@readonly($readonly)
{{ ($grouped ? $field->visibleAttributes($attributes, (bool) $info) : $field->controlAttributes($attributes, (bool) $info))->class([
'h-full w-full min-w-0 bg-transparent outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-50 read-only:cursor-default',
'ps-5' => ! $mobile && ! $prefix && ! $stepper,
// Between - and + the value sits centred.
'text-center' => $stepper,
'tabular-nums' => $grouped,
'group-data-invalid/field:placeholder:text-error group-data-invalid/field:focus:placeholder:text-muted',
]) }}
>
{{-- grouped: the visible field is for people; this carries the plain number to the form, Livewire and Alpine. --}}
@if ($grouped)
<input type="hidden" @if ($name) name="{{ $name }}" @endif value="{{ $value }}" data-number-value {{ $field->bindings($attributes) }}>
@endif
@if ($suffix)
<span @class(['text-muted shrink-0 cursor-default px-2.5 select-none', 'uppercase' => $uppercase])>{{ $suffix }}</span>
@endif
@if ($stepper)
<button type="button" data-number-step="1" aria-label="Increase" aria-controls="{{ $field->id }}" tabindex="-1" @disabled($disabled || $readonly) class="{{ $stepButton }}">
<x-widget.icon name="plus" class="size-4" />
</button>
@endif
@isset($action)
<div class="ms-1 shrink-0">{{ $action }}</div>
@endisset
</x-widget.field>
resources/js/widget/number/index.js Show
// Behaviour for <x-widget.number>: digits only, decimals, the stepper and grouping. Delegated from `document`, so fields added later work without
// re-initialising. Client-side filtering is only for convenience; the Form Request must validate the same rules.
import { on, replaceValue, onLivewireMorph, typingIn } from '../field';
// --- Number: digits, one decimal point, limited decimal places --------------------
// Much of the world writes 12,50 for 12.50, and dropping the comma would turn it into 1250. Whichever
// separator comes last is the decimal point and becomes "."; any others are thousands grouping, so a
// pasted 1.234,56 or 1,234.56 both become 1234.56. One separator used more than once (1,234,567) is
// grouping too. The field keeps "." so what it submits is a number Laravel's validation accepts.
function normaliseDecimal(raw) {
const lastDot = raw.lastIndexOf('.');
const lastComma = raw.lastIndexOf(',');
if (lastDot === -1 && lastComma === -1) {
return raw;
}
if (lastDot !== -1 && lastComma !== -1) {
const decimal = lastDot > lastComma ? '.' : ',';
const grouping = decimal === '.' ? ',' : '.';
return raw.split(grouping).join('').replace(decimal, '.');
}
const separator = lastDot !== -1 ? '.' : ',';
return raw.split(separator).length > 2 ? raw.split(separator).join('') : raw.replace(separator, '.');
}
// Filters on `input` rather than `keydown` so paste, autofill and mobile keyboards are covered too.
// `negative` keeps one leading minus sign; anything else that isn't a digit or the decimal point goes.
function sanitizeNumber(raw, decimals, negative = false) {
const minus = negative && raw.trim().startsWith('-') ? '-' : '';
if (decimals === 0) {
// Whole numbers: a decimal part is dropped, not glued on, so a pasted 2.00 or 1,5 stays 2 or 1.
return minus + normaliseDecimal(raw).replace(/[^0-9.]/g, '').split('.')[0];
}
const cleaned = normaliseDecimal(raw).replace(/[^0-9.]/g, '');
const point = cleaned.indexOf('.');
if (point === -1) {
return minus + cleaned;
}
return minus + cleaned.slice(0, point + 1) + cleaned.slice(point + 1).replace(/\./g, '').slice(0, decimals);
}
// grouped: the field shows 1,250,000.50 the locale's way (1.250.000,50 in de, 12,50,000 in en-IN) and a
// hidden input carries 1250000.50. Typing follows the locale's separators; digits stay 0-9 so they read back.
const separatorCache = new Map();
function separators(locale) {
if (!separatorCache.has(locale)) {
// Same numbering as the display (0-9), so the separators it looks for are the ones it writes.
const parts = new Intl.NumberFormat(locale, { numberingSystem: 'latn' }).formatToParts(12345.6);
separatorCache.set(locale, {
group: parts.find((part) => part.type === 'group')?.value ?? ',',
decimal: parts.find((part) => part.type === 'decimal')?.value ?? '.',
});
}
return separatorCache.get(locale);
}
function parseGrouped(display, input) {
const { group, decimal } = separators(input.dataset.locale);
// Spaces count as grouping too, since some locales group with a (narrow) no-break space people can't type.
const plain = display.split(group).join('').replace(/\s/g, '').replace(decimal, '.');
return sanitizeNumber(plain, Number(input.dataset.decimals), 'negative' in input.dataset);
}
function formatGrouped(canonical, input) {
const { decimal } = separators(input.dataset.locale);
const minus = canonical.startsWith('-') ? '-' : '';
const [whole, fraction] = canonical.replace('-', '').split('.');
const grouped = whole ? new Intl.NumberFormat(input.dataset.locale, { numberingSystem: 'latn' }).format(BigInt(whole)) : '';
return minus + grouped + (fraction !== undefined ? decimal + fraction : '');
}
// Reformatting inserts and removes separators, so the caret is put back after the same number of digits.
function regroup(input) {
const significant = (text) => text.replace(/[^0-9\-]/g, '').length + (text.includes(separators(input.dataset.locale).decimal) ? 1 : 0);
const before = significant(input.value.slice(0, input.selectionStart ?? input.value.length));
const canonical = parseGrouped(input.value, input);
input.value = formatGrouped(canonical, input);
let caret = 0;
while (caret < input.value.length && significant(input.value.slice(0, caret)) < before) {
caret++;
}
input.setSelectionRange(caret, caret);
return canonical;
}
const numberValue = (input) => ('grouped' in input.dataset ? parseGrouped(input.value, input) : input.value);
// Writes a clean number to the field (and, when grouped, the hidden input) and tells listeners.
function setNumber(input, canonical, { format = true } = {}) {
const hidden = 'grouped' in input.dataset ? input.closest('[data-field]').querySelector('[data-number-value]') : null;
if (format) {
input.value = hidden ? formatGrouped(canonical, input) : canonical;
}
const target = hidden ?? input;
if (hidden) {
hidden.value = canonical;
}
syncValueNow(input);
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
// A stepper field is a spinbutton; screen readers read its value from aria-valuenow.
function syncValueNow(input) {
if (input.getAttribute('role') !== 'spinbutton') {
return;
}
const value = numberValue(input);
value === '' || Number.isNaN(Number(value)) ? input.removeAttribute('aria-valuenow') : input.setAttribute('aria-valuenow', value);
}
on('input', '[data-number-input]', (event, input) => {
if ('grouped' in input.dataset) {
setNumber(input, regroup(input), { format: false });
return;
}
replaceValue(input, sanitizeNumber(input.value, Number(input.dataset.decimals), 'negative' in input.dataset));
syncValueNow(input);
});
// min / max: typing isn't blocked mid-way (10 on the way to 100), so the value is pulled into range on leaving.
const clamp = (input, number) => {
const min = input.dataset.min !== undefined ? Number(input.dataset.min) : -Infinity;
const max = input.dataset.max !== undefined ? Number(input.dataset.max) : Infinity;
return Math.min(max, Math.max(min, number));
};
on('focusout', '[data-number-input]', (event, input) => {
const current = numberValue(input);
if (current === '' || current === '-' || Number.isNaN(Number(current))) {
return;
}
const kept = clamp(input, Number(current));
if (kept !== Number(current)) {
setNumber(input, String(kept));
}
});
// Stepping: the - / + buttons, or ArrowUp / ArrowDown when the field has them. 12.50 + 1 stays 13.50.
function stepNumber(input, direction) {
const decimals = Number(input.dataset.decimals);
const step = Number(input.dataset.step || 1);
const current = numberValue(input);
const base = current === '' || Number.isNaN(Number(current)) ? null : Number(current);
const next = clamp(input, base === null ? (input.dataset.min !== undefined ? Number(input.dataset.min) : 0) : base + direction * step);
const text = decimals > 0 && current.includes('.') ? next.toFixed(decimals) : String(Number(next.toFixed(decimals)));
setNumber(input, text);
}
on('click', '[data-number-step]', (event, button) => {
stepNumber(document.getElementById(button.getAttribute('aria-controls')), Number(button.dataset.numberStep));
});
on('keydown', '[data-number-input]', (event, input) => {
if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && document.querySelector(`[data-number-step][aria-controls="${input.id}"]`)) {
event.preventDefault();
stepNumber(input, event.key === 'ArrowUp' ? 1 : -1);
}
});
// Livewire: a render brings back the hidden canonical value; the display follows it, unless it's being typed in.
onLivewireMorph((scope) => scope.querySelectorAll('[data-number-input][data-grouped]').forEach((input) => {
const hidden = input.closest('[data-field]').querySelector('[data-number-value]');
if (hidden && !typingIn(input) && parseGrouped(input.value, input) !== hidden.value) {
input.value = formatGrouped(hidden.value, input);
}
}));
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());
}
}