From 2a4dcead3d99638816bcdbb993a409373a2a1eeb Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 15:03:02 +0200 Subject: [PATCH] Draw the split button without Tailwind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 36. renders data-md-split-button and data-md-size on the pair and data-md-split on its halves. split-button.css draws SplitButton*Tokens' shape — full outer corners, inner corners that grow under the finger, the trailing half round while its menu is open — the narrower leading padding and 48px trailing half at xs and sm, and the chevron's own size, nudge and turn on the standard scheme. groups.css is empty and leaves tailwind.css. The actions browser test follows data-md-split and data-md-button-group, and gains the owed tests for a connected segment's 48px edge and a button group's single-required and multi selection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/groups.css | 81 ------------ resources/css/components/split-button.css | 115 ++++++++++++++++++ resources/css/tailwind.css | 1 - .../views/components/split-button.blade.php | 25 ++-- tests/Browser/ActionsTest.php | 55 ++++++++- .../Components/ActionStylesheetsTest.php | 6 +- tests/Feature/Components/SplitButtonTest.php | 61 +++++++--- 8 files changed, 225 insertions(+), 120 deletions(-) delete mode 100644 resources/css/components/groups.css create mode 100644 resources/css/components/split-button.css diff --git a/resources/css/components.css b/resources/css/components.css index 993e1a0d..c62aad2b 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -18,6 +18,7 @@ @import './components/button.css'; @import './components/group.css'; @import './components/button-group.css'; +@import './components/split-button.css'; /* Inputs, selection and data */ diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css deleted file mode 100644 index f233b172..00000000 --- a/resources/css/components/groups.css +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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, until the split button leaves Tailwind too. - * - * 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: 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-full: 1.25rem; - --group-outer: var(--group-full); -} - -[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); } -[data-button-group='split'][data-size='md'] { --split-icon: 26px; --split-nudge: 2px; } -[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; } - -/* 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-split]:active { - --group-corner: var(--split-inner-pressed); -} - -[data-split='trailing'][aria-expanded='true'] { - --group-corner: var(--group-full); -} - -[data-split='leading'] { - border-start-start-radius: var(--group-outer); - border-end-start-radius: var(--group-outer); -} - -[data-split='trailing'] { - border-start-end-radius: var(--group-outer); - border-end-end-radius: var(--group-outer); -} - -/* - * The trailing chevron: its own size, and `--split-nudge` off centre towards the leading half — - * equal and opposite margins move it by exactly that much inside a centred flex row, in whichever - * direction the writing mode runs. - */ -[data-split='trailing'] svg { - width: var(--split-icon); - height: var(--split-icon); - margin-inline-start: calc(-1 * var(--split-nudge)); - margin-inline-end: var(--split-nudge); - transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); -} - -[data-split='trailing'][aria-expanded='true'] svg { - rotate: 180deg; -} diff --git a/resources/css/components/split-button.css b/resources/css/components/split-button.css new file mode 100644 index 00000000..4ff426bf --- /dev/null +++ b/resources/css/components/split-button.css @@ -0,0 +1,115 @@ +/* + * : an action and a menu of its alternatives, as two ``s 2px apart. + * + * The halves share one shape (SplitButtonXSmall…XLargeTokens, androidx Compose Material 3, + * Apache-2.0): outer corners full — half the height, 16/20/28/48/68px, never 9999px, which would + * scale the small inner corners away — and inner corners of 4/4/4/8/12px that *grow* under the + * finger to 8/12/12/20/20px, the opposite of a connected button group. The trailing half turns + * fully round while its menu is open (`aria-expanded`). ``'s own corner rules carry no + * specificity, so these win. + * + * At the two smallest sizes the leading half keeps less room on its inner side (10px at `xs`, + * 12px at `sm`) and the trailing half is 48px wide. The trailing chevron has its own size, + * 22/22/26/38/50px, which the icon-button table does not know about, and sits + * `--md-split-button-nudge` (1/1/2/3/6px) towards the leading half so the pair reads as one + * control: equal and opposite inline margins move it by exactly that much inside a centred flex + * row, in whichever direction the writing mode runs. It turns over while the 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"). + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './button.css'; + +@layer material.components { + [data-md-split-button] { + --md-split-button-inner: var(--md-sys-shape-corner-xs); + --md-split-button-inner-pressed: var(--md-sys-shape-corner-md); + --md-split-button-full: 20px; + --md-split-button-icon: 22px; + --md-split-button-nudge: 1px; + + display: inline-flex; + align-items: center; + gap: var(--md-sys-measurement-space25); + } + + [data-md-split-button][data-md-size='xs'] { + --md-split-button-inner-pressed: var(--md-sys-shape-corner-sm); + --md-split-button-full: 16px; + } + + [data-md-split-button][data-md-size='md'] { + --md-split-button-full: 28px; + --md-split-button-icon: 26px; + --md-split-button-nudge: 2px; + } + + [data-md-split-button][data-md-size='lg'] { + --md-split-button-inner: var(--md-sys-shape-corner-sm); + --md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased); + --md-split-button-full: 48px; + --md-split-button-icon: 38px; + --md-split-button-nudge: 3px; + } + + [data-md-split-button][data-md-size='xl'] { + --md-split-button-inner: var(--md-sys-shape-corner-md); + --md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased); + --md-split-button-full: 68px; + --md-split-button-icon: 50px; + --md-split-button-nudge: 6px; + } + + [data-md-split-button] [data-md-split] { + --md-split-button-corner: var(--md-split-button-inner); + + border-start-start-radius: var(--md-split-button-corner); + border-end-start-radius: var(--md-split-button-corner); + border-start-end-radius: var(--md-split-button-corner); + border-end-end-radius: var(--md-split-button-corner); + } + + [data-md-split-button] [data-md-split]:active { + --md-split-button-corner: var(--md-split-button-inner-pressed); + } + + [data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] { + --md-split-button-corner: var(--md-split-button-full); + } + + [data-md-split-button] [data-md-split='leading'] { + border-start-start-radius: var(--md-split-button-full); + border-end-start-radius: var(--md-split-button-full); + } + + [data-md-split-button] [data-md-split='trailing'] { + border-start-end-radius: var(--md-split-button-full); + border-end-end-radius: var(--md-split-button-full); + } + + [data-md-split-button][data-md-size='xs'] [data-md-split='leading'] { + padding-inline-end: var(--md-sys-measurement-space125); + } + + [data-md-split-button][data-md-size='sm'] [data-md-split='leading'] { + padding-inline-end: 12px; + } + + [data-md-split-button]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-split='trailing'] { + inline-size: var(--md-sys-measurement-space600); + } + + [data-md-split-button] [data-md-split='trailing'] [data-md-icon] { + inline-size: var(--md-split-button-icon); + block-size: var(--md-split-button-icon); + margin-inline-start: calc(-1 * var(--md-split-button-nudge)); + margin-inline-end: var(--md-split-button-nudge); + transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); + } + + [data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] [data-md-icon] { + rotate: 180deg; + } +} diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index fe312154..af2d3276 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -18,7 +18,6 @@ @import './tokens/utilities.css'; /* Actions and communication — leaving in step 36 */ @import './components/actions.css'; -@import './components/groups.css'; @import './components/menu.css'; /* Inputs, selection and data — leaving in step 36 */ diff --git a/resources/views/components/split-button.blade.php b/resources/views/components/split-button.blade.php index d5c3f6c2..9db1dd49 100644 --- a/resources/views/components/split-button.blade.php +++ b/resources/views/components/split-button.blade.php @@ -5,16 +5,16 @@ - Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is - `filled` (the default), `tonal`, `outlined` or `elevated` — a text split button does not exist - in M3 — with `color` and `size` as on ``. The halves sit 2px apart; the trailing - one rounds fully and turns its chevron over while the menu is open. `menu-label` names the - trailing button and the menu for screen readers. + Attributes (wire:click, spinner…) go to the leading button, a `class` to the pair; the slot is + the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated` — a text split + button does not exist in M3 — with `color` and `size` as on ``. The halves sit 2px + apart; the trailing one rounds fully and turns its chevron over while the menu is open. + `menu-label` names the trailing button and the menu for screen readers. Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px - wide there. resources/css/components/groups.css carries the rest of the spec, which is all - geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a + wide there. resources/css/components/split-button.css carries the rest of the spec, which is + all geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a connected group's do), the trailing icon's own size (22/22/26/38/50px, which the icon-button table has no entry for), the 1/1/2/3/6px nudge that sits the chevron towards the leading half, and the standard motion scheme M3 asks for while it turns over. --}} @@ -35,12 +35,9 @@ $variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled'; $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; $menuLabel ??= __('More options'); - - $leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size]; - $trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size]; @endphp -
only('class')->class('inline-flex items-center gap-0.5') }}> +
only('class') }}> except('class') }} :label="$label" @@ -50,8 +47,7 @@ :size="$size" :disabled="$disabled" :spinner="$spinner" - data-split="leading" - :class="$leadingPadding" + data-md-split="leading" /> @@ -63,8 +59,7 @@ :color="$color" :size="$size" :disabled="$disabled" - data-split="trailing" - :class="$trailingWidth" + data-md-split="trailing" /> diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 5936d5c0..bfc6ef6c 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -222,10 +222,10 @@ it('moves a connected group\'s choice with the arrow keys', function () { }); it('rounds a split button\'s trailing half while its menu is open', function () { - $trailing = "document.querySelector('[data-split=\"trailing\"]')"; + $trailing = "document.querySelector('[data-md-split=\"trailing\"]')"; showcase() - ->click('[data-split="trailing"] >> nth=0') + ->click('[data-md-split="trailing"] >> nth=0') ->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'") ->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'"); }); @@ -235,7 +235,7 @@ it('keeps a connected segment\'s small inner corners, which a 9999px outer corne // factor: a full corner written as 9999px drew the 8px inner corners square. Every radius // in a connected group or a split button stays within half its height, so none is scaled. showcase() - ->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })") + ->assertScript("[...document.querySelectorAll('[data-md-button-group=\"connected\"] > *, [data-md-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })") ->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)"); }); @@ -464,3 +464,52 @@ it('closes a FAB menu when an item\'s action runs, and opens and closes it clean ->assertScript('! '.FAB_MENU.".matches(':popover-open')") ->assertScript(focused("getAttribute('aria-label') === 'New'")); }); + +it('takes a press on a small connected segment at its 48px edge, past what it draws', function () { + // M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width". + showcase()->assertScript(<<<'JS' + (() => { + const segment = document.querySelector('#buttons input[name="showcase-theme"][value="light"]').parentElement + segment.scrollIntoView({ block: 'center' }) + const box = segment.getBoundingClientRect() + const x = box.left + box.width / 2 + return box.height === 40 && box.width >= 48 + && document.elementFromPoint(x, box.top - 3) === segment + && document.elementFromPoint(x, box.bottom + 3) === segment + })() + JS); +}); + +it('keeps one choice in a single, required selection group', function () { + $group = '#buttons [data-md-selection="single"]'; + $view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent"; + + $page = showcase(); + + $page->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'true') + ->click("{$group} button:has-text(\"Day\")") + ->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true') + ->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'false') + ->assertScript("{$view} === 'day'"); + + // Required: pressing the chosen button again leaves it chosen. + $page->click("{$group} button:has-text(\"Day\")") + ->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true') + ->assertScript("{$view} === 'day'"); +}); + +it('toggles each choice of a multi selection group, down to none', function () { + $group = '#buttons [data-md-selection="multi"]'; + $marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent"; + + showcase() + ->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true') + ->click("{$group} [aria-label=\"Italic\"]") + ->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true') + ->assertScript("{$marks} === 'bold, italic'") + ->click("{$group} [aria-label=\"Bold\"]") + ->click("{$group} [aria-label=\"Italic\"]") + ->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false') + ->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false') + ->assertScript("{$marks} === 'none'"); +}); diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 1c6b76ac..4496c538 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -17,6 +17,7 @@ dataset('action components', [ 'button', 'group', 'button-group', + 'split-button', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { @@ -37,8 +38,9 @@ it('imports the stylesheet of every component its view renders', function (strin preg_match_all('/unique() - // A component still written for Tailwind has no stylesheet of its own to import yet. - ->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag))) + // A component still written for Tailwind has no stylesheet of its own to import yet; its + // old stylesheet, if it has one, lives in tailwind.css without the layer statement. + ->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components')) ->map(fn (string $tag): string => "./{$tag}.css") ->values() ->all(); diff --git a/tests/Feature/Components/SplitButtonTest.php b/tests/Feature/Components/SplitButtonTest.php index f312e843..ab6c6e26 100644 --- a/tests/Feature/Components/SplitButtonTest.php +++ b/tests/Feature/Components/SplitButtonTest.php @@ -1,5 +1,7 @@ blade(<<<'BLADE' @@ -8,27 +10,34 @@ it('puts the action on the leading button and the menu on the trailing one', fun BLADE); expect($html) - ->toContain('data-button-group="split"') - ->toContain('mt-4') - ->toContain('data-split="leading"') - ->toContain('wire:click="downloadZip"') - ->toContain('data-split="trailing"') - ->toContain('aria-label="Download options"') + ->toStartWith('
') + ->toMatch('/