From 6af501c9667ea98d622581eebc0f8999637bdfe5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:43:34 +0200 Subject: [PATCH] Let a button group own its selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. `` and this do not absorb one another, and the header and SKILL.md say why: `` 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 11 +++ .../views/components/button-group.blade.php | 90 ++++++++++++++++++- .../views/showcase/sections/buttons.blade.php | 20 +++++ tests/Feature/Components/ButtonGroupTest.php | 47 ++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 359d50a8..da11b30c 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -282,6 +282,17 @@ Clusters: `` draws M3's line, `` M3 Expres A row of ``s: ``. `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 + + + + +``` + +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 `` 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). `` is for buttons you write yourself — icons, tooltips, mixed content — and never becomes a form control. + ### `` A choice between a few options as a connected button group of native radios (checkboxes with `multiple`): diff --git a/resources/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php index f8935067..3f5b5a4e 100644 --- a/resources/views/components/button-group.blade.php +++ b/resources/views/components/button-group.blade.php @@ -8,8 +8,30 @@ 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 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 - property, `` draws a connected group of radios. + the size of the buttons inside, so the spacing and corners match. + + `selection` is M3's third button-group configuration — `single`, `multi`, and either of them + with `required` ("selection-required"): + + + + + + + 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 `` draws its own selected + colours from its own `:selected`, which is why the example binds both from one property. This + is where `` and `` part, and neither absorbs the other — + `` 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. `` is for buttons you write yourself — + icons, tooltips, mixed content, a `wire:click` of their own — and never becomes a form + control. Reach for `` first. `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 @@ -30,11 +52,20 @@ 'size' => 'sm', 'label' => null, 'shape' => 'round', + 'selection' => null, + 'required' => false, ]) @php $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $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
class([ 'inline-flex items-center', 'gap-0.5' => $connected, diff --git a/resources/views/showcase/sections/buttons.blade.php b/resources/views/showcase/sections/buttons.blade.php index 69bd916b..7bf4248a 100644 --- a/resources/views/showcase/sections/buttons.blade.php +++ b/resources/views/showcase/sections/buttons.blade.php @@ -76,6 +76,26 @@ BLADE, + 'A connected group that owns its selection' => <<<'BLADE' +
+ + + + + + + + + + + + +

+ View: · marks: . + The shape follows at once; the colours come from each button's own :selected, which a Livewire render brings back. +

+
+ BLADE, 'Square groups (hold a button down)' => <<<'BLADE' diff --git a/tests/Feature/Components/ButtonGroupTest.php b/tests/Feature/Components/ButtonGroupTest.php index d5eb2896..484f152f 100644 --- a/tests/Feature/Components/ButtonGroupTest.php +++ b/tests/Feature/Components/ButtonGroupTest.php @@ -36,6 +36,53 @@ it('squares a group, and a choice drawn as one', function () { ->toContain('data-shape="round"'); }); +it('takes over aria-pressed for a group that carries a selection', function () { + $html = (string) $this->blade(''); + + 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('')) + ->toContain('multiple: true') + ->toContain('required: false') + ->toContain('value: []') + ->and((string) $this->blade('')) + ->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' +
+ + + + +
+ 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 () { expect((string) $this->blade('')) ->not->toContain('flex-wrap');