Draw the connected choice group without Tailwind

Plan step 36. <x-group> renders data-md-group with its size, variant,
shape, multiple and inline, a data-md-button-group row and
data-md-group-segment labels over its radios or checkboxes. group.css
draws the segments as M3's buttons with the toggle button's colours
from :has(:checked), the disabled treatment from :has(:disabled), the
focus ring from :has(:focus-visible) and the 48px target at xs and sm.

The connected shapes move into button-group.css (the corner scale,
square ends, the pressed and selected inner corners), which group.css
imports; <x-button-group> adopts them in its own rewrite. The group's
render tests move to GroupTest; AppBarTest (navigation group) follows
the new legend and row hooks of theme-toggle's rows.

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 14:56:59 +02:00
co-authored by Claude Opus 5
parent 4ad257034e
commit b8c6d411db
8 changed files with 504 additions and 137 deletions
+26 -47
View File
@@ -11,9 +11,8 @@
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
`inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a
segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells
you never to reduce. Corners move on the spatial spring and colours on the effects one
(`state-transition-fast`).
segment keeps a 48px target and a 48px minimum width, which M3 asks for by name and tells
you never to reduce. Corners move on the spatial spring and colours on the effects one.
`shape` is M3's "Default shape | Round, square" configuration: `square` squares the two ends
of the group to the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size),
@@ -25,9 +24,11 @@
the hint.
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
specificity, as the field's does in the components layer, because which of two colour
utilities wins depends on the order Tailwind emits them. --}}
(`hint-class="md-ink-warning"` for a hint that warns), because a caller's class outranks the
package's layer.
Drawn by resources/css/components/group.css, with the connected shapes of
resources/css/components/button-group.css. --}}
@props([
'label' => null,
@@ -52,76 +53,54 @@
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$variant = in_array($variant, ['tonal', 'filled', 'outlined'], true) ? $variant : 'tonal';
// M3's "Default shape | Round, square": a square group's ends take the corner its inner edges
// take; the chosen segment still rounds fully, which is the cue selection has always carried.
$shape = $shape === 'square' ? 'square' : 'round';
// M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever
// its 32px/40px container measures. From md the segment is wider than that on its own.
$small = in_array($size, ['xs', 'sm'], true);
$segment = [
'xs' => 'h-8 gap-2 px-4 type-label-lg',
'sm' => 'h-10 gap-2 px-4 type-label-lg',
'md' => 'h-14 gap-2 px-6 type-title-md',
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
][$size];
// A 20px glyph comes from M3's 20px cut, which the icon component picks from the size.
$iconSize = ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$size];
$hintClasses = filled($hintClass)
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
: 'mt-1 type-body-sm text-on-surface-variant';
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
// A 20px glyph comes from M3's 20px cut, which is drawn for that size rather than scaled to it.
$optical = $iconSize === 'size-5' ? '20' : '24';
$colours = match ($variant) {
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface',
default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary',
};
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([
'data-md-group' => true,
'data-md-size' => $size,
'data-md-variant' => $variant,
'data-md-shape' => $shape,
'data-md-multiple' => $multiple ? true : null,
'data-md-inline' => $inline ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
<fieldset {{ $root }}>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
<legend data-md-group-legend>{{ $label }}</legend>
@endif
<div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
'state-transition-fast',
'flex-1' => ! $inline,
$small ? 'touch-target min-w-12' : 'min-w-0',
$segment,
$colours,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
])>
<label data-md-group-segment>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="{{ $multiple ? 'checkbox' : 'radio' }}"
name="{{ $multiple ? $name.'[]' : $name }}"
value="{{ data_get($option, $optionValue) }}"
@disabled(data_get($option, 'disabled'))
class="peer sr-only"
/>
@if (filled(data_get($option, $optionIcon)))
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :size="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
<span data-md-group-label>{{ data_get($option, $optionLabel) }}</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
<p data-md-group-error>{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="{{ $hintClasses }}">{{ $hint }}</p>
<p data-md-group-hint @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
@endif
</fieldset>