Square a button group and the choice drawn as one

Plan step 22, actions.md § Missing (Square-by-default button groups): M3
lists "Default shape | Round, square" as a button-group configuration and
neither `<x-button-group>` nor `<x-group>` had a `shape` prop, so a group
of square buttons still drew round outer corners.

`shape="square"` passes the square corner scale to every child through
`data-shape` and two new variables in groups.css: `--group-outer` is what
the two ends of a group round to (half the height when round, the corner
its inner edges take when square — M3's square table, 4/8/8/16/20dp), and
`--group-square`/`--group-square-pressed` give a standard group's buttons
the scale `<x-button shape="square">` draws. The selected rules keep
`--group-full`, so a selected button in a square group rounds: M3 has a
toggle inside a group swap shape on selection either way.

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:40:14 +02:00
co-authored by Claude Opus 5
parent 88c46001fb
commit 5d0e9bc12f
6 changed files with 96 additions and 11 deletions
@@ -280,7 +280,7 @@ Clusters: `<x-menu-separator />` draws M3's line, `<x-menu-group gap>` M3 Expres
### `<x-button-group>` ### `<x-button-group>`
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. 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.
### `<x-group>` ### `<x-group>`
@@ -294,7 +294,7 @@ A choice between a few options as a connected button group of native radios (che
]" hint="Recipients lose access after that" /> ]" hint="Recipients lose access after that" />
``` ```
Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint. Props: `label`, `hint`, `hint-class` (classes for the hint, as on the fields; a colour there paints it), `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `shape` (`round`, `square`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
### `<x-split-button>` ### `<x-split-button>`
+36 -7
View File
@@ -13,6 +13,16 @@
* full, the inner corners small, smaller still while pressed, and a selected segment fully round * full, the inner corners small, smaller still while pressed, and a selected segment fully round
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec). * (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
* *
* Shape (`shape="square"`, M3's "Default shape | Round, square" configuration): `--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 same corner its inner edges take, which is the square table M3 publishes for connected
* groups (4/8/8/16/20dp by size). A square *standard* group has no inner edges, so it hands its
* buttons the square corner scale `<x-button shape="square">` draws (`--group-square`, and
* `--group-square-pressed` under the finger) instead of asking the caller to write it on each
* one. Selection morphs the other way in both: M3 has a toggle inside a group "swap shape
* square/round on selection", so a selected button in a square group turns round, which is why
* the selected rules keep `--group-full` rather than `--group-outer`.
*
* Split button (`<x-split-button>`): the same idea for two halves, on its own corner scale — * Split button (`<x-split-button>`): the same idea for two halves, on its own corner scale —
* SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px * SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px
* under the finger, the opposite of a connected group, so `--split-inner*` is separate from * under the finger, the opposite of a connected group, so `--split-inner*` is separate from
@@ -34,13 +44,32 @@
--group-inner: var(--md-sys-shape-corner-sm); --group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs); --group-inner-pressed: var(--md-sys-shape-corner-xs);
--group-full: 1.25rem; --group-full: 1.25rem;
--group-square: var(--md-sys-shape-corner-md);
--group-square-pressed: var(--md-sys-shape-corner-sm);
--group-outer: var(--group-full);
} }
[data-button-group][data-size='xs'] { --group-pad: 1rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; } [data-button-group][data-size='xs'] { --group-pad: 1rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; } [data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; } [data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; --group-square: var(--md-sys-shape-corner-lg); --group-square-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; } [data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; } [data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); }
/* A square group's ends take the corner its inner edges take — M3's square table for connected
groups — and a square standard group hands its buttons the button's own square scale. */
[data-button-group][data-shape='square'] { --group-outer: var(--group-inner); }
[data-button-group='standard'][data-shape='square'] > * {
border-radius: var(--group-square);
}
[data-button-group='standard'][data-shape='square'] > :active:not(:disabled, [aria-disabled='true']) {
border-radius: var(--group-square-pressed);
}
[data-button-group='standard'][data-shape='square'] > [aria-pressed='true'] {
border-radius: var(--group-full);
}
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) { [data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow)); padding-inline: calc(var(--group-pad) + var(--group-grow));
@@ -107,14 +136,14 @@
[data-button-group='connected'] > :first-child, [data-button-group='connected'] > :first-child,
[data-split='leading'] { [data-split='leading'] {
border-start-start-radius: var(--group-full); border-start-start-radius: var(--group-outer);
border-end-start-radius: var(--group-full); border-end-start-radius: var(--group-outer);
} }
[data-button-group='connected'] > :last-child, [data-button-group='connected'] > :last-child,
[data-split='trailing'] { [data-split='trailing'] {
border-start-end-radius: var(--group-full); border-start-end-radius: var(--group-outer);
border-end-end-radius: var(--group-full); border-end-end-radius: var(--group-outer);
} }
/* /*
@@ -11,6 +11,13 @@
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. For a choice bound to a
property, `<x-group>` draws a connected group of radios. property, `<x-group>` draws a connected group of radios.
`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 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 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. smaller `size` or fewer buttons, or two groups stacked.
@@ -22,10 +29,12 @@
'connected' => false, 'connected' => false,
'size' => 'sm', 'size' => 'sm',
'label' => null, 'label' => null,
'shape' => 'round',
]) ])
@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';
@endphp @endphp
<div <div
@@ -33,6 +42,7 @@
@if ($label) aria-label="{{ $label }}" @endif @if ($label) aria-label="{{ $label }}" @endif
data-button-group="{{ $connected ? 'connected' : 'standard' }}" data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}" data-size="{{ $size }}"
data-shape="{{ $shape }}"
{{ $attributes->class([ {{ $attributes->class([
'inline-flex items-center', 'inline-flex items-center',
'gap-0.5' => $connected, 'gap-0.5' => $connected,
+11 -2
View File
@@ -15,9 +15,14 @@
you never to reduce. Corners move on the spatial spring and colours on the effects one you never to reduce. Corners move on the spatial spring and colours on the effects one
(`state-transition-fast`). (`state-transition-fast`).
`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),
and the chosen segment still rounds fully, so selection reads the same either way.
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint. `variant`, `shape`, `multiple`, `inline`. A validation message for the bound property replaces
the hint.
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it `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 (`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
@@ -35,6 +40,7 @@
'optionIcon' => 'icon', 'optionIcon' => 'icon',
'size' => 'sm', 'size' => 'sm',
'variant' => 'tonal', 'variant' => 'tonal',
'shape' => 'round',
'multiple' => false, 'multiple' => false,
'inline' => false, 'inline' => false,
]) ])
@@ -46,6 +52,9 @@
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); $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)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
// 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 // 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. // its 32px/40px container measures. From md the segment is wider than that on its own.
$small = in_array($size, ['xs', 'sm'], true); $small = in_array($size, ['xs', 'sm'], true);
@@ -78,7 +87,7 @@
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend> <legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif @endif
<div data-button-group="connected" data-size="{{ $size }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])> <div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
@foreach ($options as $option) @foreach ($options as $option)
<label @class([ <label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap', 'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
@@ -76,6 +76,27 @@
<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,
'Square groups (hold a button down)' => <<<'BLADE'
<x-button-group label="Square standard" size="md" shape="square">
<x-button label="Day" variant="tonal" size="md" />
<x-button label="Week" variant="tonal" size="md" />
<x-button label="Month" variant="tonal" size="md" />
</x-button-group>
<x-button-group label="Square formatting" connected shape="square">
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
<div x-data="{ density: 'cosy' }" class="w-full">
<x-group label="Density" name="showcase-density" shape="square" x-model="density" inline :options="[
['id' => 'compact', 'name' => 'Compact'],
['id' => 'cosy', 'name' => 'Cosy'],
['id' => 'roomy', 'name' => 'Roomy'],
]" />
</div>
BLADE,
'A choice as a connected group' => <<<'BLADE' 'A choice as a connected group' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 medium:grid-cols-2"> <div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 medium:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[ <x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
@@ -20,6 +20,22 @@ it('connects buttons 2px apart', function () {
->toContain('gap-0.5'); ->toContain('gap-0.5');
}); });
it('squares a group, and a choice drawn as one', function () {
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
// every button in the group, so it need not be written on each one.
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group connected shape="square"><x-button label="Day" /></x-button-group>'))
->toContain('data-button-group="connected"')
->toContain('data-shape="square"')
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->toContain('data-shape="round"')
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="square"')
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-shape="round"');
});
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');