Let a button group own its selection

Plan step 22, actions.md § Missing (Selection-required / multi-select
semantics): M3 lists single-select, multi-select and selection-required as
button-group configurations, and `<x-button-group connected>` left
selection entirely to the caller's own `aria-pressed`.

`selection="single|multi"`, with `required`, takes `aria-pressed` over:
pressing a button writes its `value` to `wire:model` or `x-model`,
deselects the others in `single`, and refuses the press that would leave
nothing selected. With no model it reads the buttons' own `aria-pressed`
once and goes on from there.

`<x-group>` and this do not absorb one another, and the header and
SKILL.md say why: `<x-group>` is for a choice whose options are data — it
renders real inputs, posts in a plain form and paints its own segments —
and stays the first thing to reach for; a selection group governs buttons
the caller writes, manages state and shape, and leaves each button to draw
its own colours from its own `:selected`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:43:34 +02:00
co-authored by Claude Opus 5
parent 5d0e9bc12f
commit 6af501c966
4 changed files with 166 additions and 2 deletions
@@ -282,6 +282,17 @@ Clusters: `<x-menu-separator />` draws M3's line, `<x-menu-group gap>` M3 Expres
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. `shape="square"` is M3's square group and covers every button in it, so do not write `shape` on each one: a connected group's ends square to the corner its inner edges take, a standard group's buttons take the square corner scale, and a selected button still rounds — M3 has the toggle morph the other way. A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. `shape="square"` is M3's square group and covers every button in it, so do not write `shape` on each one: a connected group's ends square to the corner its inner edges take, a standard group's buttons take the square corner scale, and a selected button still rounds — M3 has the toggle morph the other way.
`selection` is M3's third configuration — `single`, `multi`, and either with `required` ("selection-required"). The group then owns `aria-pressed`:
```blade
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" variant="tonal" :selected="$view === 'day'" />
<x-button label="Week" value="week" variant="tonal" :selected="$view === 'week'" />
</x-button-group>
```
Pressing a button writes its `value` (an array with `multi`) to `wire:model` or `x-model`, deselects the others in `single`, and with `required` refuses the press that would leave nothing selected; without a model it reads the buttons' own `aria-pressed` once and goes on from there. A button with no `value` is known by its label. The group manages state and shape, not colour — each button draws its selected colours from its own `:selected`, so bind both from one property as above. **Reach for `<x-group>` first**: it is the component for a choice whose options are data (real radios or checkboxes, a plain form post, the browser's keyboard, segments that paint themselves). `<x-button-group selection>` is for buttons you write yourself — icons, tooltips, mixed content — and never becomes a form control.
### `<x-group>` ### `<x-group>`
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`): A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
@@ -8,8 +8,30 @@
Standard (the default): the buttons stand apart, and pressing a label button widens it while Standard (the default): the buttons stand apart, and pressing a label button widens it while
its neighbours give way. `connected`: 2px apart with small inner corners, the shape that its neighbours give way. `connected`: 2px apart with small inner corners, the shape that
replaced M3's segmented button; a selected (aria-pressed) button rounds fully. Give `size` replaced M3's segmented button; a selected (aria-pressed) button rounds fully. Give `size`
the size of the buttons inside, so the spacing and corners match. For a choice bound to a the size of the buttons inside, so the spacing and corners match.
property, `<x-group>` draws a connected group of radios.
`selection` is M3's third button-group configuration `single`, `multi`, and either of them
with `required` ("selection-required"):
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" variant="tonal" :selected="$view === 'day'" />
<x-button label="Week" value="week" variant="tonal" :selected="$view === 'week'" />
</x-button-group>
The group owns `aria-pressed` from then on: pressing a button writes the pressed one's `value`
(an array with `multi`) to `wire:model` or `x-model`, deselects the others in `single`, and
with `required` refuses the press that would leave nothing selected. Without a model it reads
the buttons' own `aria-pressed` once and takes it from there. A button with no `value` is
known by its label.
The group manages state and shape, not colour: each `<x-button>` draws its own selected
colours from its own `:selected`, which is why the example binds both from one property. This
is where `<x-group>` and `<x-button-group selection>` part, and neither absorbs the other
`<x-group>` is for a choice whose options are *data*: it renders real radios or checkboxes
from an `options` array, so it posts in a plain form, takes the browser's own keyboard, and
paints its own segments. `<x-button-group selection>` is for buttons you write yourself —
icons, tooltips, mixed content, a `wire:click` of their own — and never becomes a form
control. Reach for `<x-group>` first.
`shape` is M3's "Default shape | Round, square" configuration, and covers every button in the `shape` is M3's "Default shape | Round, square" configuration, and covers every button in the
group so it need not be written on each one: `square` squares a connected group's two ends to group so it need not be written on each one: `square` squares a connected group's two ends to
@@ -30,11 +52,20 @@
'size' => 'sm', 'size' => 'sm',
'label' => null, 'label' => null,
'shape' => 'round', 'shape' => 'round',
'selection' => null,
'required' => false,
]) ])
@php @php
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$shape = $shape === 'square' ? 'square' : 'round'; $shape = $shape === 'square' ? 'square' : 'round';
$selection = in_array($selection, ['single', 'multi'], true) ? $selection : null;
$multiple = $selection === 'multi';
$wire = $attributes->wire('model');
$model = $selection !== null && $wire->value() !== false;
// `wire:model` is entangled into the Alpine state below, so it must not also reach the div,
// where Livewire would find no input to bind. `x-model` stays: `x-modelable` pairs with it.
$attributes = $model ? $attributes->whereDoesntStartWith('wire:model') : $attributes;
@endphp @endphp
<div <div
@@ -43,6 +74,61 @@
data-button-group="{{ $connected ? 'connected' : 'standard' }}" data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}" data-size="{{ $size }}"
data-shape="{{ $shape }}" data-shape="{{ $shape }}"
@if ($selection !== null)
data-selection="{{ $selection }}"
@if ($required) data-selection-required @endif
x-data="{
multiple: {{ $multiple ? 'true' : 'false' }},
required: {{ $required ? 'true' : 'false' }},
@if ($model) value: @entangle($wire), @else value: {{ $multiple ? '[]' : 'null' }}, @endif
init() {
this.adopt();
this.$watch('value', () => this.paint());
},
segments() {
return [...this.$el.children].filter((child) => child.matches('button, a'));
},
name(segment) {
return segment.getAttribute('value') ?? segment.textContent.trim();
},
chosen() {
return (this.multiple ? this.value ?? [] : [this.value]).filter((each) => each !== null && each !== undefined && each !== '');
},
adopt() {
const carried = this.multiple ? (this.value ?? []).length > 0 : this.value !== null && this.value !== undefined;
if (! carried) {
const pressed = this.segments().filter((segment) => segment.getAttribute('aria-pressed') === 'true').map((segment) => this.name(segment));
this.value = this.multiple ? pressed : (pressed[0] ?? null);
}
this.paint();
},
press(event) {
const segment = event.target.closest('button, a');
if (! segment || ! this.segments().includes(segment) || segment.disabled || segment.getAttribute('aria-disabled') === 'true') return;
const name = this.name(segment);
const chosen = this.chosen();
const on = chosen.includes(name);
if (on && this.required && (! this.multiple || chosen.length === 1)) return;
this.value = this.multiple
? (on ? chosen.filter((each) => each !== name) : [...chosen, name])
: (on ? null : name);
},
paint() {
const chosen = this.chosen();
this.segments().forEach((segment) => segment.setAttribute('aria-pressed', String(chosen.includes(this.name(segment)))));
},
}"
x-on:click="press($event)"
@unless ($model) x-modelable="value" @endunless
@endif
{{ $attributes->class([ {{ $attributes->class([
'inline-flex items-center', 'inline-flex items-center',
'gap-0.5' => $connected, 'gap-0.5' => $connected,
@@ -76,6 +76,26 @@
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" /> <x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group> </x-button-group>
BLADE, BLADE,
'A connected group that owns its selection' => <<<'BLADE'
<div x-data="{ view: 'week', marks: ['bold'] }" class="w-full space-y-6">
<x-button-group connected selection="single" required label="View" x-model="view">
<x-button label="Day" value="day" variant="tonal" :selected="false" />
<x-button label="Week" value="week" variant="tonal" :selected="true" />
<x-button label="Month" value="month" variant="tonal" :selected="false" />
</x-button-group>
<x-button-group connected selection="multi" label="Formatting" x-model="marks">
<x-button icon="format_bold" aria-label="Bold" value="bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" value="italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" value="underline" variant="tonal" :selected="false" />
</x-button-group>
<p class="type-body-md text-on-surface-variant">
View: <code x-text="view"></code> · marks: <code x-text="marks.join(', ') || 'none'"></code>.
The shape follows at once; the colours come from each button's own <code>:selected</code>, which a Livewire render brings back.
</p>
</div>
BLADE,
'Square groups (hold a button down)' => <<<'BLADE' 'Square groups (hold a button down)' => <<<'BLADE'
<x-button-group label="Square standard" size="md" shape="square"> <x-button-group label="Square standard" size="md" shape="square">
<x-button label="Day" variant="tonal" size="md" /> <x-button label="Day" variant="tonal" size="md" />
@@ -36,6 +36,53 @@ it('squares a group, and a choice drawn as one', function () {
->toContain('data-shape="round"'); ->toContain('data-shape="round"');
}); });
it('takes over aria-pressed for a group that carries a selection', function () {
$html = (string) $this->blade('<x-button-group connected selection="single" required label="View"><x-button label="Day" value="day" :selected="true" /></x-button-group>');
expect($html)
->toContain('data-selection="single"')
->toContain('data-selection-required')
->toContain('multiple: false')
->toContain('required: true')
->toContain('value: null')
->toContain('x-on:click="press($event)"')
// No model: the group takes the state from the buttons' own aria-pressed and goes on.
->toContain('x-modelable="value"')
->and((string) $this->blade('<x-button-group connected selection="multi"><x-button label="Bold" value="bold" :selected="false" /></x-button-group>'))
->toContain('multiple: true')
->toContain('required: false')
->toContain('value: []')
->and((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
->not->toContain('data-selection')
->not->toContain('press($event)');
});
it('entangles a selection group with the property it binds, and keeps wire:model off the div', function () {
$component = new class extends Component
{
public string $view = 'week';
public function render(): string
{
return <<<'BLADE'
<div>
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" :selected="$view === 'day'" />
<x-button label="Week" value="week" :selected="$view === 'week'" />
</x-button-group>
</div>
BLADE;
}
};
$html = Livewire::test($component)->assertSet('view', 'week')->html();
expect($html)
->toContain('data-selection="single"')
->toMatch('/value: window\.Livewire\.find\(/')
->and(substr_count($html, 'wire:model.live="view"'))->toBe(0);
});
it('never wraps a group onto a second line', function () { it('never wraps a group onto a second line', function () {
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>')) expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->not->toContain('flex-wrap'); ->not->toContain('flex-wrap');