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
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>
793 lines
36 KiB
PHP
793 lines
36 KiB
PHP
<?php
|
||
|
||
use Illuminate\Support\Facades\File;
|
||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||
|
||
/**
|
||
* The package's stylesheets are plain CSS in `material.*` layers. Every one opens with the same
|
||
* layer statement, so the order holds whichever file a bundler reaches first; then plain imports
|
||
* (an `@import` cannot sit inside a layer block, and `layer()` on it would nest the layer twice);
|
||
* then its own `@layer material.<x>` blocks. The only rules outside a layer are
|
||
* foundation/hidden.css's two `!important` ones.
|
||
*
|
||
* Every package component and layout stylesheet is plain CSS, so the shape and breakpoint checks
|
||
* below run over the whole tree `all.css` reaches (foundation, layout and components together)
|
||
* rather than the foundation alone, and — one file it does not reach — `showcase.css`, checked on
|
||
* its own further down. A component's own values (colours as roles, shadows as elevation levels,
|
||
* and so on) are ComponentStylesheetsTest's own concern.
|
||
* The prebuilt bundles in resources/dist/ are tests/Feature/StylesheetsBundleTest.php's.
|
||
*/
|
||
const MATERIAL_LAYER_STATEMENT = '@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;';
|
||
|
||
function stylesheetPath(string $path = ''): string
|
||
{
|
||
return (string) realpath(ComponentStylesheet::cssPath($path));
|
||
}
|
||
|
||
/**
|
||
* The body of the first block with this prelude, at the top level or one layer block down.
|
||
*/
|
||
function stylesheetBlock(string $css, string $prelude): string
|
||
{
|
||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||
if (($item['prelude'] ?? null) === $prelude) {
|
||
return $item['body'];
|
||
}
|
||
|
||
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
|
||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body'])) as $inner) {
|
||
if (($inner['prelude'] ?? null) === $prelude) {
|
||
return $inner['body'];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new RuntimeException("No block `{$prelude}`.");
|
||
}
|
||
|
||
/**
|
||
* The paths a stylesheet imports, as written.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function stylesheetImports(string $file): array
|
||
{
|
||
return collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))))
|
||
->map(fn (array $item): ?string => ComponentStylesheet::importPath($item['statement'] ?? ''))
|
||
->filter()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* A stylesheet and every stylesheet it reaches through `@import`, each once, in the order a
|
||
* bundler meets them. An import that is not relative (a package name, a URL) is left out.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function stylesheetTree(string $entry): array
|
||
{
|
||
$files = [];
|
||
|
||
$visit = function (string $file) use (&$visit, &$files): void {
|
||
$file = (string) realpath($file);
|
||
|
||
if (in_array($file, $files, true)) {
|
||
return;
|
||
}
|
||
|
||
$files[] = $file;
|
||
|
||
foreach (stylesheetImports($file) as $import) {
|
||
if (str_starts_with($import, '.')) {
|
||
$visit(dirname($file).'/'.$import);
|
||
}
|
||
}
|
||
};
|
||
|
||
$visit($entry);
|
||
|
||
return $files;
|
||
}
|
||
|
||
/**
|
||
* The foundation: foundation.css and everything it imports.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function foundationStylesheets(): array
|
||
{
|
||
return stylesheetTree(stylesheetPath('foundation.css'));
|
||
}
|
||
|
||
/**
|
||
* Everything all.css reaches: the foundation, every layout stylesheet and every component
|
||
* stylesheet, each once. Every one of them is plain CSS.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function allStylesheets(): array
|
||
{
|
||
return stylesheetTree(stylesheetPath('all.css'));
|
||
}
|
||
|
||
function stylesheetName(string $file): string
|
||
{
|
||
return substr($file, strlen(stylesheetPath()) + 1);
|
||
}
|
||
|
||
it('brings every foundation file and text.css into the foundation, and nothing else', function () {
|
||
$foundation = array_map(stylesheetName(...), foundationStylesheets());
|
||
|
||
$parts = collect(File::files(stylesheetPath('foundation')))
|
||
->map(fn (SplFileInfo $file): string => 'foundation/'.$file->getFilename())
|
||
->push('foundation.css', 'text.css');
|
||
|
||
expect($parts->diff($foundation)->values()->all())->toBe([])
|
||
->and(array_map(fn (string $import): string => basename($import), stylesheetImports(stylesheetPath('foundation.css'))))
|
||
// In the order of their layers, the rules outside every layer beside the reset — the seven
|
||
// token files imported directly.
|
||
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.css', 'base.css', 'interaction.css', 'text.css'])
|
||
->and($foundation)
|
||
->toContain('tokens/scheme.css', 'tokens/shape.css', 'tokens/elevation.css', 'tokens/motion.css', 'tokens/type.css', 'tokens/state.css', 'tokens/spacing.css', 'tokens/font.css')
|
||
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
|
||
});
|
||
|
||
it('paints the page in surface and on-surface, in the brand typeface, smoothed in grayscale', function () {
|
||
// An application gets it with no class of its own needed: the showcase and the error pages,
|
||
// which set it themselves before, read it from here too.
|
||
$base = (string) preg_replace('~/\*.*?\*/~s', '', (string) file_get_contents(stylesheetPath('foundation/base.css')));
|
||
|
||
expect($base)->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);\s*color: var\(--md-sys-color-on-surface\);\s*font-family: var\(--md-ref-typeface-brand\);\s*-webkit-font-smoothing: antialiased;\s*-moz-osx-font-smoothing: grayscale;\s*\}/')
|
||
->and((string) file_get_contents(stylesheetPath('showcase.css')).file_get_contents(stylesheetPath('components/error-page.css')))
|
||
->not->toContain('font-smoothing');
|
||
});
|
||
|
||
it('names the monospace faces once, as a typeface token the reset, the field and md-mono read', function () {
|
||
// Not an M3 token: M3 names a brand and a plain typeface and nothing for code.
|
||
$sources = collect(File::allFiles(stylesheetPath()))
|
||
->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), 'ui-monospace'))
|
||
->map(fn (SplFileInfo $file): string => str_replace(stylesheetPath().'/', '', $file->getPathname()))
|
||
->values()
|
||
->all();
|
||
|
||
expect($sources)->toBe(['tokens/type.css'])
|
||
->and((string) file_get_contents(stylesheetPath('foundation/reset.css')))->toContain('font-family: var(--md-ref-typeface-mono);')
|
||
->and((string) file_get_contents(stylesheetPath('components/field.css')))->toContain('font-family: var(--md-ref-typeface-mono);');
|
||
});
|
||
|
||
it('leaves no stylesheet under resources/css/ outside all.css or showcase.css', function () {
|
||
// The full tree: every .css file on disk, not just the ones a bundler happens to reach — a
|
||
// stray or orphaned file would show up here even though nothing imports it, which the checks
|
||
// above (walking @import from all.css) cannot catch.
|
||
$onDisk = collect(File::allFiles(stylesheetPath()))
|
||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
|
||
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
|
||
->sort()
|
||
->values();
|
||
|
||
$reached = collect(allStylesheets())->map(stylesheetName(...))->push('showcase.css')->sort()->values();
|
||
|
||
expect($onDisk->all())->toBe($reached->all());
|
||
});
|
||
|
||
it('opens every stylesheet all.css reaches with a header, the layer statement and plain imports', function () {
|
||
foreach (allStylesheets() as $file) {
|
||
$name = stylesheetName($file);
|
||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments(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")
|
||
// A plain import: a quoted path and nothing after it — no layer(), supports() or media.
|
||
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "{$name}: `{$item['statement']}` is not a plain import");
|
||
}
|
||
}
|
||
});
|
||
|
||
it('keeps every foundation rule inside its material layer, but the two that hide', function () {
|
||
// Which layer each file writes into.
|
||
$layers = [
|
||
'foundation.css' => null,
|
||
'foundation/reset.css' => 'material.reset',
|
||
'foundation/hidden.css' => null,
|
||
'foundation/base.css' => 'material.base',
|
||
'foundation/interaction.css' => 'material.base',
|
||
'text.css' => 'material.text',
|
||
'tokens/font.css' => 'material.base',
|
||
];
|
||
|
||
$unlayered = [];
|
||
$important = 0;
|
||
|
||
foreach (foundationStylesheets() as $file) {
|
||
$name = stylesheetName($file);
|
||
$layer = array_key_exists($name, $layers) ? $layers[$name] : 'material.tokens';
|
||
$important += substr_count(ComponentStylesheet::withoutComments(File::get($file)), '!important');
|
||
|
||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))) as $item) {
|
||
if (! isset($item['prelude'])) {
|
||
continue;
|
||
}
|
||
|
||
if (! str_starts_with($item['prelude'], '@layer ')) {
|
||
$unlayered["{$name}: {$item['prelude']}"] = ComponentStylesheet::flatDeclarations($item['body']);
|
||
|
||
continue;
|
||
}
|
||
|
||
expect($item['prelude'])->toBe("@layer {$layer}", "{$name} writes into `{$item['prelude']}`")
|
||
// A layer inside the block would become material.<x>.<y>, below the rest of <x>.
|
||
->and($item['body'])->not->toContain('@layer');
|
||
}
|
||
}
|
||
|
||
expect($unlayered)->toBe([
|
||
"foundation/hidden.css: [hidden]:where(:not([hidden='until-found']))" => ['display' => 'none !important'],
|
||
'foundation/hidden.css: [x-cloak]' => ['display' => 'none !important'],
|
||
])->and($important)->toBe(2);
|
||
});
|
||
|
||
it('defines x-cloak once, reached from both the foundation and all.css', function () {
|
||
$definitions = collect(File::allFiles(stylesheetPath()))
|
||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
|
||
->filter(fn (SplFileInfo $file): bool => str_contains(ComponentStylesheet::withoutComments($file->getContents()), '[x-cloak]'))
|
||
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
|
||
->values()
|
||
->all();
|
||
|
||
expect($definitions)->toBe(['foundation/hidden.css'])
|
||
->and(foundationStylesheets())->toContain(stylesheetPath('foundation/hidden.css'))
|
||
->and(allStylesheets())->toContain(stylesheetPath('foundation/hidden.css'));
|
||
});
|
||
|
||
// A class no stylesheet declares, anywhere the package ships or tests itself, is
|
||
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
|
||
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
|
||
// either.
|
||
|
||
it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, in px', function () {
|
||
$queries = collect(allStylesheets())
|
||
->flatMap(function (string $file): array {
|
||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments(File::get($file)), $matches);
|
||
|
||
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
|
||
});
|
||
|
||
// Some queries (hover, reduced motion) hold no width at all; the check below is only for the
|
||
// ones that do.
|
||
expect($queries)->not->toBeEmpty();
|
||
|
||
foreach ($queries as $query) {
|
||
$feature = substr($query, strpos($query, ': ') + 2);
|
||
|
||
// The one named exception (docs/reference/m3/components-navigation-selection-inputs.md §
|
||
// Time pickers/Behaviour, and tests/Feature/Components/ComponentStylesheetsTest.php's
|
||
// token-values test): a viewport *height* in an `orientation` query is not a breakpoint,
|
||
// since M3 does not make one of it. A width, in any query, still is.
|
||
$orientationHeight = preg_match('/\(\s*orientation\s*:/', $feature) === 1
|
||
&& preg_match('/(?:^|[\s:<>=(])(?:min-|max-)?height\b/', $feature) === 1
|
||
&& ! str_contains($feature, 'width');
|
||
|
||
// Range syntax (`width >= 840px`, `840px <= width < 1200px`), as the layout stylesheets'
|
||
// own check required before it folded into this one: never `min-width`/`max-width`.
|
||
expect($feature)->not->toMatch('/\b(?:min|max)-(?:width|height|aspect-ratio)\b/', "{$query} is not written as a range");
|
||
|
||
preg_match_all('/(\d*\.?\d+)([a-z%]*)/i', $feature, $lengths, PREG_SET_ORDER);
|
||
|
||
foreach ($lengths as [, $number, $unit]) {
|
||
expect($unit)->toBe('px', $query);
|
||
|
||
if (! $orientationHeight) {
|
||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it('writes every matchMedia width in resources/js at an M3 breakpoint', function () {
|
||
// The one check BreakpointsTest.php (deleted) covered that nothing else did: a script that
|
||
// widens or narrows window.matchMedia() by hand, at a width M3 does not know, rather than
|
||
// asking resources/js/breakpoints.js for the number. Isolated the same way that test isolated
|
||
// a media query's own condition — only the text inside matchMedia()'s call, up to its first
|
||
// closing quote or backtick — so a real width sitting elsewhere in the file, in a plain
|
||
// declaration rather than a query condition (a `min(40rem, 70dvh)`-style cap; none exists in
|
||
// resources/js today), is never mistaken for a breakpoint.
|
||
$files = collect(File::allFiles(__DIR__.'/../../resources/js'))
|
||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'js');
|
||
|
||
expect($files)->not->toBeEmpty();
|
||
|
||
foreach ($files as $file) {
|
||
preg_match_all('/matchMedia\(\s*[\'"`][^\'"`]*/', $file->getContents(), $conditions);
|
||
|
||
foreach ($conditions[0] as $condition) {
|
||
preg_match_all('/(\d*\.?\d+)(rem|px)/i', $condition, $lengths, PREG_SET_ORDER);
|
||
|
||
foreach ($lengths as [, $number, $unit]) {
|
||
// breakpoints.js itself never writes a literal number — from() and upTo() build
|
||
// the string from the shared table (`${width(name)}px`) — so today's tree has none
|
||
// to check; this only guards whichever file writes one by hand next.
|
||
$allowed = strtolower($unit) === 'px' ? ['600', '840', '1200', '1600'] : ['37.5', '52.5', '75', '100'];
|
||
|
||
expect(in_array($number, $allowed, true))->toBeTrue(
|
||
$file->getRelativePathname().': '.trim($condition).' is not at an M3 breakpoint'
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it('declares M3\'s spacing scale as the reference gives it', function () {
|
||
preg_match_all(
|
||
'/^\|\s*\**space(\d+)\**\s*\|\s*\**([\d.]+)×\**\s*\|\s*\**(\d+)dp/m',
|
||
File::get(__DIR__.'/../../docs/reference/m3/styles-supplement.md'),
|
||
$rows,
|
||
PREG_SET_ORDER,
|
||
);
|
||
|
||
$reference = collect($rows)->mapWithKeys(fn (array $row): array => ["--md-sys-measurement-space{$row[1]}" => "{$row[3]}px"])->all();
|
||
$tokens = ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
|
||
|
||
expect($reference)->toHaveCount(13)
|
||
->and($tokens)->toBe($reference);
|
||
|
||
// space100 is the 8dp base unit and every name is its multiplier: space125 = 1.25 × 8.
|
||
foreach ($rows as [, $name, $multiplier, $value]) {
|
||
expect((float) $name / 100 * 8)->toBe((float) $value)
|
||
->and((float) $multiplier * 8)->toBe((float) $value);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* The layout components' stylesheets: all.css's own `./layout/*.css` imports, each with
|
||
* everything it in turn imports (which reaches into `components/`, for the layout files that draw
|
||
* with a component, e.g. pane.css's app bar and button).
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function layoutStylesheets(): array
|
||
{
|
||
$files = [];
|
||
|
||
foreach (stylesheetImports(stylesheetPath('all.css')) as $import) {
|
||
if (! str_starts_with($import, './layout/')) {
|
||
continue;
|
||
}
|
||
|
||
foreach (stylesheetTree(stylesheetPath($import)) as $file) {
|
||
if (! in_array($file, $files, true)) {
|
||
$files[] = $file;
|
||
}
|
||
}
|
||
}
|
||
|
||
return $files;
|
||
}
|
||
|
||
it('imports every stylesheet under components/ and layout/ directly, each exactly once', function () {
|
||
$imports = stylesheetImports(stylesheetPath('all.css'));
|
||
|
||
expect(count($imports))->toBe(count(array_unique($imports)), 'all.css imports something more than once');
|
||
|
||
$expected = collect(File::files(stylesheetPath('components')))
|
||
->map(fn (SplFileInfo $file): string => "./components/{$file->getFilename()}")
|
||
->merge(collect(File::files(stylesheetPath('layout')))->map(fn (SplFileInfo $file): string => "./layout/{$file->getFilename()}"))
|
||
->sort()
|
||
->values()
|
||
->all();
|
||
|
||
// Every file under both directories is imported directly — a shared one (navigation-item.css,
|
||
// selection.css, menu.css included) as much as a component's own — and nothing stale remains.
|
||
$actual = collect($imports)
|
||
->filter(fn (string $import): bool => str_starts_with($import, './components/') || str_starts_with($import, './layout/'))
|
||
->sort()
|
||
->values()
|
||
->all();
|
||
|
||
expect($actual)->toBe($expected);
|
||
});
|
||
|
||
it('gives every component view a stylesheet, or lists it on a short, documented exception', function () {
|
||
// theme-script renders an inline <script> only — no markup of its own for a stylesheet to
|
||
// draw. tab renders data-md-tab-panel, drawn by tabs.css, its parent <x-tabs>'s own
|
||
// stylesheet (tabs.blade.php's own header says so).
|
||
$exceptions = ['theme-script' => true, 'tab' => true];
|
||
|
||
$views = collect(File::files(__DIR__.'/../../resources/views/components'))
|
||
->map(fn (SplFileInfo $file): string => $file->getBasename('.blade.php'));
|
||
|
||
expect($views)->not->toBeEmpty();
|
||
|
||
foreach ($views as $name) {
|
||
if (isset($exceptions[$name])) {
|
||
continue;
|
||
}
|
||
|
||
expect(is_file(stylesheetPath("components/{$name}.css")) || is_file(stylesheetPath("layout/{$name}.css")))
|
||
->toBeTrue("{$name}.blade.php has no stylesheet, in components/ or layout/, and is not on the documented exception list");
|
||
}
|
||
|
||
foreach (array_keys($exceptions) as $name) {
|
||
expect($views->contains($name))->toBeTrue("{$name} is on the documented exception list but no longer exists as a component view");
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Every component view ComponentStylesheetsTest used to check one at a time, in one dataset
|
||
* instead: every `resources/views/components/*.blade.php` backed by a `components/*.css` file of
|
||
* its own, `tabs` and `tab` folded into the one entry `imports…()` below already special-cases
|
||
* (they share tabs.css), `theme-script` (no stylesheet at all) and every layout component (the
|
||
* test above covers those) left out.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function componentStylesheetViews(): array
|
||
{
|
||
// Plain glob(), not the File facade: dataset() below runs while the file is parsed, before
|
||
// Pest has booted the application the facade needs.
|
||
return collect(glob(__DIR__.'/../../resources/views/components/*.blade.php'))
|
||
->map(fn (string $file): string => basename($file, '.blade.php'))
|
||
->reject(fn (string $name): bool => in_array($name, ['theme-script', 'tab'], true))
|
||
->filter(fn (string $name): bool => is_file(__DIR__."/../../resources/css/components/{$name}.css"))
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
dataset('component stylesheet views', componentStylesheetViews());
|
||
|
||
/**
|
||
* The `<x-livewire-material::…>` tags a view renders, including any inside a
|
||
* `@include('livewire-material::partials.<name>', …)` it pulls in — markup a view moved out to
|
||
* resources/views/partials/<name>.blade.php (menu's filter field, the timepicker's period picker)
|
||
* still renders the same tags, just not inline where a plain regex over the view alone would see
|
||
* them.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
function renderedComponentTags(string $view): array
|
||
{
|
||
$content = File::get($view);
|
||
|
||
preg_match_all('/<x-livewire-material::([a-z-]+)/', $content, $tags);
|
||
preg_match_all('/@include\(\'livewire-material::partials\.([\w.-]+)\'/', $content, $partials);
|
||
|
||
return collect($tags[1])
|
||
->merge(collect($partials[1])->flatMap(fn (string $partial): array => renderedComponentTags(__DIR__."/../../resources/views/partials/{$partial}.blade.php")))
|
||
->all();
|
||
}
|
||
|
||
it('imports, from every component stylesheet, the stylesheet of each component its view renders', function (string $name) {
|
||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php; every other
|
||
// component stylesheet in this dataset has exactly one view of its own name.
|
||
$views = $name === 'tabs' ? ['tabs', 'tab'] : [$name];
|
||
|
||
$rendered = collect($views)
|
||
->flatMap(fn (string $view): array => renderedComponentTags(__DIR__."/../../resources/views/components/{$view}.blade.php"))
|
||
->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(stylesheetPath("components/{$tag}.css")) && str_contains(File::get(stylesheetPath("components/{$tag}.css")), '@layer material.components'))
|
||
->map(fn (string $tag): string => "./{$tag}.css")
|
||
->values()
|
||
->all();
|
||
|
||
$imports = stylesheetImports(stylesheetPath("components/{$name}.css"));
|
||
|
||
expect(array_values(array_diff($rendered, $imports)))->toBe([]);
|
||
})->with('component stylesheet views');
|
||
|
||
it('imports a stylesheet for every layout component from all.css, each beside its view', function () {
|
||
$imports = array_values(array_filter(
|
||
stylesheetImports(stylesheetPath('all.css')),
|
||
fn (string $import): bool => str_starts_with($import, './layout/'),
|
||
));
|
||
$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. Both are still imported directly by all.css, like every other layout file
|
||
// — only the "has its own component" half of the check below skips them.
|
||
$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 ($files as $name) {
|
||
expect(in_array("./layout/{$name}.css", $imports, true))->toBeTrue("layout/{$name}.css is not imported directly by all.css");
|
||
}
|
||
|
||
foreach ($components as $name) {
|
||
expect(File::exists(__DIR__."/../../resources/views/components/{$name}.blade.php"))->toBeTrue("layout/{$name}.css has no component");
|
||
}
|
||
});
|
||
|
||
it('imports, from every layout stylesheet, the stylesheet of each component its view renders', function () {
|
||
$checked = 0;
|
||
|
||
foreach (File::files(stylesheetPath('layout')) as $file) {
|
||
$name = $file->getBasename('.css');
|
||
$view = __DIR__."/../../resources/views/components/{$name}.blade.php";
|
||
|
||
if (! File::exists($view)) {
|
||
continue;
|
||
}
|
||
|
||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get($view), $tags);
|
||
$imports = stylesheetImports($file->getPathname());
|
||
|
||
foreach (array_unique($tags[1]) as $tag) {
|
||
$import = match (true) {
|
||
File::exists(stylesheetPath()."/components/{$tag}.css") => "../components/{$tag}.css",
|
||
File::exists(stylesheetPath()."/layout/{$tag}.css") => "./{$tag}.css",
|
||
default => null,
|
||
};
|
||
|
||
if ($import !== null) {
|
||
$checked++;
|
||
|
||
expect(in_array($import, $imports, true))->toBeTrue("layout/{$name}.css renders <x-{$tag}> without importing {$import}");
|
||
}
|
||
}
|
||
}
|
||
|
||
expect($checked)->toBeGreaterThan(0);
|
||
});
|
||
|
||
// Every layout stylesheet's header, layer statement and plain-imports shape, and its media
|
||
// queries off an M3 breakpoint, are covered by the two all.css-wide tests above (allStylesheets()
|
||
// reaches every layout file too) — a dedicated layout-only version of either would only repeat
|
||
// that same check on a subset already checked.
|
||
|
||
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
|
||
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
|
||
// file draws on (pane.css: app-bar.css and button.css, for its own top app bar and back
|
||
// button) — each already checked, in material.components, by ComponentStylesheetsTest.
|
||
foreach (layoutStylesheets() as $file) {
|
||
$name = stylesheetName($file);
|
||
|
||
if (! str_starts_with($name, 'layout/')) {
|
||
continue;
|
||
}
|
||
|
||
$css = File::get($file);
|
||
$layer = match ($name) {
|
||
'layout/visibility.css' => 'material.visibility',
|
||
default => 'material.layout',
|
||
};
|
||
|
||
expect(ComponentStylesheet::withoutComments($css))->not->toContain('!important', $name);
|
||
|
||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($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('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(ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
|
||
);
|
||
|
||
expect(Layout::SPACING)->toBe($tokens);
|
||
|
||
$spacing = File::get(stylesheetPath('layout/spacing.css'));
|
||
|
||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
|
||
|
||
foreach ($tokens as $token) {
|
||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||
->and(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||
}
|
||
|
||
expect(collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(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(ComponentStylesheet::items(ComponentStylesheet::withoutComments($visibility)))->mapWithKeys(function (array $item): array {
|
||
$rule = ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body']))[0];
|
||
|
||
expect(ComponentStylesheet::flatDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||
|
||
return [$item['prelude'] => $rule['prelude']];
|
||
})->all();
|
||
|
||
expect($actual)->toBe($expected);
|
||
});
|
||
|
||
it('gives every md-type class exactly font, letter-spacing and font-variation-settings', function () {
|
||
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
|
||
|
||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
|
||
|
||
expect($classes)->toHaveCount(30);
|
||
|
||
foreach ($classes as $class => $declarations) {
|
||
expect(array_keys($declarations))->toBe(['font', 'letter-spacing', 'font-variation-settings'], $class);
|
||
}
|
||
});
|
||
|
||
it('ships exactly the documented text classes, each ink an M3 role', function () {
|
||
$text = File::get(stylesheetPath('text.css'));
|
||
|
||
$styles = collect(['display', 'headline', 'title', 'body', 'label'])
|
||
->crossJoin(['lg', 'md', 'sm'])
|
||
->flatMap(fn (array $style): array => ["md-type-{$style[0]}-{$style[1]}", "md-type-emphasized-{$style[0]}-{$style[1]}"]);
|
||
|
||
$inks = [
|
||
'md-ink' => 'on-surface',
|
||
'md-ink-variant' => 'on-surface-variant',
|
||
'md-ink-quiet' => 'outline',
|
||
'md-ink-primary' => 'primary',
|
||
'md-ink-error' => 'error',
|
||
'md-ink-success' => 'success',
|
||
'md-ink-warning' => 'warning',
|
||
'md-ink-info' => 'info',
|
||
'md-ink-inverse' => 'inverse-on-surface',
|
||
];
|
||
|
||
$rest = [
|
||
'md-text-start' => ['text-align' => 'start'],
|
||
'md-text-center' => ['text-align' => 'center'],
|
||
'md-text-end' => ['text-align' => 'end'],
|
||
'md-truncate' => ['overflow' => 'hidden', 'text-overflow' => 'ellipsis', 'white-space' => 'nowrap'],
|
||
'md-line-clamp-2' => ['overflow' => 'hidden', 'display' => '-webkit-box', '-webkit-box-orient' => 'vertical', '-webkit-line-clamp' => '2'],
|
||
'md-line-clamp-3' => ['overflow' => 'hidden', 'display' => '-webkit-box', '-webkit-box-orient' => 'vertical', '-webkit-line-clamp' => '3'],
|
||
'md-nowrap' => ['white-space' => 'nowrap'],
|
||
'md-tabular' => ['font-variant-numeric' => 'tabular-nums'],
|
||
'md-mono' => ['font-family' => 'var(--md-ref-typeface-mono)'],
|
||
'md-visually-hidden' => [
|
||
'position' => 'absolute', 'width' => '1px', 'height' => '1px', 'padding' => '0', 'margin' => '-1px',
|
||
'overflow' => 'hidden', 'clip-path' => 'inset(50%)', 'white-space' => 'nowrap', 'border-width' => '0',
|
||
],
|
||
];
|
||
|
||
$classes = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($text, '@layer material.text'))))
|
||
->map(fn (array $item): string => $item['prelude'] ?? $item['statement'])
|
||
->all();
|
||
|
||
expect($classes)->toBe($styles->merge(array_keys($inks))->merge(array_keys($rest))->map(fn (string $class): string => ".{$class}")->all());
|
||
|
||
foreach ($inks as $class => $role) {
|
||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
|
||
}
|
||
|
||
foreach ($rest as $class => $declarations) {
|
||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||
}
|
||
});
|
||
|
||
it('opens the Workbench\'s one CSS entry with the layer statement, then all.css, showcase.css and the scheme', function () {
|
||
$app = ComponentStylesheet::withoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||
|
||
// Opens by ordering the layers, so the order holds whichever the page links first.
|
||
expect(trim($app))->toStartWith(MATERIAL_LAYER_STATEMENT)
|
||
->toContain("@import '../../../resources/css/all.css';")
|
||
->toContain("@import '../../../resources/css/showcase.css';")
|
||
->toContain("@import './material-scheme.css';")
|
||
->and(File::get(__DIR__.'/../../vite.config.js'))
|
||
->toContain("input: ['workbench/resources/css/app.css', 'workbench/resources/js/app.js']")
|
||
->and(config('livewire-material.showcase.vite'))->toBe([
|
||
'workbench/resources/css/app.css',
|
||
'workbench/resources/js/app.js',
|
||
]);
|
||
});
|
||
|
||
it('shapes showcase.css like a package stylesheet, with its documented unlayered exceptions', function () {
|
||
$path = __DIR__.'/../../resources/css/showcase.css';
|
||
$css = File::get($path);
|
||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($css));
|
||
|
||
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
|
||
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement');
|
||
|
||
$blocks = false;
|
||
$unlayered = [];
|
||
|
||
foreach (array_slice($items, 1) as $item) {
|
||
if (isset($item['prelude'])) {
|
||
$blocks = true;
|
||
|
||
if ($item['prelude'] === '@layer material.components') {
|
||
expect($item['body'])->not->toContain('@layer');
|
||
} else {
|
||
$unlayered[$item['prelude']] = true;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
expect($blocks)->toBeFalse("`{$item['statement']}` comes after a block")
|
||
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "`{$item['statement']}` is not a plain import");
|
||
}
|
||
|
||
// showcase.css has no imports of its own (its header says why); the day it needs one, this
|
||
// still holds because it is a plain @import assertion above, not a fixed empty list here.
|
||
expect($unlayered)->toBe([
|
||
// Deliberately unlayered, as an application's own classes are — see the file's header.
|
||
'.showcase-w-narrow' => true,
|
||
'.showcase-ink-tertiary' => true,
|
||
'.showcase-ink-secondary' => true,
|
||
]);
|
||
|
||
// Bundled with all.css on every showcase page, so it styles its own hooks and classes only,
|
||
// and each of those is still drawn by some showcase view.
|
||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', ComponentStylesheet::withoutComments($css)), $preludes);
|
||
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
|
||
|
||
foreach ($preludes[1] as $prelude) {
|
||
foreach (array_map('trim', explode(',', $prelude)) as $selector) {
|
||
expect($selector)->toMatch('/^(?:\[data-md-showcase[\w-]*[\]=]|\.showcase-[\w-]+)/', "showcase.css selects `{$selector}`, outside the showcase's own hooks");
|
||
}
|
||
}
|
||
|
||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', ComponentStylesheet::withoutComments($css), $hooks);
|
||
|
||
foreach (array_unique($hooks[0]) as $hook) {
|
||
expect(str_contains($views, ltrim($hook, '.')))->toBeTrue("showcase.css draws `{$hook}`, which no showcase view renders");
|
||
}
|
||
|
||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments($css), $matches);
|
||
|
||
foreach ($matches[1] as $query) {
|
||
$feature = trim($query);
|
||
|
||
expect($feature)->not->toMatch('/\b(?:min|max)-(?:width|height)\b/', "{$feature} is not written as a range");
|
||
|
||
preg_match_all('/(\d*\.?\d+)([a-z%]*)/i', $feature, $lengths, PREG_SET_ORDER);
|
||
|
||
foreach ($lengths as [, $number, $unit]) {
|
||
expect($unit)->toBe('px', $feature)
|
||
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$feature} is not at an M3 breakpoint");
|
||
}
|
||
}
|
||
});
|
||
|
||
it('keeps the showcase bundle, all.css with showcase.css, under a gzip size budget', function () {
|
||
// 40,167 bytes gzipped today (2026-09-17), measured with PHP's own gzencode() on
|
||
// resources/dist/showcase.css, the prebuilt bundle the showcase serves, comments stripped. A
|
||
// little organic growth should not retrigger this on every commit; a real jump means look at
|
||
// what grew since the budget was last raised.
|
||
$budget = 44_000;
|
||
|
||
$gzipped = strlen((string) gzencode(File::get(__DIR__.'/../../resources/dist/showcase.css'), 9));
|
||
|
||
expect($gzipped)->toBeLessThanOrEqual($budget, sprintf(
|
||
'The showcase bundle now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||
number_format($gzipped),
|
||
number_format($budget),
|
||
));
|
||
});
|