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('/