From 92d3d761f3558f0ed6c3523ae6a56545e1e24283 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 12:56:45 +0200 Subject: [PATCH] Keep a button at its label's width in a stack and a form stretches its children across by default and is a one-track grid, so a button written directly in either drew as a full-width pill. M3 keeps a button's width "dynamic to fit label" and says not to "stretch buttons into long flat shapes on large windows". A button there now sits at the start edge at its own width; a stack aligned start, centre or end is untouched, and an application's own width still wins (a cap on the button itself was rejected for exactly that: it would have limited an app's inline-size, which CascadeTest guards). Chromium test in both directions, failing without the rules. Decided with the user (plan step 46). Co-Authored-By: Claude Opus 5 (1M context) --- UPGRADE.md | 2 ++ .../livewire-material-development/SKILL.md | 4 ++-- resources/css/components/form.css | 9 +++++++- resources/css/layout/stack.css | 9 +++++++- resources/views/components/stack.blade.php | 3 ++- tests/Browser/LayoutTest.php | 23 +++++++++++++++++++ tests/Feature/Components/FieldTest.php | 6 +++++ tests/Feature/Components/StackTest.php | 7 ++++++ 8 files changed, 58 insertions(+), 5 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 881cfc6c..8471fd27 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -85,6 +85,8 @@ over a colour, use the role directly. - `` draws a 16px-inset rule; `` or `selection="single|multi"` makes it a `listbox` of `option`s with `aria-selected`. - `` changes elevation on hover instead of its corner; `data-md-card` carries the variant. +- A button directly in `` (stretched, the default) or `` keeps its label's width at + the start edge instead of filling the width, as M3 asks; a full-width submit is your own CSS. - `` pins its header and actions and scrolls only the body; a `box-class` that set `overflow` no longer applies. - `` rows are 52px and the automatic fine-pointer density is gone: pass `dense` for 36px diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 80431369..eac83fc2 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -611,7 +611,7 @@ With `scroll`, M3's overflow affordance is drawn for you: the edge the row can s ### `` -A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions. +A one-column grid of fields with an `actions` slot at the foot, end-aligned (the slot takes its own `class`); `separator` draws a divider above the actions. Put a form's buttons in `actions`; a button written among the fields keeps its label's width at the start edge rather than stretching across. ```blade @@ -925,7 +925,7 @@ A tonal region: `` -Children one under another inside a pane: ``. `align` across it: `stretch` (default), `start`, `center`, `end`. +Children one under another inside a pane: ``. `align` across it: `stretch` (default), `start`, `center`, `end`. A button keeps its label's width at the start edge even when the rest stretch: M3 keeps a button's width dynamic and never stretches one into a long flat shape. Widen one only in your own CSS, and only with a reason. #### `` diff --git a/resources/css/components/form.css b/resources/css/components/form.css index eb10110b..411694e7 100644 --- a/resources/css/components/form.css +++ b/resources/css/components/form.css @@ -6,7 +6,10 @@ * floors at its content's min-content, so on a phone a wide field would push the form past its * pane. Rows take their content's height. 16px (`space200`) between fields, because a text field * floats its label half above its outline and any less lets the hint under one field run into the - * label of the next. + * label of the next. A button written among the fields keeps its label's width at the start edge, + * where the grid would stretch it across: M3 keeps a button's width "dynamic to fit label" and + * says not to "stretch buttons into long flat shapes" + * (docs/reference/m3/components-actions-communication-containment.md § Buttons). * * The actions wrap, end-aligned, 8px (`space100`) apart — M3's gap between buttons in a row. A * caller's class on the `actions` slot lands on the row and outranks this layer. @@ -27,6 +30,10 @@ gap: var(--md-sys-measurement-space200); } + [data-md-form] > [data-md-button] { + justify-self: start; + } + [data-md-form-actions] { display: flex; flex-wrap: wrap; diff --git a/resources/css/layout/stack.css b/resources/css/layout/stack.css index d7f3ac94..570da085 100644 --- a/resources/css/layout/stack.css +++ b/resources/css/layout/stack.css @@ -2,7 +2,10 @@ * : its children one under another, in a pane. * * A flex column, stretched across the stack's width unless `data-md-align` says `start`, `center` - * or `end`. The space between the children is `--md-gap` (spacing.css), one of M3's spacing + * or `end` — except a button, which keeps its label's width at the start edge: M3 keeps a button's + * width "dynamic to fit label" and says not to "stretch buttons into long flat shapes on large + * windows" (docs/reference/m3/components-actions-communication-containment.md § Buttons). An + * application's own width or `align-self` on it still wins. The space between the children is `--md-gap` (spacing.css), one of M3's spacing * tokens and none by default; M3 groups with proximity, so the gap is the grouping * (docs/reference/m3/foundations.md § Layout → Grids & spacing). M3 defines no in-pane arrangement * component; this is the neutral one, beside and . @@ -26,6 +29,10 @@ gap: var(--md-gap); } + [data-md-stack]:is(:not([data-md-align]), [data-md-align='stretch']) > [data-md-button] { + align-self: start; + } + [data-md-stack][data-md-align='start'] { align-items: flex-start; } diff --git a/resources/views/components/stack.blade.php b/resources/views/components/stack.blade.php index b3937e1e..300b2ed9 100644 --- a/resources/views/components/stack.blade.php +++ b/resources/views/components/stack.blade.php @@ -4,7 +4,8 @@ `gap` is a spacing token's name, `space25` … `space900` (docs/reference/m3/styles-supplement.md § Spacing), and none when left out or unknown. `align` places the children across the stack: - `stretch` (the default), `start`, `center` or `end`. + `stretch` (the default), `start`, `center` or `end`. A button keeps its label's width even + when the rest stretch, as M3 asks. M3's layout has no arrangement component inside a pane — its "column" is a grid column — so this is the package's neutral one, beside `` and ``. It takes `as`, diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index d57f824b..7b94d3c5 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -72,6 +72,29 @@ it('spaces a stack with its token, never with its parent\'s, and hides it from a ->assertScript(layoutStyle('#hidden-from', 'display')." === 'none'"); }); +it('keeps a button at its label\'s width in a stretched stack and a form, where the rest stretch', function () { + // M3: keep a button's width "dynamic to fit label"; don't "stretch buttons into long flat shapes + // on large windows". An application's own width still wins, and a centred stack centres it. + $body = '' + .'

Name

' + .'' + .''; + + layoutPage($body, 1280) + ->assertScript(layoutRect('#stack-field', 'width').' === 600') + ->assertScript(layoutRect('#stack-save', 'width').' < 200') + ->assertScript(layoutRect('#stack-save', 'left').' === '.layoutRect('#stack', 'left')) + ->assertScript(layoutRect('#stack-wide', 'width').' === 600') + ->assertScript('Math.abs(('.layoutRect('#centred-save', 'left').' + '.layoutRect('#centred-save', 'right').') / 2 - ('.layoutRect('#centred', 'left').' + '.layoutRect('#centred', 'right').') / 2) <= 1') + ->assertScript(layoutRect('#form-go', 'width').' < 200') + ->assertScript(layoutRect('#form-go', 'left').' === '.layoutRect('#form', 'left')); + + // In a right-to-left document the start edge is the right. + layoutPage($body, 1280, 900, 'rtl') + ->assertScript(layoutRect('#stack-save', 'right').' === '.layoutRect('#stack', 'right')) + ->assertScript(layoutRect('#form-go', 'right').' === '.layoutRect('#form', 'right')); +}); + it('stacks a row below its breakpoint and lays it side by side from it', function () { $body = 'OneTwo' .'One' diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index c7eca4d8..c1b53c1d 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -297,3 +297,9 @@ it('draws the file picker\'s button from its own stylesheet', function () { ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']") ->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/file.css';"); }); + +it('keeps a button written among a form\'s fields at its label\'s width', function () { + // M3: keep a button's width "dynamic to fit label"; don't "stretch buttons into long flat shapes". + expect(ComponentStylesheet::read('form')->declarations('[data-md-form] > [data-md-button]')) + ->toBe(['justify-self' => 'start']); +}); diff --git a/tests/Feature/Components/StackTest.php b/tests/Feature/Components/StackTest.php index 3eb3d5c2..6b5aff7b 100644 --- a/tests/Feature/Components/StackTest.php +++ b/tests/Feature/Components/StackTest.php @@ -41,3 +41,10 @@ it('draws the stack from a stylesheet in the layout layer, imported by all.css', ->toContain(":where([data-md-stack]) {\n --md-gap: 0px;") ->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/stack.css';"); }); + +it('keeps a button at its label\'s width while the stack stretches the rest', function () { + // M3: keep a button's width "dynamic to fit label"; don't "stretch buttons into long flat shapes". + $css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/stack.css'); + + expect($css)->toContain("[data-md-stack]:is(:not([data-md-align]), [data-md-align='stretch']) > [data-md-button] {\n align-self: start;\n }"); +});