Files
livewire-material/tests/Browser/CascadeTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

179 lines
7.8 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* The central claims of the `@layer material` design (plan step 42, docs/plans/material-3-alignment.md
* Decisions § "Components carry their styles as `data-md-*` attributes inside `@layer material`"),
* each on its own small probe page: an application's unlayered rule beats a component default;
* `[hidden]` beats a component's `display`; the layer order holds even when a component file is
* imported before the foundation; a `data-md-*` attribute survives a Livewire `$refresh`; an icon
* mirrors in a right-to-left document. Every other stylesheet claim (layers, imports, breakpoints,
* no Tailwind) is StylesheetsTest.php's, asserted on the source rather than a rendered page.
*/
function cascadeReady(mixed $page): mixed
{
return ready($page);
}
it('lets an application\'s unlayered rule beat a component default', function () {
// The brief's own example: a caller's plain, unlayered `.app-wide` rule against button.css's
// layered `padding-inline: var(--md-button-padding)` and `inline-size: var(--md-button-width)`
// — unlayered CSS outranks every `@layer`, however specific the layered rule is.
Route::middleware('web')->get('/cascade-unlayered-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>.app-wide { inline-size: 320px; }</style>
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-button label="Save" class="app-wide" data-test="button" />
@livewireScripts
</body>
</html>
BLADE));
cascadeReady(visit('/cascade-unlayered-probe'))
->assertScript("Math.round(document.querySelector('[data-test=\"button\"]').getBoundingClientRect().width) === 320");
});
it('lets [hidden] beat a component\'s display', function () {
// The reset's `[hidden]:where(:not([hidden='until-found'])) { display: none !important }`
// sits outside every layer, so it beats a component's own `display` (button.css's `inline-flex`,
// row.css's `flex`) whatever `@layer` that rule sits in.
Route::middleware('web')->get('/cascade-hidden-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-row hidden data-test="row"><p>One</p><p>Two</p></x-row>
<x-button hidden label="Ghost" data-test="button" />
@livewireScripts
</body>
</html>
BLADE));
$page = cascadeReady(visit('/cascade-hidden-probe'));
$page->assertScript("getComputedStyle(document.querySelector('[data-test=\"row\"]')).display === 'none'")
->assertScript("getComputedStyle(document.querySelector('[data-test=\"button\"]')).display === 'none'");
});
it('keeps the layer order even when a component file is imported before the foundation', function () {
// Every package stylesheet opens with the same full `@layer` statement (plan step 33), so the
// order it establishes holds whichever file a page reaches first — the error layout's copy of
// it, reached here before the foundation's. The two prebuilt error-page stylesheets, in that
// deliberate order: the layout's bundle, whose button.css rules sit in `material.components`,
// physically precedes the fallback's foundation reset, in `material.reset` — and still loses
// the layer priority fight, so the button's own padding survives the reset's `padding: 0`.
$css = File::get(__DIR__.'/../../resources/dist/error-page.css')
.File::get(__DIR__.'/../../resources/dist/error-page-fallback.css');
// Served as its own response, not inlined through Blade::render(): CSS's own `@media`,
// `@layer` and `@import` would otherwise reach Blade's directive compiler along with the page.
Route::middleware('web')->get('/cascade-layer-order-probe.css', fn () => response($css, 200, ['Content-Type' => 'text/css']));
Route::middleware('web')->get('/cascade-layer-order-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/cascade-layer-order-probe.css">
</head>
<body>
<x-button label="Save" data-test="button" />
</body>
</html>
BLADE));
visit('/cascade-layer-order-probe')
->assertScript("getComputedStyle(document.querySelector('[data-test=\"button\"]')).paddingInlineStart !== '0px'");
});
/**
* A Livewire component whose `data-md-variant` attribute changes on every re-render — the same
* morph a plain `wire:click="$refresh"` drives, since `$refresh` is Livewire's own name for calling
* a method that changes nothing before it re-renders.
*/
class CascadeVariantProbe extends Component
{
public string $variant = 'filled';
public function toggle(): void
{
$this->variant = $this->variant === 'filled' ? 'outlined' : 'filled';
}
public function render(): string
{
return <<<'BLADE'
<div>
<x-button :variant="$variant" label="Toggle" wire:click="toggle" data-test="button" />
</div>
BLADE;
}
}
it('keeps a data-md-* attribute through a Livewire $refresh', function () {
Livewire::component('cascade-variant-probe', CascadeVariantProbe::class);
Route::middleware('web')->get('/cascade-variant-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:cascade-variant-probe />
@livewireScripts
</body>
</html>
BLADE));
$button = "document.querySelector('[data-test=\"button\"]')";
$page = cascadeReady(visit('/cascade-variant-probe'))
->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'");
$page->click('[data-test="button"]')
->assertScript("{$button}.getAttribute('data-md-variant') === 'outlined'");
// Twice, so the morph is proven both ways round, not just once off the initial paint.
$page->click('[data-test="button"]')
->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'");
});
it('mirrors an icon in a right-to-left document, not in left-to-right', function (string $dir, string $transform) {
Route::middleware('web')->get("/cascade-icon-mirror-probe-{$dir}", fn () => Blade::render(<<<BLADE
<!DOCTYPE html>
<html dir="{$dir}">
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-icon name="arrow_back" mirror-rtl data-test="icon" />
@livewireScripts
</body>
</html>
BLADE));
cascadeReady(visit("/cascade-icon-mirror-probe-{$dir}"))
->assertScript("getComputedStyle(document.querySelector('[data-test=\"icon\"]')).transform === '{$transform}'");
})->with([
'right-to-left, mirrored' => ['rtl', 'matrix(-1, 0, 0, 1, 0, 0)'],
'left-to-right, unmirrored' => ['ltr', 'none'],
]);