<x-widget.radio>
Radio
A group of radio buttons in a fieldset, with descriptions and disabled options. Works with Livewire wire:model.
php artisan larawell:add radio
Usage
Livewire
In a Livewire component, bind the group with wire:model (deferred) or wire:model.live: public string $plan = 'free'. No name is needed. The chosen option follows the property after every render, so setting it in PHP changes the choice. The values are strings in the HTML: bind an enum property (public Plan $plan) and Livewire casts it back.
<x-widget.radio
label="Plan"
:options="['free' => 'Free', 'pro' => 'Pro', 'team' => 'Team']"
wire:model.live="plan"
/>
Examples
Radio
Options take the same shapes as the select, plus an optional description. inline lays them out in a row.
Show code Hide code
<div class="grid gap-8 sm:grid-cols-2">
<x-widget.radio name="plan" label="Plan" value="pro" required :options="[
['id' => 'starter', 'name' => 'Starter', 'description' => 'Up to 3 wallets'],
['id' => 'pro', 'name' => 'Pro', 'description' => 'Unlimited wallets and exports'],
['id' => 'team', 'name' => 'Team', 'description' => 'Coming soon', 'disabled' => true],
]" />
<x-widget.radio name="frequency" label="Statement" inline :options="['monthly' => 'Monthly', 'quarterly' => 'Quarterly', 'yearly' => 'Yearly']" value="monthly" />
</div>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.radio>
| 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
|
The group's legend, read before each option. |
| options |
[]
|
A list, a value => label map, enum cases, or rows (value or id, label or name) with an optional description and disabled. |
| value |
null
|
The chosen value. Old input wins after a failed submit; with wire:model and no value, the bound property. |
| 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. |
| inline |
false
|
Side by side instead of stacked. |
Source
What larawell:add radio 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/radio/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,
// The group's legend, read before each option.
'label' => null,
// A list, a value => label map, enum cases, or rows (value or id, label or name) with an optional description
// and disabled.
'options' => [],
// The chosen value. Old input wins after a failed submit; with wire:model and no value, the bound property.
'value' => 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,
// Side by side instead of stacked.
'inline' => false,
])
@php
$field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'radio', attributes: $attributes);
// Values cast to backed enums on the model (Plan::Pro) compare and submit as their backing value.
$text = static fn (mixed $v): string => (string) ($v instanceof \BackedEnum ? $v->value : $v);
$selected = $field->old($value);
$selected = $selected === null ? null : $text($selected);
// Same option shapes as the select: a list (['Card', 'Bank'], or Plan::cases()), a key => label map, or
// rows with id / name, plus an optional description and disabled.
$items = collect($options)->map(function (mixed $option, int|string $key) use ($options, $text): array {
if (is_array($option)) {
return [
'value' => $text($option['id'] ?? $option['value'] ?? $key),
'label' => (string) ($option['name'] ?? $option['label'] ?? ''),
'description' => $option['description'] ?? null,
'disabled' => (bool) ($option['disabled'] ?? false),
];
}
if ($option instanceof \BackedEnum) {
return ['value' => $text($option), 'label' => $option->name, 'description' => null, 'disabled' => false];
}
return ['value' => array_is_list($options) ? (string) $option : (string) $key, 'label' => (string) $option, 'description' => null, 'disabled' => false];
})->values();
@endphp
{{-- A fieldset with a legend, so screen readers announce the question along with each choice. --}}
<x-widget.field :id="$field->id" :error="$field->errors" :info="$info" :disabled="$disabled" :required="$attributes->has('required')" bare :class="$attributes->get('class')">
<fieldset id="{{ $field->id }}" @disabled($disabled)>
@if ($label)
<legend @class(['text-style-2 mb-[11.5px] block', 'text-muted' => $disabled, 'text-foreground' => ! $disabled])>
{{ $label }}
@if ($attributes->has('required'))
<span class="text-error" aria-hidden="true">*</span>
@endif
</legend>
@endif
<div @class(['flex gap-x-6 gap-y-3', 'flex-wrap' => $inline, 'flex-col' => ! $inline])>
@foreach ($items as $i => $item)
@php($optionId = "{$field->id}-{$i}")
<label for="{{ $optionId }}" @class(['flex items-start gap-3', 'cursor-pointer' => ! ($disabled || $item['disabled']), 'cursor-not-allowed opacity-60' => $disabled || $item['disabled']])>
<input
type="radio"
id="{{ $optionId }}"
@if ($name) name="{{ $name }}" @endif
value="{{ $item['value'] }}"
@checked($selected === $item['value'])
@disabled($item['disabled'])
{{ $field->controlAttributes($attributes, (bool) $info)->class([
// border-muted: an unchecked radio's ring must reach 3:1 against the page to be seen at all.
'border-muted bg-surface mt-0.5 size-5 shrink-0 appearance-none rounded-full border transition-all outline-none',
// A thick border in the brand colour reads as the filled dot.
'checked:border-primary checked:border-[6px] focus-visible:ring-primary focus-visible:ring-2 focus-visible:ring-offset-2',
'group-data-invalid/field:border-error disabled:cursor-not-allowed',
]) }}
>
<span class="min-w-0">
<span class="text-foreground">{{ $item['label'] }}</span>
@if ($item['description'])
<span class="text-foreground/60 mt-0.5 block text-xs">{{ $item['description'] }}</span>
@endif
</span>
</label>
@endforeach
</div>
</fieldset>
</x-widget.field>
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());
}
}