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
142 lines
6.8 KiB
PHP
142 lines
6.8 KiB
PHP
{{-- An M3 Expressive button group: related `<x-button>`s in a row.
|
|
|
|
<x-button-group size="md" label="Text formatting">
|
|
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="$bold" wire:click="toggleBold" />
|
|
…
|
|
</x-button-group>
|
|
|
|
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.
|
|
|
|
`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
|
|
group so it need not be written on each one: `square` squares a connected group's two ends to
|
|
the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size) and gives a
|
|
standard group's buttons the square corner scale `<x-button shape="square">` draws. Selection
|
|
still morphs the other way — M3 has a toggle inside a group "swap shape square/round on
|
|
selection" — so a selected button in a square group rounds.
|
|
|
|
A group never wraps to a second line — M3's rule, and the press expansion only reaches a
|
|
neighbour on the same line anyway. Where a row is too long for its window the answer is a
|
|
smaller `size` or fewer buttons, or two groups stacked.
|
|
|
|
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
|
|
shapes are resources/css/components/groups.css. --}}
|
|
|
|
@props([
|
|
'connected' => false,
|
|
'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
|
|
|
|
<div
|
|
role="group"
|
|
@if ($label) aria-label="{{ $label }}" @endif
|
|
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
|
|
data-size="{{ $size }}"
|
|
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([
|
|
'inline-flex items-center',
|
|
'gap-0.5' => $connected,
|
|
'gap-[18px]' => ! $connected && $size === 'xs',
|
|
'gap-3' => ! $connected && $size === 'sm',
|
|
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
|
]) }}
|
|
>
|
|
{{ $slot }}
|
|
</div>
|