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
+2
View File
@@ -16,6 +16,8 @@
@import './components/tooltip.css';
@import './components/badge.css';
@import './components/button.css';
@import './components/group.css';
@import './components/button-group.css';
/* Inputs, selection and data */
+101
View File
@@ -0,0 +1,101 @@
/*
* <x-button-group>'s shapes: the corners a button takes from its place among its siblings. The
* connected form is also what `<x-group>` draws its segments with, which is why its stylesheet
* imports this one.
*
* Connected (`data-md-button-group="connected"`): 2px apart, the outer corners full, the inner
* corners small, smaller still while pressed, and a selected segment (`aria-pressed`, or a checked
* input inside it) fully round — ConnectedButtonGroupXSmall…XLargeTokens and the M3 Expressive
* connected button group spec: inner corners 4/8/8/16/20px, pressed 2/4/4/12/16px. At the two
* smallest sizes a segment is 32px or 40px tall, and M3 names both numbers it must not go below:
* "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
*
* Shape (`data-md-shape="square"`, M3's "Default shape | Round, square" configuration):
* `--md-button-group-outer` is what the two ends of a group round to — half the height for a round
* group, and for a square one the corner its inner edges take, which is the square table M3
* publishes for connected groups (4/8/8/16/20dp by size). Selection morphs the other way: M3 has a
* toggle inside a group "swap shape square/round on selection", so a selected segment in a square
* group turns round, which is why the selected rule keeps `--md-button-group-full`.
*
* "Full" is half the size's height (16/20/28/48/68px), never `--md-sys-shape-corner-full`'s
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii add
* up to more than its side, CSS scales every radius by the same factor: a 9999px corner beside an
* 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew square.
*
* <x-button>'s own corner rules carry no specificity, so these reshape the buttons in a group
* whatever selector they use; a pressed or selected segment changes one custom property and the
* outer corners stay put.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-button-group] {
--md-button-group-inner: var(--md-sys-shape-corner-sm);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-xs);
--md-button-group-full: 20px;
--md-button-group-outer: var(--md-button-group-full);
}
[data-md-button-group][data-md-size='xs'] {
--md-button-group-inner: var(--md-sys-shape-corner-xs);
--md-button-group-inner-pressed: 2px;
--md-button-group-full: 16px;
}
[data-md-button-group][data-md-size='md'] {
--md-button-group-full: 28px;
}
[data-md-button-group][data-md-size='lg'] {
--md-button-group-inner: var(--md-sys-shape-corner-lg);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-md);
--md-button-group-full: 48px;
}
[data-md-button-group][data-md-size='xl'] {
--md-button-group-inner: var(--md-sys-shape-corner-lg-increased);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-full: 68px;
}
/* A square group's ends take the corner its inner edges take. */
[data-md-button-group][data-md-shape='square'] {
--md-button-group-outer: var(--md-button-group-inner);
}
[data-md-button-group='connected'] {
gap: var(--md-sys-measurement-space25);
}
[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > * {
min-inline-size: var(--md-sys-measurement-space600);
}
[data-md-button-group='connected'] > * {
--md-button-group-corner: var(--md-button-group-inner);
border-start-start-radius: var(--md-button-group-corner);
border-end-start-radius: var(--md-button-group-corner);
border-start-end-radius: var(--md-button-group-corner);
border-end-end-radius: var(--md-button-group-corner);
}
[data-md-button-group='connected'] > :active {
--md-button-group-corner: var(--md-button-group-inner-pressed);
}
[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)) {
--md-button-group-corner: var(--md-button-group-full);
}
[data-md-button-group='connected'] > :first-child {
border-start-start-radius: var(--md-button-group-outer);
border-end-start-radius: var(--md-button-group-outer);
}
[data-md-button-group='connected'] > :last-child {
border-start-end-radius: var(--md-button-group-outer);
border-end-end-radius: var(--md-button-group-outer);
}
}
+241
View File
@@ -0,0 +1,241 @@
/*
* <x-group>: a choice of a few options drawn as M3 Expressive's connected button group, the
* successor of the segmented button — native radios (checkboxes with `data-md-multiple`) under
* segments that share the row, or hug their labels with `data-md-inline`.
*
* The segments are M3's buttons at the group's `data-md-size` (Button*Tokens: 32/40/56/96/136px
* tall, 16/16/24/48/64px either side, 8/8/8/12/16px between icon and label, label-large, label-
* large, title-medium, headline-small, headline-large), with the connected shape from
* button-group.css: 2px apart, small inner corners, the chosen segment fully round. At `xs` and
* `sm` a segment carries M3's 48px target and a 48px minimum width, which M3 asks for by name.
*
* `data-md-variant` is the toggle button's colours: `tonal` (secondary-container, chosen in
* secondary), `filled` (surface-container behind on-surface-variant, chosen in primary) or
* `outlined` (an outline-variant edge, chosen in inverse-surface). A checked input picks the
* chosen colours (`:has(:checked)`), a disabled one the disabled treatment — on-surface at 10%
* behind 38% text — and keyboard focus on the input draws M3's focus ring around its segment.
* Corners move on the fast spatial spring and colours on the fast effects spring beside it.
*
* The legend is label-large and the hint body-small, both in on-surface-variant; a validation
* message takes the hint's place in error. `hint-class` lands on the hint, where a caller's class
* outranks the colour here.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './button-group.css';
@layer material.components {
[data-md-group] {
min-inline-size: 0;
}
[data-md-group-legend] {
margin-block-end: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group] > [data-md-button-group] {
display: flex;
align-items: normal;
inline-size: 100%;
}
[data-md-group][data-md-inline] > [data-md-button-group] {
inline-size: fit-content;
}
[data-md-group-segment] {
--md-group-container: var(--md-sys-color-secondary-container);
--md-group-label: var(--md-sys-color-on-secondary-container);
position: relative;
isolation: isolate;
display: flex;
flex: 1 1 0%;
align-items: center;
justify-content: center;
min-inline-size: 0;
background-color: var(--md-group-container);
color: var(--md-group-label);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:has(:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader. */
& > input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
}
[data-md-group][data-md-inline] [data-md-group-segment] {
flex: initial;
}
[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment] {
min-inline-size: var(--md-sys-measurement-space600);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: var(--md-sys-measurement-space600);
min-height: var(--md-sys-measurement-space600);
transform: translate(-50%, -50%);
}
}
[data-md-group][data-md-size='xs'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 32px;
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='sm'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 40px;
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='md'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 56px;
padding-inline: var(--md-sys-measurement-space300);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='lg'] [data-md-group-segment] {
gap: 12px;
block-size: 96px;
padding-inline: var(--md-sys-measurement-space600);
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='xl'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space200);
block-size: 136px;
padding-inline: var(--md-sys-measurement-space800);
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
font-variation-settings: normal;
}
[data-md-group-segment]:has(:checked) {
--md-group-container: var(--md-sys-color-secondary);
--md-group-label: var(--md-sys-color-on-secondary);
}
[data-md-group][data-md-variant='filled'] [data-md-group-segment] {
--md-group-container: var(--md-sys-color-surface-container);
--md-group-label: var(--md-sys-color-on-surface-variant);
&:has(:checked) {
--md-group-container: var(--md-sys-color-primary);
--md-group-label: var(--md-sys-color-on-primary);
}
}
[data-md-group][data-md-variant='outlined'] [data-md-group-segment] {
--md-group-container: transparent;
--md-group-label: var(--md-sys-color-on-surface-variant);
border: 1px solid var(--md-sys-color-outline-variant);
&:has(:checked) {
--md-group-container: var(--md-sys-color-inverse-surface);
--md-group-label: var(--md-sys-color-inverse-on-surface);
border-color: transparent;
}
}
[data-md-group-segment]:has(:disabled) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: not-allowed;
}
[data-md-group-label] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-md-group-hint],
[data-md-group-error] {
margin-block-start: var(--md-sys-measurement-space50);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-group-hint] {
color: var(--md-sys-color-on-surface-variant);
}
[data-md-group-error] {
color: var(--md-sys-color-error);
}
}
+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>