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

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, rewritten without Tailwind (plan step 36): 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, the plain imports and freedom from Tailwind 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/tailwind',
'pagination/laravel/simple-tailwind',
'pagination/livewire/tailwind',
'pagination/livewire/simple-tailwind',
]);
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');