diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index b2f06272..55c8832e 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -795,6 +795,19 @@ Children side by side inside a pane: `` + +Children in columns inside a pane. + +```blade + + +``` + +- `columns`: a map from breakpoint to count, or one number for all. A breakpoint left out takes the nearest smaller one's count; compact is 1 unless given. Each grid writes all five (`--md-columns-compact` … `--md-columns-extra-large`), so a grid inside another never inherits its parent's. +- `min-item` fills each row with as many columns as fit at that width — following the grid's own width, the choice inside a pane narrower than the window. With `columns` too, the counts are a ceiling. +- Children keep their source order. M3 publishes no column table for the web; choose counts by the content. + ### `` 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 01f763a3..cc9cf0d4 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -8,3 +8,4 @@ @import './layout/stack.css'; @import './layout/row.css'; +@import './layout/grid.css'; diff --git a/resources/css/layout/grid.css b/resources/css/layout/grid.css new file mode 100644 index 00000000..3722e91f --- /dev/null +++ b/resources/css/layout/grid.css @@ -0,0 +1,68 @@ +/* + * : children in columns, in a pane, with a column count per breakpoint. + * + * The count at each of M3's breakpoints arrives as five inline custom properties, + * `--md-columns-compact` … `--md-columns-extra-large`, every one written on every grid, each filled + * from the nearest smaller breakpoint (src/Support/Layout.php). One rule per breakpoint picks its + * own into `--md-columns` — compact below 600px, then medium 600, expanded 840, large 1200 and + * extra-large 1600, in px (docs/reference/m3/foundations.md § Layout → Breakpoints) — so a grid + * nested in another reads its own five and never its parent's. M3 publishes no column table for + * the web ("no explicit numeric column-count or gutter table is given on the current site", same + * section), only that columns grow with the breakpoint; the counts are the caller's. + * + * `data-md-min-item` (`--md-min-item`, a length) fills the row with as many columns as fit at that + * minimum width instead, the room a pane actually has deciding rather than the window. With both, + * the count is a ceiling: never more columns than the breakpoint's count, never narrower than the + * minimum. The column width the ceiling implies — the grid's width, less its gaps, over the count — + * needs the gap as a length, which is why a gap is `--md-gap` (spacing.css) and not `gap` alone. + * Every column is `minmax(0, 1fr)` or at most the grid's width, so a long word never pushes a + * column past the pane. Children keep their source order, which is the reading order. + * + * 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-grid]) { + --md-gap: 0px; + } + + [data-md-grid] { + --md-columns: var(--md-columns-compact, 1); + + display: grid; + grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr)); + gap: var(--md-gap); + + @media (width >= 600px) { + --md-columns: var(--md-columns-medium, 1); + } + + @media (width >= 840px) { + --md-columns: var(--md-columns-expanded, 1); + } + + @media (width >= 1200px) { + --md-columns: var(--md-columns-large, 1); + } + + @media (width >= 1600px) { + --md-columns: var(--md-columns-extra-large, 1); + } + } + + [data-md-grid][data-md-min-item] { + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--md-min-item)), 1fr)); + } + + [data-md-grid][data-md-min-item][data-md-columns] { + grid-template-columns: repeat( + auto-fill, + minmax(min(100%, max(var(--md-min-item), (100% - (var(--md-columns) - 1) * var(--md-gap)) / var(--md-columns))), 1fr) + ); + } +} diff --git a/resources/views/components/grid.blade.php b/resources/views/components/grid.blade.php new file mode 100644 index 00000000..4135c8b1 --- /dev/null +++ b/resources/views/components/grid.blade.php @@ -0,0 +1,49 @@ +{{-- Children in columns, inside a pane. + + + + + `columns` is the column count per breakpoint — `compact`, `medium` (600px), `expanded` (840), + `large` (1200), `extra-large` (1600), M3's (docs/reference/m3/foundations.md § Layout → + Breakpoints) — as a map, or one whole number for all of them. A breakpoint left out takes the + nearest smaller one's count, and compact is 1 unless given. All five are written on the grid as + `--md-columns-compact` … `--md-columns-extra-large`, so a grid nested in another never inherits + its parent's. + + `min-item` (`280px`, `18rem`, `20ch`, or a number of px) fills each row with as many columns as + fit at that width, which follows the room the grid has rather than the window — the right + choice inside a pane whose width is not the window's. With `columns` as well, the counts become + a ceiling. `gap` is a spacing token's name, `space25` … `space900` + (docs/reference/m3/styles-supplement.md § Spacing), none when left out or unknown. Children + keep their order, which is the reading order. + + It takes `as`, `hide-below` and `hide-from` like every layout component, and the caller's + `class` and `style` land on it untouched (the caller's style after the column properties). + Drawn by resources/css/layout/grid.css. --}} + +@props([ + 'as' => null, + 'columns' => null, + 'gap' => null, + 'minItem' => null, + 'hideBelow' => null, + 'hideFrom' => null, +]) + +@php + $layout = \NoNameWeb\LivewireMaterial\Support\Layout::class; + $element = $layout::element($as); + $minItem = $layout::length($minItem); + + $style = $layout::columnStyle($layout::columns($columns)).($minItem !== null ? " --md-min-item: {$minItem};" : ''); + + $attributes = $attributes->merge(array_filter([ + 'data-md-grid' => true, + 'data-md-gap' => $layout::spacing($gap), + 'data-md-min-item' => $minItem !== null ? true : null, + 'data-md-columns' => $minItem !== null && $columns !== null ? true : null, + 'style' => $style, + ] + $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 378c8815..2babc284 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -95,3 +95,34 @@ it('mirrors a row in a right-to-left document', function () { layoutPage('FirstSecond', 800, 600, 'rtl') ->assertScript(layoutRect('#first', 'left').' > '.layoutRect('#second', 'left')); }); + +it('gives a grid its columns per breakpoint, and a nested grid its own', function () { + $body = <<<'BLADE' + +
A
B
+
2
3
4
+
+ BLADE; + + layoutPage($body, 599) + ->assertScript(layoutColumns('#outer').' === 1') + ->assertScript(layoutColumns('#inner').' === 1'); + + layoutPage($body, 600) + ->assertScript(layoutColumns('#outer').' === 3') + ->assertScript(layoutStyle('#outer', 'columnGap')." === '16px'") + // The parent's three never reach the grid inside it. + ->assertScript(layoutColumns('#inner').' === 1'); + + layoutPage($body, 1199)->assertScript(layoutColumns('#outer').' === 3'); + layoutPage($body, 1200)->assertScript(layoutColumns('#outer').' === 4'); +}); + +it('fills a grid with columns of a minimum width, capped by the count', function () { + $body = '
1
2
3
4
' + .'
1
2
3
4
'; + + layoutPage($body, 800) + ->assertScript(layoutColumns('#fill').' === 3') + ->assertScript(layoutColumns('#capped').' === 2'); +}); diff --git a/tests/Feature/Components/GridTest.php b/tests/Feature/Components/GridTest.php new file mode 100644 index 00000000..073a9ccd --- /dev/null +++ b/tests/Feature/Components/GridTest.php @@ -0,0 +1,79 @@ + + */ +function gridColumns(string $html): array +{ + preg_match_all('/--md-columns-([a-z-]+): (\d+);/', layoutRoot($html)['style'] ?? '', $matches, PREG_SET_ORDER); + + return collect($matches)->mapWithKeys(fn (array $match): array => [$match[1] => $match[2]])->all(); +} + +it('writes all five column counts on every grid, one column when given none', function () { + $html = (string) $this->blade('
One
'); + + expect(layoutRoot($html))->toMatchArray(['<' => 'div', 'data-md-grid' => 'data-md-grid']) + ->not->toHaveKeys(['data-md-gap', 'data-md-min-item', 'data-md-columns']) + ->and(gridColumns($html))->toBe(['compact' => '1', 'medium' => '1', 'expanded' => '1', 'large' => '1', 'extra-large' => '1']); +}); + +it('fills each breakpoint left out from the nearest smaller one', function () { + expect(gridColumns((string) $this->blade(''))) + ->toBe(['compact' => '1', 'medium' => '2', 'expanded' => '2', 'large' => '4', 'extra-large' => '4']) + ->and(gridColumns((string) $this->blade(''))) + ->toBe(['compact' => '3', 'medium' => '3', 'expanded' => '3', 'large' => '3', 'extra-large' => '3']) + // A count that is not a whole number from 1 keeps the one before it. + ->and(gridColumns((string) $this->blade(''))) + ->toBe(['compact' => '2', 'medium' => '2', 'expanded' => '2', 'large' => '5', 'extra-large' => '5']); + + expect(Layout::columns(['extra-large' => 6]))->toBe(['compact' => 1, 'medium' => 1, 'expanded' => 1, 'large' => 1, 'extra-large' => 6]); +}); + +it('spaces the columns with a spacing token only, and with none for anything else', function () { + expect(layoutRoot((string) $this->blade(''))['data-md-gap'])->toBe('space300') + ->and(layoutRoot((string) $this->blade(''))['data-md-gap'])->toBe('none'); +}); + +it('fills a row with columns of a minimum width, capped by the counts when both are given', function () { + $fill = layoutRoot((string) $this->blade('')); + $capped = layoutRoot((string) $this->blade('')); + + expect($fill)->toHaveKey('data-md-min-item')->not->toHaveKey('data-md-columns') + ->and($fill['style'])->toContain('--md-min-item: 280px;') + ->and($capped)->toHaveKeys(['data-md-min-item', 'data-md-columns']) + ->and($capped['style'])->toContain('--md-min-item: 18rem;'); + + foreach (['240' => '240px', '20ch' => '20ch', '12.5em' => '12.5em'] as $given => $length) { + expect(layoutRoot((string) $this->blade('', ['m' => $given]))['style'])->toContain("--md-min-item: {$length};"); + } + + foreach (['wide', '50%', 'calc(10px + 1rem)', '0'] as $given) { + expect(layoutRoot((string) $this->blade('', ['m' => $given])))->not->toHaveKey('data-md-min-item') + ->and((string) $this->blade('', ['m' => $given]))->not->toContain('--md-min-item'); + } +}); + +it('takes the element, the visibility props, and the caller\'s class and style after its own', function () { + $root = layoutRoot((string) $this->blade('')); + + expect($root)->toMatchArray(['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery']) + ->and($root['style'])->toEndWith('--md-columns-extra-large: 1; align-items: start;'); +}); + +it('reads its own column count at each breakpoint in the layout layer', function () { + $css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/grid.css'); + + expect($css)->toContain('@layer material.layout') + ->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));'); + + foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) { + expect($css)->toContain("@media (width >= {$width}px) {\n --md-columns: var(--md-columns-{$breakpoint}, 1);"); + } + + expect((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/grid.css';"); +});