Add the grid layout component
Plan step 35: <x-grid :columns gap min-item>. The column count per breakpoint is written as five inline custom properties, each filled from the nearest smaller breakpoint, so a nested grid never inherits its parent's; min-item fills a row by the room the grid has, and with columns the counts become a ceiling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
8de053f15c
commit
da7a05a619
@@ -795,6 +795,19 @@ Children side by side inside a pane: `<x-row gap="space100" justify="between" st
|
||||
- `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.
|
||||
|
||||
#### `<x-grid>`
|
||||
|
||||
Children in columns inside a pane.
|
||||
|
||||
```blade
|
||||
<x-grid :columns="['compact' => 1, 'medium' => 2, 'expanded' => 3]" gap="space300">…</x-grid>
|
||||
<x-grid min-item="280px" gap="space200">…</x-grid>
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
||||
### `<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>`.
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
|
||||
@import './layout/stack.css';
|
||||
@import './layout/row.css';
|
||||
@import './layout/grid.css';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* <x-grid>: 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{{-- Children in columns, inside a pane.
|
||||
|
||||
<x-grid :columns="['compact' => 1, 'medium' => 2, 'expanded' => 3]" gap="space300">…</x-grid>
|
||||
<x-grid min-item="280px" gap="space200">…</x-grid>
|
||||
|
||||
`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 }}</{{ $element }}>
|
||||
@@ -95,3 +95,34 @@ it('mirrors a row in a right-to-left document', function () {
|
||||
layoutPage('<x-row id="row"><span id="first">First</span><span id="second">Second</span></x-row>', 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'
|
||||
<x-grid id="outer" :columns="['medium' => 3, 'large' => 4]" gap="space200">
|
||||
<x-grid id="inner"><div>A</div><div>B</div></x-grid>
|
||||
<div>2</div><div>3</div><div>4</div>
|
||||
</x-grid>
|
||||
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 = '<div style="width: 580px"><x-grid id="fill" min-item="180px"><div>1</div><div>2</div><div>3</div><div>4</div></x-grid></div>'
|
||||
.'<div style="width: 580px"><x-grid id="capped" min-item="100px" :columns="[\'compact\' => 2]" gap="space200"><div>1</div><div>2</div><div>3</div><div>4</div></x-grid></div>';
|
||||
|
||||
layoutPage($body, 800)
|
||||
->assertScript(layoutColumns('#fill').' === 3')
|
||||
->assertScript(layoutColumns('#capped').' === 2');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
|
||||
/**
|
||||
* A grid's column properties from its inline style, by breakpoint.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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('<x-grid><div>One</div></x-grid>');
|
||||
|
||||
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('<x-grid :columns="[\'medium\' => 2, \'large\' => 4]" />')))
|
||||
->toBe(['compact' => '1', 'medium' => '2', 'expanded' => '2', 'large' => '4', 'extra-large' => '4'])
|
||||
->and(gridColumns((string) $this->blade('<x-grid :columns="3" />')))
|
||||
->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('<x-grid :columns="[\'compact\' => 2, \'medium\' => 0, \'expanded\' => \'many\', \'large\' => 5, \'huge\' => 9]" />')))
|
||||
->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('<x-grid gap="space300" />'))['data-md-gap'])->toBe('space300')
|
||||
->and(layoutRoot((string) $this->blade('<x-grid gap="1.5rem" />'))['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('<x-grid min-item="280px" />'));
|
||||
$capped = layoutRoot((string) $this->blade('<x-grid min-item="18rem" :columns="[\'medium\' => 2, \'expanded\' => 3]" />'));
|
||||
|
||||
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('<x-grid :min-item="$m" />', ['m' => $given]))['style'])->toContain("--md-min-item: {$length};");
|
||||
}
|
||||
|
||||
foreach (['wide', '50%', 'calc(10px + 1rem)', '0'] as $given) {
|
||||
expect(layoutRoot((string) $this->blade('<x-grid :min-item="$m" />', ['m' => $given])))->not->toHaveKey('data-md-min-item')
|
||||
->and((string) $this->blade('<x-grid :min-item="$m" />', ['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('<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />'));
|
||||
|
||||
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';");
|
||||
});
|
||||
Reference in New Issue
Block a user