Add the stack layout component and the layout foundation

Plan step 35: <x-stack gap align>, the first of M3's layout components,
with what they all share - src/Support/Layout.php reading the props into
data-md-* attributes, the spacing-token gap and padding rules, the
hide-below/hide-from rules in material.visibility, the stylesheet checks
for resources/css/layout, the browser test file and the skill's Layout
group. The Workbench puts the material layers above Tailwind's preflight,
which would otherwise zero every layout padding and margin.

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 14:50:27 +02:00
co-authored by Claude Opus 5
parent 6be9f35c98
commit b677e60876
13 changed files with 724 additions and 3 deletions
@@ -770,6 +770,23 @@ The docked view overlaps what is under it; never place a search inside an elemen
</x-search>
```
### Layout
M3's layout vocabulary as components (M3 foundations § Layout: scaffold, bars, rails, panes, margins, spacers, and the canonical layouts). `<x-scaffold>` holds the bars, the rail and the FAB around the page; all content lives in panes, `<x-pane>`; two panes side by side are the canonical layouts `<x-list-detail>` and `<x-supporting-pane>`, and `<x-feed>` is the third; `<x-surface>` is a tonal region; inside a pane, `<x-stack>`, `<x-row>` and `<x-grid>` arrange. There is no "page" component in M3 — a page is a pane. Their stylesheets are `resources/css/layout/*.css`, all imported by `resources/css/layout.css`; the list-detail's focus handling is `resources/js/layout.js`, in `material.js`.
Breakpoints are M3's five, in px: compact below 600, `medium` 600, `expanded` 840, `large` 1200, `extra-large` 1600. Every layout component takes:
- `as`: the element, `div` unless the component says otherwise — `section`, `article`, `aside`, `main`, `nav`, `header`, `footer`, `ul`, `ol`, `li`, `dl`, `form`, `fieldset`, `figure`, `span`, `p`; anything else draws the default.
- `hide-below` / `hide-from`: `medium`, `expanded`, `large` or `extra-large`. Hidden on a window narrower than that breakpoint, or from it on, over the component's own `display` (the `material.visibility` layer). Nothing is below compact, so neither takes `compact`.
- `gap` and `padding` take only a spacing token's name, `space25``space900`; any other value is no gap or no padding.
- The caller's `class` and `style` land on the root untouched, and an application's own CSS outranks every package rule.
M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an `<x-surface>`, a new edge, draws it again.
#### `<x-stack>`
Children one under another inside a pane: `<x-stack gap="space200">…</x-stack>`. `align` across it: `stretch` (default), `start`, `center`, `end`.
### `<x-app-shell>`
The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
+2
View File
@@ -5,3 +5,5 @@
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './layout/stack.css';
+126
View File
@@ -0,0 +1,126 @@
/*
* Gap and padding, the two spacing props of the layout components, on M3's measurement scale.
*
* Both take only a token's name — `data-md-gap="space200"`, `data-md-padding="space300"` — and the
* value is that token, `--md-sys-measurement-space200` (docs/reference/m3/styles-supplement.md
* § Spacing; resources/css/tokens/spacing.css). A name the scale does not have renders as `none`
* (src/Support/Layout.php): no gap, and no padding.
*
* A gap is written to `--md-gap`, not to `gap` itself: the stack, row, grid and feed read it (a
* grid also counts it into a column's width), and each resets it on itself at zero specificity, so
* an arrangement nested in another never inherits its parent's gap. Padding goes on directly.
*
* In `material.layout`, under every component.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.layout {
[data-md-gap='none'] {
--md-gap: 0px;
}
[data-md-gap='space25'] {
--md-gap: var(--md-sys-measurement-space25);
}
[data-md-gap='space50'] {
--md-gap: var(--md-sys-measurement-space50);
}
[data-md-gap='space75'] {
--md-gap: var(--md-sys-measurement-space75);
}
[data-md-gap='space100'] {
--md-gap: var(--md-sys-measurement-space100);
}
[data-md-gap='space125'] {
--md-gap: var(--md-sys-measurement-space125);
}
[data-md-gap='space200'] {
--md-gap: var(--md-sys-measurement-space200);
}
[data-md-gap='space300'] {
--md-gap: var(--md-sys-measurement-space300);
}
[data-md-gap='space400'] {
--md-gap: var(--md-sys-measurement-space400);
}
[data-md-gap='space500'] {
--md-gap: var(--md-sys-measurement-space500);
}
[data-md-gap='space600'] {
--md-gap: var(--md-sys-measurement-space600);
}
[data-md-gap='space700'] {
--md-gap: var(--md-sys-measurement-space700);
}
[data-md-gap='space800'] {
--md-gap: var(--md-sys-measurement-space800);
}
[data-md-gap='space900'] {
--md-gap: var(--md-sys-measurement-space900);
}
[data-md-padding='space25'] {
padding: var(--md-sys-measurement-space25);
}
[data-md-padding='space50'] {
padding: var(--md-sys-measurement-space50);
}
[data-md-padding='space75'] {
padding: var(--md-sys-measurement-space75);
}
[data-md-padding='space100'] {
padding: var(--md-sys-measurement-space100);
}
[data-md-padding='space125'] {
padding: var(--md-sys-measurement-space125);
}
[data-md-padding='space200'] {
padding: var(--md-sys-measurement-space200);
}
[data-md-padding='space300'] {
padding: var(--md-sys-measurement-space300);
}
[data-md-padding='space400'] {
padding: var(--md-sys-measurement-space400);
}
[data-md-padding='space500'] {
padding: var(--md-sys-measurement-space500);
}
[data-md-padding='space600'] {
padding: var(--md-sys-measurement-space600);
}
[data-md-padding='space700'] {
padding: var(--md-sys-measurement-space700);
}
[data-md-padding='space800'] {
padding: var(--md-sys-measurement-space800);
}
[data-md-padding='space900'] {
padding: var(--md-sys-measurement-space900);
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* <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`
* or `end`. 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 <x-row> and <x-grid>.
*
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './spacing.css';
@import './visibility.css';
@layer material.layout {
:where([data-md-stack]) {
--md-gap: 0px;
}
[data-md-stack] {
display: flex;
flex-direction: column;
gap: var(--md-gap);
}
[data-md-stack][data-md-align='start'] {
align-items: flex-start;
}
[data-md-stack][data-md-align='center'] {
align-items: center;
}
[data-md-stack][data-md-align='end'] {
align-items: flex-end;
}
}
+64
View File
@@ -0,0 +1,64 @@
/*
* `hide-below` and `hide-from`, the visibility props every layout component takes.
*
* The breakpoints are M3's (docs/reference/m3/foundations.md § Layout → Breakpoints): compact below
* 600px, medium 600, expanded 840, large 1200, extra-large 1600, in px — a dp is a CSS pixel, so a
* larger text size never moves them. `data-md-hide-below="expanded"` hides an element on a window
* narrower than 840px; `data-md-hide-from="expanded"` hides it from 840px on. There is nothing
* below compact and from compact is every width, so neither names it; `hidden` does that.
*
* In `material.visibility`, the last layer, so it beats the `display` of the component it sits on
* and of every other package rule. An application's own unlayered rule still wins, by design.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.visibility {
@media (width < 600px) {
[data-md-hide-below='medium'] {
display: none;
}
}
@media (width < 840px) {
[data-md-hide-below='expanded'] {
display: none;
}
}
@media (width < 1200px) {
[data-md-hide-below='large'] {
display: none;
}
}
@media (width < 1600px) {
[data-md-hide-below='extra-large'] {
display: none;
}
}
@media (width >= 600px) {
[data-md-hide-from='medium'] {
display: none;
}
}
@media (width >= 840px) {
[data-md-hide-from='expanded'] {
display: none;
}
}
@media (width >= 1200px) {
[data-md-hide-from='large'] {
display: none;
}
}
@media (width >= 1600px) {
[data-md-hide-from='extra-large'] {
display: none;
}
}
}
@@ -0,0 +1,33 @@
{{-- Children one under another, inside a pane.
<x-stack gap="space200"></x-stack>
`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`.
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`,
`hide-below` and `hide-from` like every layout component, and the caller's `class` and
`style` land on it untouched. Drawn by resources/css/layout/stack.css. --}}
@props([
'as' => null,
'gap' => null,
'align' => null,
'hideBelow' => null,
'hideFrom' => null,
])
@php
$layout = \NoNameWeb\LivewireMaterial\Support\Layout::class;
$element = $layout::element($as);
$attributes = $attributes->merge(array_filter([
'data-md-stack' => true,
'data-md-gap' => $layout::spacing($gap),
'data-md-align' => $layout::choice($align, ['stretch', 'start', 'center', 'end']),
] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null));
@endphp
<{{ $element }} {{ $attributes }}>{{ $slot }}</{{ $element }}>
@@ -102,4 +102,18 @@
content margin (<code>--material-margin</code>) change at 600, 840 and 1200px. A visitor who has chosen a rail
width keeps it; until then the rail follows the class.
</p>
<x-livewire-material::stack gap="space200" data-test="layout-components">
<h3 class="md-type-title-lg">The layout components</h3>
<p class="md-type-body-md md-ink-variant">
M3 describes a layout as a scaffold of bars and rails around panes, and names three canonical layouts. These are
those words as components, each taking <code>as</code>, <code>hide-below</code> and <code>hide-from</code>, and
only the spacing tokens for a gap or padding:
</p>
<x-livewire-material::stack as="ul" gap="space100" class="md-type-body-md">
<li><code>&lt;x-stack&gt;</code>, <code>&lt;x-row&gt;</code> and <code>&lt;x-grid&gt;</code> — arrangement inside a pane.</li>
</x-livewire-material::stack>
</x-livewire-material::stack>
</section>