From 8dd0a7ef9d33d7000356acb86c113558176b45bd Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:59:18 +0200 Subject: [PATCH] Draw the button group without Tailwind Plan step 36. renders data-md-button-group (standard or connected), data-md-size, data-md-shape and, with a selection, data-md-selection and data-md-selection-required. button-group.css now also draws the standard group: its gaps per size, the square corner scale it hands its buttons, and the press expansion that widens a pressed label button while its neighbours give way. groups.css keeps only the split button until that is rewritten too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/button-group.css | 74 ++++++++++- resources/css/components/groups.css | 125 +++--------------- .../views/components/button-group.blade.php | 20 +-- .../Components/ActionStylesheetsTest.php | 1 + tests/Feature/Components/ButtonGroupTest.php | 81 ++++++++---- 5 files changed, 156 insertions(+), 145 deletions(-) diff --git a/resources/css/components/button-group.css b/resources/css/components/button-group.css index 460c0534..6a6f2745 100644 --- a/resources/css/components/button-group.css +++ b/resources/css/components/button-group.css @@ -1,7 +1,17 @@ /* - * 's shapes: the corners a button takes from its place among its siblings. The - * connected form is also what `` draws its segments with, which is why its stylesheet - * imports this one. + * : related buttons in a row, and the shapes a button takes from its place among + * its siblings. The connected form is also what `` draws its segments with, which is why + * its stylesheet imports this one. A group never wraps: M3's rule, and the press expansion only + * reaches a neighbour on the same line anyway. + * + * Standard (`data-md-button-group="standard"`): the buttons stand apart — 18/12/8/8/8px by size + * (ButtonGroupXSmall…XLargeTokens.BetweenSpace) — and pressing a label button widens it and + * narrows its neighbours by the same amount, so the row keeps its length: Expressive's press + * expansion (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size, 4/6/8/16/ + * 20px, on the button's own padding of 16/16/24/48/64px). Icon buttons keep their width. A square + * standard group has no inner edges, so it hands its buttons the square corner scale + * `` draws (12/12/16/28/28px, 8/8/12/16/16px under the finger), and a + * selected button in it turns round. * * 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 @@ -34,29 +44,87 @@ --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-square: var(--md-sys-shape-corner-md); + --md-button-group-square-pressed: var(--md-sys-shape-corner-sm); --md-button-group-outer: var(--md-button-group-full); + --md-button-group-pad: var(--md-sys-measurement-space200); + --md-button-group-grow: 6px; + + display: inline-flex; + align-items: center; } [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; + --md-button-group-grow: 4px; } [data-md-button-group][data-md-size='md'] { --md-button-group-full: 28px; + --md-button-group-square: var(--md-sys-shape-corner-lg); + --md-button-group-square-pressed: var(--md-sys-shape-corner-md); + --md-button-group-pad: var(--md-sys-measurement-space300); + --md-button-group-grow: 8px; } [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; + --md-button-group-square: var(--md-sys-shape-corner-xl); + --md-button-group-square-pressed: var(--md-sys-shape-corner-lg); + --md-button-group-pad: var(--md-sys-measurement-space600); + --md-button-group-grow: 16px; } [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; + --md-button-group-square: var(--md-sys-shape-corner-xl); + --md-button-group-square-pressed: var(--md-sys-shape-corner-lg); + --md-button-group-pad: var(--md-sys-measurement-space800); + --md-button-group-grow: 20px; + } + + [data-md-button-group='standard'] { + gap: var(--md-sys-measurement-space100); + } + + [data-md-button-group='standard'][data-md-size='xs'] { + gap: 18px; + } + + [data-md-button-group='standard'][data-md-size='sm'] { + gap: 12px; + } + + /* A square standard group hands its buttons the square corner scale; a selected one rounds. */ + [data-md-button-group='standard'][data-md-shape='square'] > * { + border-radius: var(--md-button-group-square); + } + + [data-md-button-group='standard'][data-md-shape='square'] > :active:not(:disabled, [aria-disabled='true']) { + border-radius: var(--md-button-group-square-pressed); + } + + [data-md-button-group='standard'][data-md-shape='square'] > [aria-pressed='true'] { + border-radius: var(--md-button-group-full); + } + + /* Press expansion: the pressed label button grows, the label buttons beside it give way. */ + [data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) { + padding-inline: calc(var(--md-button-group-pad) + var(--md-button-group-grow)); + } + + [data-md-button-group='standard'] > :not([data-md-icon-button]):has(+ :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])) { + padding-inline-end: calc(var(--md-button-group-pad) - var(--md-button-group-grow)); + } + + [data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button]) { + padding-inline-start: calc(var(--md-button-group-pad) - var(--md-button-group-grow)); } /* A square group's ends take the corner its inner edges take. */ diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css index 33a595bf..f233b172 100644 --- a/resources/css/components/groups.css +++ b/resources/css/components/groups.css @@ -1,87 +1,32 @@ /* - * Button groups and split buttons: shapes that depend on a button's place among its siblings. + * Split buttons: shapes that depend on a button's place beside its other half. Button groups and + * `` draw theirs from resources/css/components/button-group.css. * - * Unlayered on purpose. Each draws its own corners and padding as utilities, and a - * rule in any @layer loses to a utility whatever its specificity; the group has to win. + * Unlayered on purpose, until the split button leaves Tailwind too. * - * Standard group (``): pressing a label button widens it and narrows its - * neighbours by the same amount, so the row keeps its length — Expressive's press expansion - * (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep - * their width. - * - * Connected group (``, ``): 2px apart, the outer corners - * full, the inner corners small, smaller still while pressed, and a selected segment fully round - * (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 `` 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 (``): 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 - * under the finger, the opposite of a connected group, so `--split-inner*` is separate from - * `--group-inner*`. The trailing half turns round and its chevron turns over while its menu is - * open, on M3's standard scheme rather than the expressive springs (the site: "the trailing - * button uses the standard motion scheme, not expressive, when rotating"), and the chevron sits - * a little towards the leading half so the pair reads as one control (`--split-nudge`: - * 1/1/2/3/6px). `--split-icon` is the trailing icon's own size, 22/22/26/38/50px, which the - * icon-button table does not know about. + * Split button (``): two halves on their 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 under the finger, + * the opposite of a connected group. The trailing half turns round and its chevron turns over + * while its menu is open, on M3's standard scheme rather than the expressive springs (the site: + * "the trailing button uses the standard motion scheme, not expressive, when rotating"), and the + * chevron sits a little towards the leading half so the pair reads as one control + * (`--split-nudge`: 1/1/2/3/6px). `--split-icon` is the trailing icon's own size, + * 22/22/26/38/50px, which the icon-button table does not know about. * * "Full" here is half the size's height (`--group-full`), 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. + * 9999px: when a box's radii add up to more than its side, CSS scales every radius by the same + * factor, and a 9999px corner beside an 8px one drew the inner corners square. */ [data-button-group] { - --group-inner: var(--md-sys-shape-corner-sm); - --group-inner-pressed: var(--md-sys-shape-corner-xs); --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='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; --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; --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; --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-md-icon-button]):active:not(:disabled, [aria-disabled='true']) { - padding-inline: calc(var(--group-pad) + var(--group-grow)); -} - -[data-button-group='standard'] > :not([data-md-icon-button]):has(+ :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])) { - padding-inline-end: calc(var(--group-pad) - var(--group-grow)); -} - -[data-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button]) { - padding-inline-start: calc(var(--group-pad) - var(--group-grow)); -} +[data-button-group][data-size='xs'] { --group-full: 1rem; } +[data-button-group][data-size='md'] { --group-full: 1.75rem; } +[data-button-group][data-size='lg'] { --group-full: 3rem; } +[data-button-group][data-size='xl'] { --group-full: 4.25rem; } [data-button-group='split'] { --split-inner: var(--md-sys-shape-corner-xs); --split-inner-pressed: var(--md-sys-shape-corner-md); --split-icon: 22px; --split-nudge: 1px; } [data-button-group='split'][data-size='xs'] { --split-inner-pressed: var(--md-sys-shape-corner-sm); } @@ -89,58 +34,30 @@ [data-button-group='split'][data-size='lg'] { --split-inner: var(--md-sys-shape-corner-sm); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 38px; --split-nudge: 3px; } [data-button-group='split'][data-size='xl'] { --split-inner: var(--md-sys-shape-corner-md); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 50px; --split-nudge: 6px; } -/* - * 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 - * ``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 - * or selecting changes one variable and the rounded outer corners stay put. - */ -[data-button-group='connected'] > *, +/* The halves take their inner corner from `--group-corner`, so pressing changes one variable and + the rounded outer corners stay put. */ [data-split] { + --group-corner: var(--split-inner); + border-start-start-radius: var(--group-corner); border-end-start-radius: var(--group-corner); border-start-end-radius: var(--group-corner); border-end-end-radius: var(--group-corner); } -[data-button-group='connected'] > * { - --group-corner: var(--group-inner); -} - -[data-button-group='connected'] > :active { - --group-corner: var(--group-inner-pressed); -} - -[data-split] { - --group-corner: var(--split-inner); -} - [data-split]:active { --group-corner: var(--split-inner-pressed); } -[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)), [data-split='trailing'][aria-expanded='true'] { --group-corner: var(--group-full); } -[data-button-group='connected'] > :first-child, [data-split='leading'] { border-start-start-radius: var(--group-outer); border-end-start-radius: var(--group-outer); } -[data-button-group='connected'] > :last-child, [data-split='trailing'] { border-start-end-radius: var(--group-outer); border-end-end-radius: var(--group-outer); diff --git a/resources/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php index 3f5b5a4e..b3c8cdb7 100644 --- a/resources/views/components/button-group.blade.php +++ b/resources/views/components/button-group.blade.php @@ -45,7 +45,7 @@ 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. --}} + spacing, shapes and press expansion are resources/css/components/button-group.css. --}} @props([ 'connected' => false, @@ -71,12 +71,12 @@
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), - ]) }} + {{ $attributes }} > {{ $slot }}
diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index be7bd627..1c6b76ac 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -16,6 +16,7 @@ dataset('action components', [ 'badge', 'button', 'group', + 'button-group', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/ButtonGroupTest.php b/tests/Feature/Components/ButtonGroupTest.php index b59d2cda..b27677e9 100644 --- a/tests/Feature/Components/ButtonGroupTest.php +++ b/tests/Feature/Components/ButtonGroupTest.php @@ -2,42 +2,73 @@ use Livewire\Component; use Livewire\Livewire; +use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; -it('groups buttons with the spacing of their size', function () { - expect((string) $this->blade('')) - ->toContain('role="group"') - ->toContain('aria-label="Views"') - ->toContain('data-button-group="standard"') - ->toContain('data-size="md"') - ->toContain('gap-2') - ->and((string) $this->blade('')) - ->toContain('gap-3'); -}); +it('groups buttons with the spacing of their size', function (string $size, string $gap) { + expect((string) $this->blade("")) + ->toMatch("/not->toContain('class=') + ->and(ComponentStylesheet::read('button-group')->declarations(in_array($size, ['xs', 'sm'], true) + ? "[data-md-button-group='standard'][data-md-size='{$size}']" + : "[data-md-button-group='standard']"))->toBe(['gap' => $gap]); +})->with([ + 'xs' => ['xs', '18px'], + 'sm' => ['sm', '12px'], + 'md' => ['md', 'var(--md-sys-measurement-space100)'], + 'lg' => ['lg', 'var(--md-sys-measurement-space100)'], + 'xl' => ['xl', 'var(--md-sys-measurement-space100)'], +]); -it('connects buttons 2px apart', function () { - expect((string) $this->blade('')) - ->toContain('data-button-group="connected"') - ->toContain('gap-0.5'); +it('connects buttons 2px apart, with M3\'s inner corners', function () { + $css = ComponentStylesheet::read('button-group'); + + expect((string) $this->blade(''))->toContain('data-md-button-group="connected"') + ->and($css->declarations("[data-md-button-group='connected']"))->toBe(['gap' => 'var(--md-sys-measurement-space25)']) + ->and($css->declarations("[data-md-button-group='connected'] > *"))->toMatchArray([ + '--md-button-group-corner' => 'var(--md-button-group-inner)', + 'border-start-start-radius' => 'var(--md-button-group-corner)', + ]) + ->and($css->declarations("[data-md-button-group='connected'] > :active"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-inner-pressed)']) + ->and($css->declarations("[data-md-button-group='connected'] > :first-child"))->toBe([ + 'border-start-start-radius' => 'var(--md-button-group-outer)', + 'border-end-start-radius' => 'var(--md-button-group-outer)', + ]) + // Half the height, never 9999px, which would scale the small inner corners away. + ->and($css->declarations("[data-md-button-group][data-md-size='xl']"))->toMatchArray(['--md-button-group-full' => '68px', '--md-button-group-inner' => 'var(--md-sys-shape-corner-lg-increased)']); }); it('squares a group', 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. + $css = ComponentStylesheet::read('button-group'); + expect((string) $this->blade('')) - ->toContain('data-shape="square"') + ->toContain('data-md-shape="square"') ->and((string) $this->blade('')) - ->toContain('data-button-group="connected"') - ->toContain('data-shape="square"') - ->and((string) $this->blade('')) - ->toContain('data-shape="round"'); + ->toContain('data-md-button-group="connected"') + ->toContain('data-md-shape="square"') + ->and((string) $this->blade('')) + ->toContain('data-md-shape="round"') + ->and($css->declarations("[data-md-button-group='standard'][data-md-shape='square'] > *"))->toBe(['border-radius' => 'var(--md-button-group-square)']) + ->and($css->declarations("[data-md-button-group='standard'][data-md-shape='square'] > [aria-pressed='true']"))->toBe(['border-radius' => 'var(--md-button-group-full)']); +}); + +it('widens a pressed label button while its neighbours give way', function () { + $css = ComponentStylesheet::read('button-group'); + + expect($css->declarations("[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])")) + ->toBe(['padding-inline' => 'calc(var(--md-button-group-pad) + var(--md-button-group-grow))']) + ->and($css->declarations("[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button])")) + ->toBe(['padding-inline-start' => 'calc(var(--md-button-group-pad) - var(--md-button-group-grow))']) + ->and($css->declarations("[data-md-button-group][data-md-size='lg']"))->toMatchArray(['--md-button-group-pad' => 'var(--md-sys-measurement-space600)', '--md-button-group-grow' => '16px']); }); it('takes over aria-pressed for a group that carries a selection', function () { $html = (string) $this->blade(''); expect($html) - ->toContain('data-selection="single"') - ->toContain('data-selection-required') + ->toContain('data-md-selection="single"') + ->toContain('data-md-selection-required') ->toContain('multiple: false') ->toContain('required: true') ->toContain('value: null') @@ -49,7 +80,7 @@ it('takes over aria-pressed for a group that carries a selection', function () { ->toContain('required: false') ->toContain('value: []') ->and((string) $this->blade('')) - ->not->toContain('data-selection') + ->not->toContain('data-md-selection') ->not->toContain('press($event)'); }); @@ -74,14 +105,14 @@ it('entangles a selection group with the property it binds, and keeps wire:model $html = Livewire::test($component)->assertSet('view', 'week')->html(); expect($html) - ->toContain('data-selection="single"') + ->toContain('data-md-selection="single"') ->toMatch('/value: window\.Livewire\.find\(/') ->and(substr_count($html, 'wire:model.live="view"'))->toBe(0); }); it('never wraps a group onto a second line', function () { - expect((string) $this->blade('')) - ->not->toContain('flex-wrap'); + expect(ComponentStylesheet::read('button-group')->declarations('[data-md-button-group]'))->toMatchArray(['display' => 'inline-flex']) + ->not->toHaveKey('flex-wrap'); }); it('marks icon buttons, which keep their width when a group is pressed', function () {