Files
livewire-material/tests/Feature/Components/ComponentStylesheetsTest.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

210 lines
11 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* Every component and layout-shared stylesheet: each view renders `data-md-*` attributes and no
* class list of its own beyond the interaction and text classes (tests/Support/ViewClasses.php),
* writes its values from the tokens and its breakpoints as px range queries, and is imported from
* its own block of all.css. This used to be four near-copies of the same checks, one per group
* (Action/Containment/Input/Navigation), plus a fifth in ErrorPagesTest.php for the error layout —
* folded into this one file (plan cleanup) because the checks never differed in substance, only in
* which names they ran over and, for the token check, which box-shadow shape a group's own values
* may take (see $boxShadow below). `icon` and `shape` (no group's view renders them exclusively)
* and `color` (its own ColorTest.php already covers it, the shared colour-role table every
* hue-taking component imports) keep their separate treatment.
*
* Four names in the dataset are not a component view of their own: `selection` (what checkbox,
* radio and toggle share), `navigation-item` (what the bar and the rail's items share), `pagination`
* (four views under resources/views/pagination/, not resources/views/components/) and `error-page`
* (the framework's error layout, not a component at all). The stylesheet-only checks below (shape,
* tokens, block import) still run on all four; the view-scoped ones (class list, element-wide
* selector scoping) run only where the original group already ran them — pagination's own views get
* their own small dataset further down, and error-page's view-scoped checks stay in
* ErrorPagesTest.php, which reads a view outside resources/views/components/.
*/
function componentStylesheets(): array
{
$entries = [];
foreach (['icon', 'shape'] as $name) {
$entries[$name] = ['Foundation components', 'loose'];
}
foreach ([
'loading', 'tooltip', 'badge', 'button', 'group', 'button-group', 'split-button', 'fab',
'menu-separator', 'menu-group', 'menu-item', 'menu', 'fab-menu-item', 'fab-menu',
'rich-tooltip', 'toast', 'progress', 'alert', 'stat', 'empty-state',
] as $name) {
$entries[$name] = ['Actions and communication', 'loose'];
}
foreach ([
'divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet',
'carousel-item', 'carousel',
] as $name) {
$entries[$name] = ['Containment', 'ring'];
}
$entries['error-page'] = ['Containment', 'none'];
foreach ([
'form', 'field', 'input', 'password', 'textarea', 'select', 'file', 'checkbox', 'radio',
'toggle', 'chip', 'chip-set', 'choices', 'slider', 'search', 'table', 'sort-header',
'datepicker', 'timepicker', 'selection', 'pagination',
] as $name) {
$entries[$name] = ['Inputs, selection and data', 'ring'];
}
foreach ([
'app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail',
'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu',
'theme-toggle', 'scheme-picker', 'navigation-item',
] as $name) {
$entries[$name] = ['Navigation', 'ring'];
}
return $entries;
}
dataset('component stylesheets', function (): Generator {
foreach (componentStylesheets() as $name => [$block, $boxShadow]) {
yield $name => [$name, $block, $boxShadow];
}
});
// The names with exactly one view of their own at resources/views/components/<name>.blade.php —
// every dataset entry above but the four stylesheet-only names (see the file header) — for the two
// checks that read that view. `tabs` renders two (tabs.blade.php and tab.blade.php).
dataset('component views', array_values(array_diff(array_keys(componentStylesheets()), ['selection', 'navigation-item', 'pagination', 'error-page'])));
/**
* The names whose stylesheet travels in every page's bundle (all.css), so a bare `html`, `body`,
* `dialog` or `:root` rule would restyle an application's own pages — containment and navigation,
* the two groups whose original tests carried this check. `[data-md-` is the exception in both: a
* rule that only applies while a package component is on the page at all. `:has([data-md-` needs
* no `>` combinator only in navigation's own stylesheets (toolbar.css's
* `:root:has([data-md-toolbar-place=…])` has none) — containment keeps the strict, `>`-only form
* its own original test held it to.
*/
dataset('scoped selectors', function (): Generator {
$names = [
...array_fill_keys(['divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet', 'carousel-item', 'carousel'], true),
...array_fill_keys(['app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail', 'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu', 'theme-toggle', 'scheme-picker', 'navigation-item'], false),
];
foreach ($names as $name => $strict) {
yield $name => [$name, $strict];
}
});
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
// The header, the layer statement and the plain imports are StylesheetsTest's, checked over
// every file all.css reaches; only the block layer and the imports' existence are this
// dataset's own to check.
expect($css->blocks())->each->toBe('@layer material.components');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('component stylesheets');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
// `tabs` renders two views of its own, tabs.blade.php and tab.blade.php; every other name here
// has exactly one.
foreach (($name === 'tabs' ? ['tabs', 'tab'] : [$name]) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('component views');
/**
* `$boxShadow` is the shape a stylesheet's own box-shadow values may take: 'loose' for actions and
* communication (a component paints its own elevation through a local custom property, e.g.
* button.css's `--md-button-elevation`, not `--md-sys-elevation-N` directly, so any `var(--md-*)`
* passes); 'ring' for containment, inputs/selection/data and navigation (`none`, an elevation level,
* or a literal ring — `[inset] 0 0 0 <n>px` in a colour role, a focus or a selected day); 'none' for
* the error layout, which draws no box-shadow of its own.
*
* Breakpoints (px, at one of M3's four) are StylesheetsTest's, checked over every file all.css
* reaches. field.css's autofill workaround (`transition: background-color 604800s, color
* 604800s`) delays Chrome's autofill background for a week — not a spring, and not meant to be
* one — so it is stripped before the transition check.
*/
it('takes its values from the tokens', function (string $name, string $block, string $boxShadow) {
$label = "{$name}.css";
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
if ($label === 'field.css') {
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
}
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
if ($boxShadow === 'loose') {
expect($source)->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/');
} elseif ($boxShadow === 'ring') {
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
}
})->with('component stylesheets');
it('is imported from its own block of all.css', function (string $name, string $block) {
expect(allCssBlock($block))->toContain("@import './components/{$name}.css';");
})->with('component stylesheets');
it('scopes every element-wide selector to a data-md hook', function (string $name, bool $strict) {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
$has = $strict ? ':has\(> \[data-md-' : ':has\((?:> )?\[data-md-';
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|'.$has.')/m');
})->with('scoped selectors');
// ---- pagination: its views live outside resources/views/components/ --------------------------
dataset('pagination views', [
'pagination/laravel/material',
'pagination/laravel/simple-material',
'pagination/livewire/material',
'pagination/livewire/simple-material',
]);
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
})->with('pagination views');
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('pagination views');
// ---- partials: fragments a component view @includes, not a component view of their own --------
dataset('partial views', array_map(
fn (string $file): string => 'partials/'.basename($file, '.blade.php'),
glob(__DIR__.'/../../../resources/views/partials/*.blade.php'),
));
it('writes no class list into a partial but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('partial views');