Draw the split button without Tailwind

Plan step 36. <x-split-button> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:03:02 +02:00
co-authored by Claude Opus 5
parent 8dd0a7ef9d
commit 2a4dcead3d
8 changed files with 225 additions and 120 deletions
+1
View File
@@ -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 */
-81
View File
@@ -1,81 +0,0 @@
/*
* Split buttons: shapes that depend on a button's place beside its other half. Button groups and
* `<x-group>` draw theirs from resources/css/components/button-group.css.
*
* Unlayered on purpose, until the split button leaves Tailwind too.
*
* Split button (`<x-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;
}
+115
View File
@@ -0,0 +1,115 @@
/*
* <x-split-button>: an action and a menu of its alternatives, as two `<x-button>`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`). `<x-button>`'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;
}
}
-1
View File
@@ -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 */