Give a connected segment M3's 48px target and width

An <x-group> segment is a plain label, 32px or 40px tall, with nothing reaching
past it, and nothing gave a connected <x-button-group> its 48dp minimum width
either — both numbers M3 names and tells you not to reduce. The segment now
carries the shared `touch-target` utility and a 48px floor, and groups.css puts
the floor on the buttons a caller nests in a connected group. <x-button> drops
its hand-rolled ::after for the same utility. Plan step 11, actions.md ACT-05.

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
2026-09-14 05:56:52 +02:00
co-authored by Claude Opus 5
parent c1ca157341
commit e4de067350
5 changed files with 34 additions and 6 deletions
+12
View File
@@ -47,6 +47,18 @@
padding-inline-start: calc(var(--group-pad) - var(--group-grow)); padding-inline-start: calc(var(--group-pad) - var(--group-grow));
} }
/*
* A connected segment at the two smallest sizes is 32px or 40px tall, and an icon-only one is as
* narrow as it is tall. M3 names both numbers and tells you not to reduce them: "XS and S
* connected button groups have a 48dp target area and a 48dp minimum width". The target is the
* `touch-target` utility on the segment itself; the width is here, where a group can reach the
* `<x-button>`s a caller put inside it.
*/
[data-button-group='connected'][data-size='xs'] > *,
[data-button-group='connected'][data-size='sm'] > * {
min-inline-size: 3rem;
}
/* /*
* Connected segments and split halves take their inner corner from `--group-corner`, so pressing * Connected segments and split halves take their inner corner from `--group-corner`, so pressing
* or selecting changes one variable and the rounded outer corners stay put. * or selecting changes one variable and the rounded outer corners stay put.
+2 -2
View File
@@ -172,8 +172,8 @@
$outline => $variant === 'outlined', $outline => $variant === 'outlined',
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true), 'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated', 'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
// Below 48px the touch target reaches past the button, as M3 requires. // Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css).
'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true), 'touch-target' => in_array($size, ['xs', 'sm'], true),
'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none', 'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none',
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained, 'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained, 'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
+8 -2
View File
@@ -10,7 +10,9 @@
bind as on any input, the arrow keys move the choice, and a screen reader announces a group. bind as on any input, the arrow keys move the choice, and a screen reader announces a group.
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the 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 default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
`inline`. An option with `'disabled' => true` greys its own segment. `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.
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`,
@@ -43,6 +45,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: 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 = [ $segment = [
'xs' => 'h-8 gap-2 px-3 type-label-lg', 'xs' => 'h-8 gap-2 px-3 type-label-lg',
@@ -75,7 +80,8 @@
<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',
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', 'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'min-w-0 flex-1' => ! $inline, 'flex-1' => ! $inline,
$small ? 'touch-target min-w-12' : 'min-w-0',
$segment, $segment,
$colours, $colours,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary', 'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
@@ -46,6 +46,16 @@ it('draws a choice as connected radios', function () {
->and(substr_count($html, '<svg'))->toBe(1); ->and(substr_count($html, '<svg'))->toBe(1);
}); });
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
$options = [['id' => 'a', 'name' => 'A']];
expect((string) $this->blade('<x-group size="sm" name="x" :$options />', ['options' => $options]))
->toContain('touch-target min-w-12')
->and((string) $this->blade('<x-group size="md" name="x" :$options />', ['options' => $options]))
->not->toContain('touch-target')
->toContain('min-w-0');
});
it('draws several choices as checkboxes', function () { it('draws several choices as checkboxes', function () {
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />')) expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
->toContain('type="checkbox"') ->toContain('type="checkbox"')
+2 -2
View File
@@ -63,8 +63,8 @@ it('rounds by default, squares on request, and squares off further while pressed
}); });
it('reaches a 48px touch target below the medium size', function () { it('reaches a 48px touch target below the medium size', function () {
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('after:min-h-12') expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('touch-target')
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('after:min-h-12'); ->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('touch-target');
}); });
it('is an icon button named by its tooltip when it has no label', function () { it('is an icon button named by its tooltip when it has no label', function () {