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:
co-authored by
Claude Opus 5
parent
6be9f35c98
commit
b677e60876
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* The layout components at M3's breakpoints (plan step 35): each on a page of its own, measured at
|
||||
* the pixel either side of the breakpoint it changes at. The window is resized after the page has
|
||||
* loaded, which is what a person does; a media query answers at once, and a script listening to
|
||||
* one (resources/js/layout.js) hears a change event.
|
||||
*/
|
||||
function layoutPage(string $body, int $width, int $height = 900, string $dir = 'ltr'): mixed
|
||||
{
|
||||
// Until the Workbench leaves Tailwind, its build turns `:dir(rtl)` into a list of `:lang()`s, so a
|
||||
// right-to-left page also says which language it is in.
|
||||
$lang = $dir === 'rtl' ? 'ar' : 'en';
|
||||
$path = '/layout-probe/'.md5($body.$dir);
|
||||
|
||||
Route::middleware('web')->get($path, fn () => Blade::render(<<<BLADE
|
||||
<!DOCTYPE html>
|
||||
<html dir="{$dir}" lang="{$lang}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body>
|
||||
{$body}
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
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 = '<x-stack id="outer" gap="space300"><x-stack id="inner"><p>One</p><p>Two</p></x-stack><p>Three</p></x-stack>'
|
||||
.'<x-stack id="hidden-from" hide-from="medium"><p>Compact only</p></x-stack>';
|
||||
|
||||
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'");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
it('stacks its children in a div with no gap and no alignment of its own', function () {
|
||||
$root = layoutRoot((string) $this->blade('<x-stack><p>One</p><p>Two</p></x-stack>'));
|
||||
|
||||
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('<x-stack gap="space200" />'))['data-md-gap'])->toBe('space200')
|
||||
->and(layoutRoot((string) $this->blade('<x-stack gap="16px" />'))['data-md-gap'])->toBe('none')
|
||||
->and(layoutRoot((string) $this->blade('<x-stack gap="space1000" />'))['data-md-gap'])->toBe('none');
|
||||
});
|
||||
|
||||
it('aligns its children across it', function () {
|
||||
foreach (['stretch', 'start', 'center', 'end'] as $align) {
|
||||
expect(layoutRoot((string) $this->blade('<x-stack :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
|
||||
}
|
||||
|
||||
expect(layoutRoot((string) $this->blade('<x-stack align="baseline" />')))->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('<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />')))
|
||||
->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';");
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
|
||||
/**
|
||||
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
||||
@@ -327,6 +328,148 @@ it('declares M3\'s spacing scale as the reference gives it', function () {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The layout components' stylesheets: layout.css and everything it imports.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<string, string>
|
||||
*/
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user