Choice
Pick-one and pick-many controls built on native radios and checkboxes — a card, a full-width row, a segmented tab, a chip or a colour swatch.
Choicebox — The option card: a label around a real radio or checkbox (native control, keyboard, focus ring, invalid state, plain form submission) that styles itself off :has(:checked), with the control at the end, the start (a row) or hidden, sm/md sizes, an icon slot and a badge; choicebox.group renders a set from an options array. Supersedes radio-rows.
Preview
{{-- Radio cards in a group: the group shares `name` (and type, size, indicator)
down, so a card only states its own value. The native radio sits at the end. --}}
<x-ui.choicebox.group name="plan" label="{{ __('Plan') }}" class="w-full max-w-md">
<x-ui.choicebox value="pro" checked :title="__('Pro')" :description="__('Everything in Starter plus advanced analytics and priority support.')" :badge="__('Popular')" />
<x-ui.choicebox value="starter" :title="__('Starter')" :description="__('For individuals getting started, up to 3 projects.')" />
<x-ui.choicebox value="enterprise" disabled :title="__('Enterprise')" :description="__('Custom limits, SSO and a dedicated account manager.')" />
</x-ui.choicebox.group>
Size options
Indicator options
Tone options
Installation
php artisan ui:add choicebox
Registry contract
php artisan ui:add choicebox
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/choicebox.blade.php -
resources/views/components/ui/choicebox/group.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Choicebox (`choicebox`)
The option card: a label around a real radio or checkbox (native control, keyboard, focus ring, invalid state, plain form submission) that styles itself off :has(:checked), with the control at the end, the start (a row) or hidden, sm/md sizes, an icon slot and a badge; choicebox.group renders a set from an options array. Supersedes radio-rows.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add choicebox
```
## Usage
```blade
{{-- Radio cards in a group: the group shares `name` (and type, size, indicator)
down, so a card only states its own value. The native radio sits at the end. --}}
<x-ui.choicebox.group name="plan" label="{{ __('Plan') }}" class="w-full max-w-md">
<x-ui.choicebox value="pro" checked :title="__('Pro')" :description="__('Everything in Starter plus advanced analytics and priority support.')" :badge="__('Popular')" />
<x-ui.choicebox value="starter" :title="__('Starter')" :description="__('For individuals getting started, up to 3 projects.')" />
<x-ui.choicebox value="enterprise" disabled :title="__('Enterprise')" :description="__('Custom limits, SSO and a dedicated account manager.')" />
</x-ui.choicebox.group>
```
## Props
- `type` (string, default `radio`) — Underlying control: radio for single-select, checkbox for multi-select.
- `name` (mixed|null, default `null`) — Field name; shared from a wrapping group or field via @aware, or set per card.
- `value` (mixed|null, default `null`) — Submitted value for this card's control.
- `title` (mixed|null, default `null`) — Card headline shown beside the badge.
- `description` (mixed|null, default `null`) — Supporting text shown under the title.
- `checked` (bool, default `false`) — Initial checked state; overridden by old() input when a matching field name is flashed.
- `disabled` (bool, default `false`) — Disables the control and dims the card.
- `disabledReason` (string|null, default `null`) — Why a disabled card cannot be picked. Shown under the description in full foreground (still readable on the dimmed card) and tied to the control with aria-describedby. Ignored while the card is enabled; choicebox.group takes it per option as disabledReason.
- `badge` (mixed|null, default `null`) — Optional badge rendered beside the title.
- `size` (md|sm, default `md`) — md is a surface-sized card (rounded-lg, p-6); sm is control-sized (rounded-md, p-4).
- `indicator` (string, default `end`) — Where the native control sits: end (classic card), start (a row), or none (card border alone shows selection).
- `tone` (neutral|primary, default `neutral`) — Colour of the checked card: neutral uses a foreground border and ring on a faint neutral tint, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand border, ring and tint. The card's radio or checkbox and its icon chip follow the tone; choicebox.group shares it with every card.
- `options` (array, default `[]`) — Declared by @props in the registry Blade source.
- `label` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Presenting a small set of options as tappable cards with a title, description, icon or badge.
- Letting the whole card area, not just a small control, respond to selection.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Listing more than a handful of plain-text options; use radio or checkbox directly.
- Building a dense inline row of pill-style choices; use radio-tabs.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:choicebox>` tag (or `<x-ui.choicebox>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/choicebox
- Registry JSON (files, props, contract): https://brokui.dev/r/open/choicebox.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Borderless
{{-- `indicator="none"`: the card border alone shows the selection. --}}
<x-ui.choicebox.group name="theme" value="system" indicator="none" class="grid w-full max-w-md grid-cols-3 gap-2" :options="[
['value' => 'light', 'title' => __('Light')],
['value' => 'dark', 'title' => __('Dark')],
['value' => 'system', 'title' => __('System')],
]" />
{{-- `type="checkbox"`: several cards can be on and each submits as `addons[]`.
Cards composed by hand (here with an icon slot) mark their own `checked`;
the `options` array derives it from the group's `value`. --}}
<x-ui.choicebox.group name="addons" type="checkbox" label="Add-ons" class="w-full max-w-md">
<x-ui.choicebox value="backups" checked :title="__('Daily backups')" :description="__('Thirty days of point-in-time restore.')" badge="$4/mo">
<x-slot:icon>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" /></svg>
</x-slot:icon>
</x-ui.choicebox>
<x-ui.choicebox value="cdn" :title="__('Global CDN')" :description="__('Edge caching in 30 regions.')" badge="$9/mo">
<x-slot:icon>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
</x-slot:icon>
</x-ui.choicebox>
</x-ui.choicebox.group>
Disabled Reason
{{-- A template picker: an option that cannot be picked says why. The reason is
read with the radio through aria-describedby. --}}
<x-ui.choicebox.group name="template" label="{{ __('Template') }}" size="sm" indicator="start" class="w-full max-w-md">
<x-ui.choicebox value="blank" checked :title="__('Blank page')" :description="__('Start from an empty page.')" />
<x-ui.choicebox value="project-brief" :title="__('Project brief')" :description="__('Goal, scope, deliverables and the people involved.')" />
<x-ui.choicebox value="meeting-note" disabled :title="__('Meeting note')" :description="__('Attendees, agenda, decisions and follow-ups.')" :disabled-reason="__('Only in a client workspace. This page is in the internal workspace.')" />
</x-ui.choicebox.group>
{{-- `indicator="start"` and `size="sm"`: control-sized rows for a settings list. --}}
<x-ui.choicebox.group name="visibility" value="team" indicator="start" size="sm" class="w-full max-w-md" :options="[
['value' => 'private', 'title' => __('Private'), 'description' => __('Only you can see this project.')],
['value' => 'team', 'title' => __('Team'), 'description' => __('Everyone in your workspace can view it.')],
['value' => 'public', 'title' => __('Public'), 'description' => __('Anyone with the link can view it.')],
]" />
{{-- tone: the checked card is neutral by default (a foreground border and ring
on a faint tint), so the brand colour stays free for the primary action.
tone="primary" on the group (or a card) brings the brand border back. --}}
<div class="flex w-full max-w-md flex-col gap-6">
<x-ui.choicebox.group name="billing" label="{{ __('Billing interval (neutral, the default)') }}" size="sm">
<x-ui.choicebox value="monthly" checked :title="__('Monthly')" :description="__('Pay each month, cancel any time.')" />
<x-ui.choicebox value="yearly" :title="__('Yearly')" :description="__('Two months free, billed once a year.')" />
</x-ui.choicebox.group>
<x-ui.choicebox.group name="billing-primary" label="{{ __('Billing interval (primary)') }}" size="sm" tone="primary">
<x-ui.choicebox value="monthly" checked :title="__('Monthly')" :description="__('Pay each month, cancel any time.')" />
<x-ui.choicebox value="yearly" :title="__('Yearly')" :description="__('Two months free, billed once a year.')" />
</x-ui.choicebox.group>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| type | string | radio | Underlying control: radio for single-select, checkbox for multi-select. |
| name | mixed | null | null | Field name; shared from a wrapping group or field via @aware, or set per card. |
| value | mixed | null | null | Submitted value for this card's control. |
| title | mixed | null | null | Card headline shown beside the badge. |
| description | mixed | null | null | Supporting text shown under the title. |
| checked | bool | false | Initial checked state; overridden by old() input when a matching field name is flashed. |
| disabled | bool | false | Disables the control and dims the card. |
| disabledReason | string | null | null | Why a disabled card cannot be picked. Shown under the description in full foreground (still readable on the dimmed card) and tied to the control with aria-describedby. Ignored while the card is enabled; choicebox.group takes it per option as disabledReason. |
| badge | mixed | null | null | Optional badge rendered beside the title. |
| size | md | sm | md | md is a surface-sized card (rounded-lg, p-6); sm is control-sized (rounded-md, p-4). |
| indicator | string | end | Where the native control sits: end (classic card), start (a row), or none (card border alone shows selection). |
| tone | neutral | primary | neutral | Colour of the checked card: neutral uses a foreground border and ring on a faint neutral tint, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand border, ring and tint. The card's radio or checkbox and its icon chip follow the tone; choicebox.group shares it with every card. |
| options | array | [] | Declared by @props in the registry Blade source. |
| label | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default— Extra content rendered under the description.icon— Leading icon shown before the title and description.x-ui.choicebox.group— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The whole card is a label wrapping a real radio or checkbox, so it submits in a plain form with no JavaScript and the native control carries keyboard, focus ring, and invalid state.
- The card styles its border, ring, and background off :has(:checked), so a pick reads as one change without extra JS.
- tone="neutral" (the default) draws the checked card in the foreground colour; tone="primary" uses the brand primary. The inner radio or checkbox gets the same tone, and data-tone on the card reports it.
- Selection is restored from old() input after a failed redirect-back when the card shares a field name.
- A disabled card with disabled-reason renders the reason (data-slot=choicebox-reason) under the description and adds its id to the control's aria-describedby, after any error id.
- wire:model or x-model on choicebox.group binds the group value (one value for radio, an array for type="checkbox") through x-modelable; every Livewire modifier works, .blur fires when focus leaves the group, and a server-side change or reset re-checks the cards after a Livewire update. An unbound group renders no JavaScript.
- wire:model or x-model on a single card reaches the card's native radio or checkbox, not the label.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Choose one option from a small visible set.
Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Presenting a small set of options as tappable cards with a title, description, icon or badge.
- Letting the whole card area, not just a small control, respond to selection.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Listing more than a handful of plain-text options; use radio or checkbox directly.
- Building a dense inline row of pill-style choices; use radio-tabs.
Use instead
- Select or combobox for long option sets
Anti-patterns
- Hiding a small comparable set in a dropdown
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
managed
- Each card renders a real radio or checkbox input, so screen readers announce role, label, and checked state exactly as a native control would.
- When indicator is none the control is visually hidden but stays in the accessibility tree and keyboard tab order.
- Give a disabled option a disabled-reason instead of replacing its description: a screen reader hears the option, that it is unavailable, and why.
- In a named group every control gets its own id from the field name and the value (plan-pro), so ids never repeat; a value that is not id-safe adds a short hash.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="choicebox-{{ $record->id }}">
{{-- Radio cards in a group: the group shares `name` (and type, size, indicator)
down, so a card only states its own value. The native radio sits at the end. --}}
<x-ui.choicebox.group name="plan" label="{{ __('Plan') }}" class="w-full max-w-md">
<x-ui.choicebox value="pro" checked :title="__('Pro')" :description="__('Everything in Starter plus advanced analytics and priority support.')" :badge="__('Popular')" />
<x-ui.choicebox value="starter" :title="__('Starter')" :description="__('For individuals getting started, up to 3 projects.')" />
<x-ui.choicebox value="enterprise" disabled :title="__('Enterprise')" :description="__('Custom limits, SSO and a dedicated account manager.')" />
</x-ui.choicebox.group>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:choicebox
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <x-ui.choicebox.group name="…"> or <x-ui.field name="…"> shares
its name (and type, size, indicator) down via @aware, so a card never repeats
them. Inert with no ancestor and no own `name` — fully backward-compatible. --}}
@aware([
'name' => null,
'type' => 'radio',
'size' => 'md',
'indicator' => 'end',
'tone' => 'neutral',
])
@props([
'type' => 'radio',
'name' => null,
'value' => null,
'title' => null,
'description' => null,
'checked' => false,
'disabled' => false,
// Why a disabled option cannot be picked ("Needs a client first"). Shown
// under the description and read with the control through
// aria-describedby. Ignored while the card is enabled.
'disabledReason' => null,
'badge' => null,
// md: surface-sized card (rounded-lg, p-6); sm: control-sized (rounded-md, p-4).
'size' => 'md',
// Where the native control sits: end (the classic card), start (a row), none.
'indicator' => 'end',
// Colour of the checked card. neutral (default): a foreground border and
// ring on a faint neutral tint, so a product that keeps primary for its one
// main action and for focus is not diluted. primary: the brand border,
// ring and tint. The card's radio or checkbox follows the same tone.
'tone' => 'neutral',
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$type = $type === 'checkbox' ? 'checkbox' : 'radio';
$size = in_array($size, ['sm', 'md'], true) ? $size : 'md';
$indicator = in_array($indicator, ['start', 'end', 'none'], true) ? $indicator : 'end';
// Full class strings per tone so Tailwind's JIT can see every utility: the
// checked card (border, ring, tint) and the checked icon chip.
$tones = [
'neutral' => [
'card' => 'has-[:checked]:border-foreground has-[:checked]:bg-foreground/[0.03] has-[:checked]:ring-1 has-[:checked]:ring-foreground',
'icon' => 'group-has-[:checked]:bg-foreground group-has-[:checked]:text-background',
],
'primary' => [
'card' => 'has-[:checked]:border-primary has-[:checked]:bg-primary/[0.03] has-[:checked]:ring-1 has-[:checked]:ring-primary',
'icon' => 'group-has-[:checked]:bg-primary group-has-[:checked]:text-primary-foreground',
],
];
$tone = isset($tones[$tone]) ? $tone : 'neutral';
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// Restore selection from old() after a failed redirect-back. A radio card is
// checked when its value equals the flashed scalar; a checkbox card when its
// value is in the flashed array. Falls back to the explicit `checked` prop.
$isChecked = $checked;
if (filled($fieldName) && $value !== null && app('session')->hasOldInput($fieldName)) {
$old = old($fieldName);
$isChecked = $type === 'checkbox'
? in_array((string) $value, array_map('strval', (array) $old), true)
: (string) $old === (string) $value;
}
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
// A stable id from the card's own data, so a render is deterministic.
$reasonId = ($disabled && filled($disabledReason))
? 'choicebox-reason-'.substr(md5($fieldName.'|'.$value.'|'.$disabledReason), 0, 10)
: null;
$describedBy = trim(($describedBy ?? '').' '.($reasonId ?? '')) ?: null;
$controlName = filled($fieldName) ? ($type === 'checkbox' ? $fieldName.'[]' : $fieldName) : null;
// One id per option, from the field name and the value: a named group no
// longer repeats the field name as every control's id. A value that is not
// id-safe keeps a readable slug plus a short hash, so ids stay unique.
$valueKey = (string) $value;
$idSuffix = preg_match('/^[A-Za-z0-9_-]+$/', $valueKey) === 1
? $valueKey
: trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $valueKey), '-').'-'.substr(md5($valueKey), 0, 6);
$controlId = filled($fieldName) ? $fieldName.'-'.ltrim($idSuffix, '-') : null;
// wire:model / x-model on the card bind its native control, not the label.
$modelAttributes = $attributes->whereStartsWith(['wire:model', 'x-model'])->getAttributes();
// Border, ring, background and the control move on one 150ms curve so a pick
// reads as one change; hover and press make the card feel pressable.
$shape = $size === 'sm' ? 'rounded-md p-4' : 'rounded-lg p-6';
$card = 'group relative flex items-start gap-3 border border-border bg-card text-start '.$shape.' '
.'transition-[border-color,box-shadow,background-color,scale] duration-150 ease-out motion-reduce:transition-none '
.$tones[$tone]['card'].' '
.($disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:border-input hover:bg-muted/40 active:scale-[0.995]');
@endphp
{{--
Choicebox — the option card. The whole card is a <label> around a real
<x-ui.radio> or <x-ui.checkbox>, so it submits in a plain <form> with no
JavaScript, the native control carries keyboard, focus ring and invalid
state, and the card styles itself off :has(:checked). `indicator` puts
the control at the end (card), the start (row), or hides it (`none`, the
card border alone shows selection). Optional leading `icon` slot and a
trailing `badge`. Group cards with <x-ui.choicebox.group>.
--}}
<label
data-slot="{{ $attributes->get('data-slot', 'choicebox') }}"
{{ $attributes->except(['name', 'data-slot', ...array_keys($modelAttributes)])->merge(['class' => $card]) }}
data-type="{{ $type }}"
data-size="{{ $size }}"
data-indicator="{{ $indicator }}"
data-tone="{{ $tone }}"
@if ($hasBagError) data-invalid="true" @endif
>
@php
$controlAttributes = [
'id' => $controlId,
'name' => $controlName,
'value' => $value,
'checked' => $isChecked,
'disabled' => $disabled,
'tone' => $tone,
'aria-describedby' => $describedBy,
'class' => $indicator === 'none' ? 'sr-only' : 'mt-0.5',
// A bound choicebox.group finds and syncs its cards' controls by this marker.
'data-choicebox-control' => true,
] + $modelAttributes;
@endphp
@if ($indicator !== 'end')
@if ($type === 'checkbox')
<x-ui.checkbox :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
@else
<x-ui.radio :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
@endif
@endif
@if (isset($icon))
<span class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors duration-150 ease-out motion-reduce:transition-none {{ $tones[$tone]['icon'] }}" aria-hidden="true">
{{ $icon }}
</span>
@endif
<span class="flex min-w-0 flex-1 flex-col gap-1">
@if ($title || $badge)
<span class="flex items-center gap-2">
@if ($title)
<span class="text-sm font-medium leading-snug text-foreground">{{ $title }}</span>
@endif
@if ($badge)
<x-ui.badge variant="secondary" size="sm">{{ $badge }}</x-ui.badge>
@endif
</span>
@endif
@if ($description)
<span class="text-sm leading-normal text-muted-foreground">{{ $description }}</span>
@endif
@if ($reasonId)
{{-- Full foreground: the dimmed card leaves it at the muted tone and
still above 4.5:1, where muted text would drop below it. --}}
<span id="{{ $reasonId }}" data-slot="choicebox-reason" class="text-sm leading-normal text-foreground">{{ $disabledReason }}</span>
@endif
{{ $slot }}
</span>
@if ($indicator === 'end')
@if ($type === 'checkbox')
<x-ui.checkbox :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
@else
<x-ui.radio :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
@endif
@endif
</label>
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware; the group
adopts it, forwards name/type/size/indicator/tone to every card (each reads them
via @aware) and self-wires aria-invalid/aria-describedby off the shared
$errors bag. --}}
@aware([
'name' => null,
])
@props([
'name' => null,
// Radio groups take one value; checkbox groups an array of values.
'value' => null,
'type' => 'radio',
'size' => 'md',
'indicator' => 'end',
// Checked colour of every card (neutral | primary), read by each card via @aware.
'tone' => 'neutral',
// Declarative cards: [['value', 'title', 'description', 'badge', 'disabled', 'disabledReason']];
// the card matching `value` is checked. Hand-composed cards mark `checked`
// themselves (a parent's computed state cannot reach a child through @aware).
'options' => [],
'label' => null,
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$type = $type === 'checkbox' ? 'checkbox' : 'radio';
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// Restore selection from old() after a failed redirect-back; fall back to
// the passed `value` when there is no flashed input.
$resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;
$selected = array_map('strval', $type === 'checkbox' ? (array) $resolvedValue : ($resolvedValue === null ? [] : [$resolvedValue]));
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
// wire:model / x-model on the group bind its value (a scalar for radio, an
// array for checkbox). The root becomes modelable and keeps every card's
// native control checked from the bound value, so a server-side change or
// reset shows after a Livewire update. Without a binding the group stays
// plain HTML with no JavaScript.
$bound = $attributes->whereStartsWith(['wire:model', 'x-model'])->isNotEmpty();
$initialChoice = $type === 'checkbox' ? $selected : ($selected[0] ?? null);
@endphp
<div
role="{{ $type === 'checkbox' ? 'group' : 'radiogroup' }}"
data-slot="{{ $attributes->get('data-slot', 'choicebox-group') }}"
data-type="{{ $type }}"
@if ($label) aria-label="{{ __($label) }}" @endif
@if ($hasBagError) data-invalid="true" aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
@if ($bound)
x-data="{ choice: @js($initialChoice) }"
x-modelable="choice"
@if ($type === 'checkbox')
x-effect="const picked = (Array.isArray(choice) ? choice : []).map(String); $el.querySelectorAll('[data-choicebox-control]').forEach((control) => { control.checked = picked.includes(control.value) })"
x-on:input="if ($event.target.hasAttribute('data-choicebox-control')) { choice = Array.from($el.querySelectorAll('[data-choicebox-control]:checked'), (control) => control.value) }"
@else
x-effect="const picked = choice === null || choice === undefined ? null : String(choice); $el.querySelectorAll('[data-choicebox-control]').forEach((control) => { control.checked = control.value === picked })"
x-on:input="if ($event.target.hasAttribute('data-choicebox-control')) { choice = $event.target.value }"
@endif
{{-- .blur: blur does not bubble, so the group reports one when focus leaves it.
Safari does not focus a radio or checkbox on click, so focus never
entered the group and a mouse pick was never sent; focus it here. --}}
x-on:change="if ($event.target.hasAttribute('data-choicebox-control') && document.activeElement !== $event.target) { $event.target.focus({ preventScroll: true }) }"
x-on:focusout="if (! $el.contains($event.relatedTarget)) { $el.dispatchEvent(new FocusEvent('blur')) }"
@endif
{{ $attributes->except(['name', 'data-slot'])->merge(['class' => 'flex flex-col gap-2']) }}
>
@if (! empty($options))
@foreach ($options as $option)
<x-ui.choicebox
:value="$option['value']"
:title="$option['title'] ?? null"
:description="$option['description'] ?? null"
:badge="$option['badge'] ?? null"
:disabled="$option['disabled'] ?? false"
:disabled-reason="$option['disabledReason'] ?? null"
:checked="in_array((string) $option['value'], $selected, true)"
/>
@endforeach
@else
{{ $slot }}
@endif
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.4 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0