diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index aea65cbc..b2f06272 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -787,6 +787,14 @@ M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: Children one under another inside a pane: ``. `align` across it: `stretch` (default), `start`, `center`, `end`. +#### `` + +Children side by side inside a pane: ``. + +- `align` across it: `center` (default), `start`, `end`, `stretch`, `baseline`. `justify` along it: `start` (default), `center`, `end`, `between`. `wrap` lets them wrap. +- `stack-below`: `medium`, `expanded`, `large` or `extra-large` — below that breakpoint the row is a column, its children stretched unless `align` was given. +- It runs in the inline direction, so it mirrors in a right-to-left document by itself. + ### `` The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `
` behind a skip link, and the snackbar host (do not add another ``). It needs `` in ``. diff --git a/resources/css/layout.css b/resources/css/layout.css index 63fbd6e6..01f763a3 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -7,3 +7,4 @@ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @import './layout/stack.css'; +@import './layout/row.css'; diff --git a/resources/css/layout/row.css b/resources/css/layout/row.css new file mode 100644 index 00000000..0f0c03a2 --- /dev/null +++ b/resources/css/layout/row.css @@ -0,0 +1,111 @@ +/* + * : its children side by side, in a pane, stacked under one another below a breakpoint. + * + * A flex row in the inline direction, so it runs right to left in a right-to-left document, as M3's + * bidirectionality rules ask of every layout (docs/reference/m3/foundations.md § Layout → + * Bidirectionality / RTL). Children are centred across the row unless `data-md-align` says + * `start`, `end`, `stretch` or `baseline`; `data-md-justify` places them along it (`start`, + * `center`, `end`, `between`); `data-md-wrap` lets them wrap. The space between them is `--md-gap` + * (spacing.css). + * + * `data-md-stack-below` turns the row into a column on a window narrower than the breakpoint it + * names (M3's, in px: medium 600, expanded 840, large 1200, extra-large 1600) — M3's "reposition" + * as the window shrinks (§ Layout → Breakpoints). A stacked row stretches its children across its + * width unless an `align` was given. + * + * 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-row]) { + --md-gap: 0px; + } + + [data-md-row] { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--md-gap); + } + + [data-md-row][data-md-align='start'] { + align-items: flex-start; + } + + [data-md-row][data-md-align='end'] { + align-items: flex-end; + } + + [data-md-row][data-md-align='stretch'] { + align-items: stretch; + } + + [data-md-row][data-md-align='baseline'] { + align-items: baseline; + } + + [data-md-row][data-md-justify='start'] { + justify-content: flex-start; + } + + [data-md-row][data-md-justify='center'] { + justify-content: center; + } + + [data-md-row][data-md-justify='end'] { + justify-content: flex-end; + } + + [data-md-row][data-md-justify='between'] { + justify-content: space-between; + } + + [data-md-row][data-md-wrap] { + flex-wrap: wrap; + } + + @media (width < 600px) { + [data-md-row][data-md-stack-below='medium'] { + flex-direction: column; + } + + [data-md-row][data-md-stack-below='medium']:not([data-md-align]) { + align-items: stretch; + } + } + + @media (width < 840px) { + [data-md-row][data-md-stack-below='expanded'] { + flex-direction: column; + } + + [data-md-row][data-md-stack-below='expanded']:not([data-md-align]) { + align-items: stretch; + } + } + + @media (width < 1200px) { + [data-md-row][data-md-stack-below='large'] { + flex-direction: column; + } + + [data-md-row][data-md-stack-below='large']:not([data-md-align]) { + align-items: stretch; + } + } + + @media (width < 1600px) { + [data-md-row][data-md-stack-below='extra-large'] { + flex-direction: column; + } + + [data-md-row][data-md-stack-below='extra-large']:not([data-md-align]) { + align-items: stretch; + } + } +} diff --git a/resources/views/components/row.blade.php b/resources/views/components/row.blade.php new file mode 100644 index 00000000..1420c351 --- /dev/null +++ b/resources/views/components/row.blade.php @@ -0,0 +1,44 @@ +{{-- Children side by side, inside a pane; under one another below a breakpoint. + + + + `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 row: + `center` (the default), `start`, `end`, `stretch` or `baseline`. `justify` places them along + it: `start` (the default), `center`, `end` or `between`. `wrap` lets them wrap onto more lines. + + `stack-below` names a breakpoint — `medium` (600px), `expanded` (840), `large` (1200) or + `extra-large` (1600), M3's in px (docs/reference/m3/foundations.md § Layout → Breakpoints) — + below which the row is a column: M3's "reposition" as the window narrows. A stacked row + stretches its children unless `align` was given. + + The row runs in the inline direction, so it mirrors in a right-to-left document by itself. 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/row.css. --}} + +@props([ + 'as' => null, + 'gap' => null, + 'align' => null, + 'justify' => null, + 'wrap' => false, + 'stackBelow' => null, + 'hideBelow' => null, + 'hideFrom' => null, +]) + +@php + $layout = \NoNameWeb\LivewireMaterial\Support\Layout::class; + $element = $layout::element($as); + + $attributes = $attributes->merge(array_filter([ + 'data-md-row' => true, + 'data-md-gap' => $layout::spacing($gap), + 'data-md-align' => $layout::choice($align, ['center', 'start', 'end', 'stretch', 'baseline']), + 'data-md-justify' => $layout::choice($justify, ['start', 'center', 'end', 'between']), + 'data-md-wrap' => $wrap ? true : null, + 'data-md-stack-below' => $layout::edge($stackBelow), + ] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null)); +@endphp + +<{{ $element }} {{ $attributes }}>{{ $slot }} diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index 01648768..378c8815 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -72,3 +72,26 @@ it('spaces a stack with its token, never with its parent\'s, and hides it from a layoutPage($body, 600) ->assertScript(layoutStyle('#hidden-from', 'display')." === 'none'"); }); + +it('stacks a row below its breakpoint and lays it side by side from it', function () { + $body = 'OneTwo' + .'One' + .'Medium and up'; + + layoutPage($body, 599) + ->assertScript(layoutStyle('#row', 'flexDirection')." === 'column'") + ->assertScript(layoutStyle('#row', 'alignItems')." === 'stretch'") + ->assertScript(layoutStyle('#aligned', 'alignItems')." === 'flex-start'") + ->assertScript(layoutStyle('#row', 'rowGap')." === '16px'") + ->assertScript(layoutStyle('#hidden', 'display')." === 'none'"); + + layoutPage($body, 600) + ->assertScript(layoutStyle('#row', 'flexDirection')." === 'row'") + ->assertScript(layoutStyle('#row', 'alignItems')." === 'center'") + ->assertScript(layoutStyle('#hidden', 'display')." === 'flex'"); +}); + +it('mirrors a row in a right-to-left document', function () { + layoutPage('FirstSecond', 800, 600, 'rtl') + ->assertScript(layoutRect('#first', 'left').' > '.layoutRect('#second', 'left')); +}); diff --git a/tests/Feature/Components/RowTest.php b/tests/Feature/Components/RowTest.php new file mode 100644 index 00000000..af28fb8f --- /dev/null +++ b/tests/Feature/Components/RowTest.php @@ -0,0 +1,60 @@ +blade('OneTwo')); + + expect($root)->toMatchArray(['<' => 'div', 'data-md-row' => 'data-md-row']) + ->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-justify', 'data-md-wrap', 'data-md-stack-below']); +}); + +it('spaces its children with a spacing token only, and with none for anything else', function () { + expect(layoutRoot((string) $this->blade(''))['data-md-gap'])->toBe('space100') + ->and(layoutRoot((string) $this->blade(''))['data-md-gap'])->toBe('none'); +}); + +it('aligns its children across it and places them along it', function () { + foreach (['center', 'start', 'end', 'stretch', 'baseline'] as $align) { + expect(layoutRoot((string) $this->blade('', ['align' => $align]))['data-md-align'])->toBe($align); + } + + foreach (['start', 'center', 'end', 'between'] as $justify) { + expect(layoutRoot((string) $this->blade('', ['justify' => $justify]))['data-md-justify'])->toBe($justify); + } + + expect(layoutRoot((string) $this->blade('')))->not->toHaveKeys(['data-md-align', 'data-md-justify']); +}); + +it('wraps only when asked', function () { + expect(layoutRoot((string) $this->blade('')))->toHaveKey('data-md-wrap') + ->and(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-wrap'); +}); + +it('stacks below one of M3\'s breakpoints, and never below compact', function () { + foreach (['medium', 'expanded', 'large', 'extra-large'] as $breakpoint) { + expect(layoutRoot((string) $this->blade('', ['b' => $breakpoint]))['data-md-stack-below'])->toBe($breakpoint); + } + + foreach (['compact', 'tablet', '600'] as $breakpoint) { + expect(layoutRoot((string) $this->blade('', ['b' => $breakpoint])))->not->toHaveKey('data-md-stack-below'); + } +}); + +it('takes the element, the visibility props and the caller\'s class and style', function () { + expect(layoutRoot((string) $this->blade(''))) + ->toMatchArray([ + '<' => 'nav', + 'data-md-hide-from' => 'expanded', + 'class' => 'toolbar-row', + 'style' => 'min-height: 3rem;', + 'aria-label' => 'Filters', + ]); +}); + +it('draws the row from a stylesheet in the layout layer, stacking at the breakpoint in px', function () { + $css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/row.css'); + + expect($css)->toContain('@layer material.layout') + ->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;") + ->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;") + ->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/row.css';"); +});