Keep a button at its label's width in a stack and a form

<x-stack> stretches its children across by default and <x-form> 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) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 12:56:45 +02:00
co-authored by Claude Opus 5
parent f5f040516e
commit 92d3d761f3
8 changed files with 58 additions and 5 deletions
+2
View File
@@ -85,6 +85,8 @@ over a colour, use the role directly.
- `<x-list dividers>` draws a 16px-inset rule; `<x-list selectable>` or `selection="single|multi"` - `<x-list dividers>` draws a 16px-inset rule; `<x-list selectable>` or `selection="single|multi"`
makes it a `listbox` of `option`s with `aria-selected`. makes it a `listbox` of `option`s with `aria-selected`.
- `<x-card>` changes elevation on hover instead of its corner; `data-md-card` carries the variant. - `<x-card>` changes elevation on hover instead of its corner; `data-md-card` carries the variant.
- A button directly in `<x-stack>` (stretched, the default) or `<x-form>` 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.
- `<x-modal>` pins its header and actions and scrolls only the body; a `box-class` that set - `<x-modal>` pins its header and actions and scrolls only the body; a `box-class` that set
`overflow` no longer applies. `overflow` no longer applies.
- `<x-table>` rows are 52px and the automatic fine-pointer density is gone: pass `dense` for 36px - `<x-table>` rows are 52px and the automatic fine-pointer density is gone: pass `dense` for 36px
@@ -611,7 +611,7 @@ With `scroll`, M3's overflow affordance is drawn for you: the edge the row can s
### `<x-form>` ### `<x-form>`
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 ```blade
<x-form wire:submit="save"> <x-form wire:submit="save">
@@ -925,7 +925,7 @@ A tonal region: `<x-surface level="surface-container-low" padding="space300" cor
#### `<x-stack>` #### `<x-stack>`
Children one under another inside a pane: `<x-stack gap="space200">…</x-stack>`. `align` across it: `stretch` (default), `start`, `center`, `end`. Children one under another inside a pane: `<x-stack gap="space200">…</x-stack>`. `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.
#### `<x-row>` #### `<x-row>`
+8 -1
View File
@@ -6,7 +6,10 @@
* floors at its content's min-content, so on a phone a wide field would push the form past its * 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 * 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 * 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 * 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. * 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); gap: var(--md-sys-measurement-space200);
} }
[data-md-form] > [data-md-button] {
justify-self: start;
}
[data-md-form-actions] { [data-md-form-actions] {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+8 -1
View File
@@ -2,7 +2,10 @@
* <x-stack>: its children one under another, in a pane. * <x-stack>: its children one under another, in a pane.
* *
* A flex column, stretched across the stack's width unless `data-md-align` says `start`, `center` * 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 * 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 * (docs/reference/m3/foundations.md § Layout Grids & spacing). M3 defines no in-pane arrangement
* component; this is the neutral one, beside <x-row> and <x-grid>. * component; this is the neutral one, beside <x-row> and <x-grid>.
@@ -26,6 +29,10 @@
gap: var(--md-gap); 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'] { [data-md-stack][data-md-align='start'] {
align-items: flex-start; align-items: flex-start;
} }
+2 -1
View File
@@ -4,7 +4,8 @@
`gap` is a spacing token's name, `space25` `space900` (docs/reference/m3/styles-supplement.md `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: § 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 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 `<x-row>` and `<x-grid>`. It takes `as`, this is the package's neutral one, beside `<x-row>` and `<x-grid>`. It takes `as`,
+23
View File
@@ -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'"); ->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 = '<style>.probe-full { inline-size: 100%; }</style>'
.'<x-stack id="stack" gap="space200" style="inline-size: 600px"><p id="stack-field">Name</p><x-button label="Save" id="stack-save" /><x-button label="Wide" class="probe-full" id="stack-wide" /></x-stack>'
.'<x-stack id="centred" align="center" style="inline-size: 600px"><x-button label="Save" id="centred-save" /></x-stack>'
.'<x-form id="form" style="inline-size: 600px"><x-input label="Name" /><x-button label="Go" id="form-go" /></x-form>';
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 () { it('stacks a row below its breakpoint and lays it side by side from it', function () {
$body = '<x-row id="row" gap="space200" stack-below="medium"><span>One</span><span>Two</span></x-row>' $body = '<x-row id="row" gap="space200" stack-below="medium"><span>One</span><span>Two</span></x-row>'
.'<x-row id="aligned" align="start" stack-below="medium"><span>One</span></x-row>' .'<x-row id="aligned" align="start" stack-below="medium"><span>One</span></x-row>'
+6
View File
@@ -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/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/file.css';"); ->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']);
});
+7
View File
@@ -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;") ->toContain(":where([data-md-stack]) {\n --md-gap: 0px;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/stack.css';"); ->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 }");
});