Files
livewire-material/tests/Browser/CascadeTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

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

179 lines
7.7 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 — 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) 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, 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'],
]);