diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 56713dd2..aea65cbc 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -770,6 +770,23 @@ The docked view overlaps what is under it; never place a search inside an elemen ``` +### Layout + +M3's layout vocabulary as components (M3 foundations § Layout: scaffold, bars, rails, panes, margins, spacers, and the canonical layouts). `` holds the bars, the rail and the FAB around the page; all content lives in panes, ``; two panes side by side are the canonical layouts `` and ``, and `` is the third; `` is a tonal region; inside a pane, ``, `` and `` 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 ``, a new edge, draws it again. + +#### `` + +Children one under another inside a pane: ``. `align` across it: `stretch` (default), `start`, `center`, `end`. + ### `` 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 89f088a2..63fbd6e6 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -5,3 +5,5 @@ */ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './layout/stack.css'; diff --git a/resources/css/layout/spacing.css b/resources/css/layout/spacing.css new file mode 100644 index 00000000..c7c3eedd --- /dev/null +++ b/resources/css/layout/spacing.css @@ -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); + } +} diff --git a/resources/css/layout/stack.css b/resources/css/layout/stack.css new file mode 100644 index 00000000..d7f3ac94 --- /dev/null +++ b/resources/css/layout/stack.css @@ -0,0 +1,40 @@ +/* + * : 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 and . + * + * 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; + } +} diff --git a/resources/css/layout/visibility.css b/resources/css/layout/visibility.css new file mode 100644 index 00000000..437d4524 --- /dev/null +++ b/resources/css/layout/visibility.css @@ -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; + } + } +} diff --git a/resources/views/components/stack.blade.php b/resources/views/components/stack.blade.php new file mode 100644 index 00000000..b3937e1e --- /dev/null +++ b/resources/views/components/stack.blade.php @@ -0,0 +1,33 @@ +{{-- Children one under another, inside a pane. + + + + `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 `` and ``. 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 }} diff --git a/resources/views/showcase/sections/layout.blade.php b/resources/views/showcase/sections/layout.blade.php index ae42928b..45d6869f 100644 --- a/resources/views/showcase/sections/layout.blade.php +++ b/resources/views/showcase/sections/layout.blade.php @@ -102,4 +102,18 @@ content margin (--material-margin) change at 600, 840 and 1200px. A visitor who has chosen a rail width keeps it; until then the rail follows the class.

+ + +

The layout components

+ +

+ 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 as, hide-below and hide-from, and + only the spacing tokens for a gap or padding: +

+ + +
  • <x-stack>, <x-row> and <x-grid> — arrangement inside a pane.
  • +
    +
    diff --git a/src/Support/Layout.php b/src/Support/Layout.php new file mode 100644 index 00000000..0a4038cd --- /dev/null +++ b/src/Support/Layout.php @@ -0,0 +1,147 @@ + 0, + 'medium' => 600, + 'expanded' => 840, + 'large' => 1200, + 'extra-large' => 1600, + ]; + + /** M3's spacing tokens, by the name after `--md-sys-measurement-`. */ + public const array SPACING = [ + 'space25', 'space50', 'space75', 'space100', 'space125', 'space200', 'space300', + 'space400', 'space500', 'space600', 'space700', 'space800', 'space900', + ]; + + /** + * The elements a layout component can be drawn as: containers of flow content. A void element + * could hold no slot, and a form control or a link would carry its own semantics into a box. + */ + public const array ELEMENTS = [ + 'div', 'section', 'article', 'aside', 'main', 'nav', 'header', 'footer', + 'ul', 'ol', 'li', 'dl', 'form', 'fieldset', 'figure', 'span', 'p', + ]; + + /** + * A spacing token's name, `none` for anything else that was given, or null when nothing was. + * `none` is written out so an unknown gap on a component with a default (a feed's spacer) + * falls back to no gap rather than to that default. + */ + public static function spacing(mixed $value): ?string + { + if ($value === null || $value === false || $value === '') { + return null; + } + + return in_array($value, self::SPACING, true) ? $value : 'none'; + } + + /** + * A breakpoint a prop can hide or restack from: medium, expanded, large or extra-large. There + * is nothing below compact, and from compact is every width. + */ + public static function edge(mixed $value): ?string + { + return is_string($value) && $value !== 'compact' && array_key_exists($value, self::BREAKPOINTS) ? $value : null; + } + + /** + * The element to draw, or the component's default when `as` names none of ours. + */ + public static function element(mixed $as, string $default = 'div'): string + { + return in_array($as, self::ELEMENTS, true) ? $as : $default; + } + + /** + * One of a fixed set of names, or the default. + * + * @param list $allowed + */ + public static function choice(mixed $value, array $allowed, ?string $default = null): ?string + { + return in_array($value, $allowed, true) ? $value : $default; + } + + /** + * A grid's columns at every breakpoint, as the inline custom properties its stylesheet reads. + * + * `columns` is one whole number for every breakpoint, or a map from breakpoint to number; a + * breakpoint left out takes the nearest smaller one's, and compact takes 1 when nothing is + * given for it. All five are written on every grid, so a grid inside another never inherits + * its parent's count through the cascade. + * + * @return array + */ + public static function columns(mixed $columns): array + { + $given = match (true) { + is_array($columns) => $columns, + $columns === null => [], + default => ['compact' => $columns], + }; + + $filled = []; + $current = 1; + + foreach (array_keys(self::BREAKPOINTS) as $breakpoint) { + $count = filter_var($given[$breakpoint] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 24]]); + $current = $count === false ? $current : $count; + $filled[$breakpoint] = $current; + } + + return $filled; + } + + /** + * The `style` those columns become: `--md-columns-compact: 1; …`. + * + * @param array $columns + */ + public static function columnStyle(array $columns): string + { + return collect($columns)->map(fn (int $count, string $breakpoint): string => "--md-columns-{$breakpoint}: {$count};")->implode(' '); + } + + /** + * A minimum item width — `280px`, `18rem`, `20ch`, or a bare number read as px — or null. + */ + public static function length(mixed $value): ?string + { + if (is_int($value) || (is_string($value) && preg_match('/^\d+$/', $value) === 1)) { + return (int) $value > 0 ? ((int) $value).'px' : null; + } + + return is_string($value) && preg_match('/^(?:\d+|\d*\.\d+)(?:px|rem|em|ch)$/', $value) === 1 ? $value : null; + } + + /** + * The attributes every layout component shares: `hide-below` and `hide-from`. + * + * @return array + */ + public static function visibility(mixed $hideBelow, mixed $hideFrom): array + { + return array_filter([ + 'data-md-hide-below' => self::edge($hideBelow), + 'data-md-hide-from' => self::edge($hideFrom), + ]); + } +} diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php new file mode 100644 index 00000000..01648768 --- /dev/null +++ b/tests/Browser/LayoutTest.php @@ -0,0 +1,74 @@ +get($path, fn () => Blade::render(<< + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + {$body} + @livewireScripts + + + BLADE)); + + return layoutReady(visit($path)->resize($width, $height)); +} + +function layoutReady(mixed $page): mixed +{ + return $page->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +/** A computed style of the element matching the selector. */ +function layoutStyle(string $selector, string $property): string +{ + return "getComputedStyle(document.querySelector('{$selector}')).{$property}"; +} + +/** A side of the element's box, rounded to the pixel. */ +function layoutRect(string $selector, string $side): string +{ + return "Math.round(document.querySelector('{$selector}').getBoundingClientRect().{$side})"; +} + +/** How many column tracks a grid has. */ +function layoutColumns(string $selector): string +{ + return layoutStyle($selector, 'gridTemplateColumns').".split(' ').length"; +} + +it('spaces a stack with its token, never with its parent\'s, and hides it from a breakpoint over its own display', function () { + $body = '

    One

    Two

    Three

    ' + .'

    Compact only

    '; + + layoutPage($body, 599) + ->assertScript(layoutStyle('#outer', 'flexDirection')." === 'column'") + ->assertScript(layoutStyle('#outer', 'rowGap')." === '24px'") + ->assertScript(layoutStyle('#inner', 'rowGap')." === '0px'") + ->assertScript(layoutStyle('#hidden-from', 'display')." === 'flex'"); + + layoutPage($body, 600) + ->assertScript(layoutStyle('#hidden-from', 'display')." === 'none'"); +}); diff --git a/tests/Feature/Components/StackTest.php b/tests/Feature/Components/StackTest.php new file mode 100644 index 00000000..6e7c7449 --- /dev/null +++ b/tests/Feature/Components/StackTest.php @@ -0,0 +1,43 @@ +blade('

    One

    Two

    ')); + + expect($root)->toMatchArray(['<' => 'div', 'data-md-stack' => 'data-md-stack']) + ->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-hide-below', 'data-md-hide-from']); +}); + +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('space200') + ->and(layoutRoot((string) $this->blade(''))['data-md-gap'])->toBe('none') + ->and(layoutRoot((string) $this->blade(''))['data-md-gap'])->toBe('none'); +}); + +it('aligns its children across it', function () { + foreach (['stretch', 'start', 'center', 'end'] as $align) { + expect(layoutRoot((string) $this->blade('', ['align' => $align]))['data-md-align'])->toBe($align); + } + + expect(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-align'); +}); + +it('takes the element, the visibility props and the caller\'s class and style', function () { + expect(layoutRoot((string) $this->blade(''))) + ->toMatchArray([ + '<' => 'ul', + 'data-md-hide-below' => 'medium', + 'data-md-hide-from' => 'large', + 'class' => 'steps', + 'style' => 'max-width: 40rem;', + ]); +}); + +it('draws the stack from a stylesheet in the layout layer, imported by layout.css', function () { + $css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/stack.css'); + + expect($css)->toContain('@layer material.layout') + ->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);") + // A stack inside another never takes its parent's gap. + ->toContain(":where([data-md-stack]) {\n --md-gap: 0px;") + ->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/stack.css';"); +}); diff --git a/tests/Feature/StylesheetsTest.php b/tests/Feature/StylesheetsTest.php index 2d6df214..130f041b 100644 --- a/tests/Feature/StylesheetsTest.php +++ b/tests/Feature/StylesheetsTest.php @@ -1,6 +1,7 @@ + */ +function layoutStylesheets(): array +{ + return stylesheetTree(stylesheetPath('layout.css')); +} + +it('imports a stylesheet for every layout component from layout.css, each beside its view', function () { + $imports = stylesheetImports(stylesheetPath('layout.css')); + $reached = array_map(stylesheetName(...), layoutStylesheets()); + + $files = collect(File::files(stylesheetPath('layout')))->map(fn (SplFileInfo $file): string => $file->getBasename('.css')); + + // Two files are shared by the components rather than being one: the spacing props and the + // visibility props. + $components = $files->diff(['spacing', 'visibility'])->values(); + + expect($components)->not->toBeEmpty() + ->and($files->map(fn (string $name): string => "layout/{$name}.css")->diff($reached)->values()->all())->toBe([]); + + foreach ($components as $name) { + expect(File::exists(__DIR__."/../../resources/views/components/{$name}.blade.php"))->toBeTrue("layout/{$name}.css has no component") + ->and($imports)->toContain("./layout/{$name}.css"); + } +}); + +it('opens every layout stylesheet with a header, the layer statement and plain imports', function () { + foreach (layoutStylesheets() as $file) { + $name = stylesheetName($file); + $items = stylesheetItems(File::get($file)); + + expect(File::get($file))->toStartWith('/*', "{$name} has no header comment") + ->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, "{$name} does not open with the layer statement"); + + $blocks = false; + + foreach (array_slice($items, 1) as $item) { + if (isset($item['prelude'])) { + $blocks = true; + + continue; + } + + expect($blocks)->toBeFalse("{$name}: `{$item['statement']}` comes after a block") + ->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "{$name}: `{$item['statement']}` is not a plain import"); + } + } +}); + +it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () { + foreach (layoutStylesheets() as $file) { + $name = stylesheetName($file); + $css = File::get($file); + $layer = match ($name) { + 'layout/visibility.css' => 'material.visibility', + default => 'material.layout', + }; + + expect(stylesheetWithoutComments($css))->not->toContain('!important', $name); + + foreach (stylesheetItems($css) as $item) { + if (! isset($item['prelude'])) { + continue; + } + + expect($item['prelude'])->toBe("@layer {$layer}", "{$name} writes outside `@layer {$layer}`") + ->and($item['body'])->not->toContain('@layer'); + } + } +}); + +it('writes no Tailwind in the layout stylesheets, and media queries only at M3\'s breakpoints, in px', function () { + $queries = 0; + + foreach (layoutStylesheets() as $file) { + $name = stylesheetName($file); + $css = stylesheetWithoutComments(File::get($file)); + + expect($css) + ->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', $name) + ->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', $name); + + preg_match_all('/@media\b([^{]*)\{/', $css, $matches); + + foreach ($matches[1] as $query) { + $queries++; + + expect(trim($query))->toMatch('/^\(width (?:<|>=) (?:600|840|1200|1600)px\)$/', "{$name}: `@media {$query}` is not an M3 breakpoint in px"); + } + } + + expect($queries)->toBeGreaterThan(0); +}); + +it('gives every spacing token a gap and a padding, and the layout components read only those', function () { + $tokens = array_map( + fn (string $property): string => substr($property, strlen('--md-sys-measurement-')), + array_keys(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))), + ); + + expect(Layout::SPACING)->toBe($tokens); + + $spacing = File::get(stylesheetPath('layout/spacing.css')); + + expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']); + + foreach ($tokens as $token) { + expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"]) + ->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]); + } + + expect(collect(stylesheetItems(stylesheetBlock($spacing, '@layer material.layout')))->pluck('prelude')->all()) + ->toHaveCount(1 + 2 * count($tokens)); +}); + +it('hides an element below and from every breakpoint but compact, and nothing else', function () { + $visibility = stylesheetBlock(File::get(stylesheetPath('layout/visibility.css')), '@layer material.visibility'); + + $expected = []; + + foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) { + $expected["@media (width < {$width}px)"] = "[data-md-hide-below='{$breakpoint}']"; + } + + foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) { + $expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']"; + } + + $actual = collect(stylesheetItems($visibility))->mapWithKeys(function (array $item): array { + $rule = stylesheetItems($item['body'])[0]; + + expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']); + + return [$item['prelude'] => $rule['prelude']]; + })->all(); + + expect($actual)->toBe($expected); +}); + it('gives every md-type class the declarations of its type utility', function () { preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(stylesheetPath('tokens/utilities.css')), $matches, PREG_SET_ORDER); diff --git a/tests/Pest.php b/tests/Pest.php index ee795b8a..4d31f207 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,6 +4,21 @@ use NoNameWeb\LivewireMaterial\Tests\TestCase; pest()->extend(TestCase::class)->in('Feature', 'Browser'); +/** + * The element name (under `<`) and the attributes of the first tag in a rendered component, by + * name; a boolean attribute Blade renders as `name="name"`. For the layout components' render + * tests, which assert what their root carries. + * + * @return array + */ +function layoutRoot(string $html): array +{ + preg_match('/^\s*<([a-z]+)\b([^>]*)>/s', $html, $tag); + preg_match_all('/([\w:.@-]+)(?:="([^"]*)")?/', $tag[2] ?? '', $pairs, PREG_SET_ORDER); + + return ['<' => $tag[1] ?? ''] + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all(); +} + // A shared CI runner is slower than a workstation: an animation or a smooth scroll can take longer // than the default five seconds to settle there. BROWSER_TIMEOUT (milliseconds) raises the limit. if (($timeout = (int) getenv('BROWSER_TIMEOUT')) > 0) { diff --git a/workbench/resources/css/app.css b/workbench/resources/css/app.css index b0d5c678..9e7d115d 100644 --- a/workbench/resources/css/app.css +++ b/workbench/resources/css/app.css @@ -1,7 +1,10 @@ /* The Workbench builds the package as an application would, with explicit sources so the plans - in docs/ are not scanned for class names. The foundation comes before Tailwind, so the - `material` layers are declared first and sit below Tailwind's; tailwind.css brings the - components still written for Tailwind, without declaring the tokens a second time. */ + in docs/ are not scanned for class names. The layer statement puts the `material` layers above + Tailwind's preflight (`base`) and below its `components` and `utilities`: a rewritten component's + padding, margin and border would otherwise lose to preflight's `* { padding: 0 }`, which the + material reset already carries, while every utility still outranks the package. tailwind.css + brings the components still written for Tailwind, without declaring the tokens a second time. */ +@layer theme, base, material, components, utilities; @import '../../../resources/css/foundation.css'; @import '../../../resources/css/layout.css'; @import '../../../resources/css/components.css';