Files
livewire-material/tests/Feature/StylesheetsTest.php
T

575 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Layout;
/**
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. 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.
*
* Until the components leave Tailwind, the checks cover the foundation — foundation.css and every
* file it imports, text.css included — and material.css and tailwind.css carry the rest.
*/
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(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
}
/**
* A stylesheet without its comments; a quoted string keeps whatever it holds.
*/
function stylesheetWithoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The top-level statements (`@layer …;`, `@import …;`) and blocks of a stylesheet, in order, with
* whitespace collapsed in statements and preludes.
*
* @return list<array{statement: string}|array{prelude: string, body: string}>
*/
function stylesheetItems(string $css): array
{
$css = stylesheetWithoutComments($css);
$items = [];
$start = 0;
$depth = 0;
$quote = null;
$opening = 0;
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
$char = $css[$i];
if ($quote !== null) {
if ($char === '\\') {
$i++;
} elseif ($char === $quote) {
$quote = null;
}
continue;
}
if ($char === '"' || $char === "'") {
$quote = $char;
} elseif ($char === '{') {
if ($depth++ === 0) {
$opening = $i;
}
} elseif ($char === '}' && --$depth === 0) {
$items[] = [
'prelude' => (string) preg_replace('/\s+/', ' ', trim(substr($css, $start, $opening - $start))),
'body' => substr($css, $opening + 1, $i - $opening - 1),
];
$start = $i + 1;
} elseif ($char === ';' && $depth === 0) {
$items[] = ['statement' => preg_replace('/\s+/', ' ', trim(substr($css, $start, $i - $start))).';'];
$start = $i + 1;
}
}
expect(trim(substr($css, $start)))->toBe('', 'The stylesheet ends inside an unclosed rule.');
return $items;
}
/**
* The declarations of a flat block body, by property.
*
* @return array<string, string>
*/
function stylesheetDeclarations(string $body): array
{
return collect(explode(';', $body))
->map(fn (string $declaration): string => trim($declaration))
->filter()
->mapWithKeys(fn (string $declaration): array => [trim(strstr($declaration, ':', true)) => trim(substr(strstr($declaration, ':'), 1))])
->all();
}
/**
* 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 (stylesheetItems($css) as $item) {
if (($item['prelude'] ?? null) === $prelude) {
return $item['body'];
}
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
foreach (stylesheetItems($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(stylesheetItems(File::get($file)))
->map(fn (array $item): ?string => preg_match('/^@import\s+([\'"])(.+?)\1/', $item['statement'] ?? '', $match) === 1 ? $match[2] : null)
->filter()
->values()
->all();
}
/**
* A stylesheet and every stylesheet it reaches through `@import`, each once, in the order a
* bundler meets them. `tailwindcss` itself is a package, not a file, and 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'));
}
function stylesheetName(string $file): string
{
return substr($file, strlen(stylesheetPath()) + 1);
}
it('brings every foundation file and text.css into the foundation, and nothing of Tailwind', 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.
->toBe(['reset.css', 'hidden.css', 'tokens.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')
// The Tailwind half: the theme, the token utilities and the components still written for it.
->and(array_values(array_intersect($foundation, ['tokens/theme.css', 'tokens/utilities.css', 'tailwind.css', 'material.css'])))->toBe([])
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
});
it('opens every foundation stylesheet with a header, the layer statement and plain imports', function () {
foreach (foundationStylesheets() as $file) {
$name = stylesheetName($file);
$items = stylesheetItems(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; tokens.css only imports.
$layers = [
'foundation.css' => null,
'foundation/reset.css' => 'material.reset',
'foundation/hidden.css' => null,
'foundation/tokens.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(stylesheetWithoutComments(File::get($file)), '!important');
foreach (stylesheetItems(File::get($file)) as $item) {
if (! isset($item['prelude'])) {
continue;
}
if (! str_starts_with($item['prelude'], '@layer ')) {
$unlayered["{$name}: {$item['prelude']}"] = stylesheetDeclarations($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, where both the foundation and material.css reach it', function () {
$definitions = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
->filter(fn (SplFileInfo $file): bool => str_contains(stylesheetWithoutComments($file->getContents()), '[x-cloak]'))
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
->values()
->all();
$material = array_map(stylesheetName(...), stylesheetTree(stylesheetPath('material.css')));
expect($definitions)->toBe(['foundation/hidden.css'])
->and($material)->toContain('foundation/hidden.css', 'tailwind.css')
// Imported after Tailwind, the reset would sit in a layer above Tailwind's utilities.
->and(array_values(array_intersect($material, ['foundation.css', 'foundation/reset.css', 'foundation/base.css', 'foundation/interaction.css', 'text.css'])))->toBe([]);
});
it('writes no Tailwind directive in the foundation', function () {
foreach (foundationStylesheets() as $file) {
expect(stylesheetWithoutComments(File::get($file)))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', stylesheetName($file))
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', stylesheetName($file));
}
});
it('writes a media query in the foundation only at M3\'s breakpoints, in px', function () {
$queries = collect(foundationStylesheets())
->flatMap(function (string $file): array {
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments(File::get($file)), $matches);
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
});
// The foundation's own queries (hover, reduced motion) hold no width; the check is for what
// lands here later.
expect($queries)->not->toBeEmpty();
foreach ($queries as $query) {
preg_match_all('/(\d*\.?\d+)([a-z%]*)/i', substr($query, strpos($query, ': ') + 2), $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $query)
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} 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 = stylesheetDeclarations(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: layout.css and everything it imports.
*
* @return list<string>
*/
function layoutStylesheets(): array
{
return stylesheetTree(stylesheetPath('layout.css'));
}
it('imports a stylesheet for every layout component from layout.css, each beside its view', function () {
$imports = stylesheetImports(stylesheetPath('layout.css'));
$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.
$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 ($components as $name) {
expect(File::exists(__DIR__."/../../resources/views/components/{$name}.blade.php"))->toBeTrue("layout/{$name}.css has no component")
->and($imports)->toContain("./layout/{$name}.css");
}
});
it('opens every layout stylesheet with a header, the layer statement and plain imports', function () {
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
$items = stylesheetItems(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")
->and($item['statement'])->toMatch('/^@import ([\'"])\.{1,2}\/[\w.\/-]+\.css\1;$/', "{$name}: `{$item['statement']}` is not a plain import");
}
}
});
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
$css = File::get($file);
$layer = match ($name) {
'layout/visibility.css' => 'material.visibility',
default => 'material.layout',
};
expect(stylesheetWithoutComments($css))->not->toContain('!important', $name);
foreach (stylesheetItems($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('writes no Tailwind in the layout stylesheets, and media queries only at M3\'s breakpoints, in px', function () {
$queries = 0;
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
$css = stylesheetWithoutComments(File::get($file));
expect($css)
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', $name)
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', $name);
preg_match_all('/@media\b([^{]*)\{/', $css, $matches);
foreach ($matches[1] as $query) {
$queries++;
expect(trim($query))->toMatch('/^\(width (?:<|>=) (?:600|840|1200|1600)px\)$/', "{$name}: `@media {$query}` is not an M3 breakpoint in px");
}
}
expect($queries)->toBeGreaterThan(0);
});
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(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
);
expect(Layout::SPACING)->toBe($tokens);
$spacing = File::get(stylesheetPath('layout/spacing.css'));
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
foreach ($tokens as $token) {
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
}
expect(collect(stylesheetItems(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(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
$rule = stylesheetItems($item['body'])[0];
expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']);
return [$item['prelude'] => $rule['prelude']];
})->all();
expect($actual)->toBe($expected);
});
it('gives every md-type class the declarations of its type utility', function () {
preg_match_all('/@utility (type-[\w-]+) \{\n(.*?)\n\}/s', File::get(stylesheetPath('tokens/utilities.css')), $matches, PREG_SET_ORDER);
$utilities = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
$text = File::get(stylesheetPath('text.css'));
expect($utilities)->toHaveCount(30);
foreach ($utilities as $class => $declarations) {
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations, $class)
->and(array_keys($declarations))->toBe(['font', 'letter-spacing', 'font-variation-settings']);
}
});
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-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(stylesheetItems(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(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
}
foreach ($rest as $class => $declarations) {
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
}
});
it('draws the interaction classes as the utilities the Tailwind components still use', function () {
$utilities = File::get(stylesheetPath('tokens/utilities.css'));
$interaction = File::get(stylesheetPath('foundation/interaction.css'));
$normalise = fn (string $body): string => trim((string) preg_replace('/\s+/', ' ', $body));
foreach (['state-layer', 'focus-ring', 'touch-target', 'link'] as $name) {
$class = stylesheetBlock($interaction, ".md-{$name}");
expect($normalise(str_replace(
// The two deliberate differences: the data-md-* hook beside the old one, and M3's 48
// CSS pixels for the target where the utility reads 3rem.
['&:is([data-md-dragged], [data-dragged])', 'var(--md-sys-measurement-space600)'],
['&[data-dragged]', '3rem'],
$class,
)))->toBe($normalise(stylesheetBlock($utilities, "@utility {$name}")), $name);
}
expect(stylesheetBlock($interaction, '.md-state-layer'))->toContain('&:is([data-md-dragged], [data-dragged])::before')
->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);');
});
it('declares the material layers in the Workbench between Tailwind\'s preflight and its utilities', function () {
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// The first statement orders the layers: above preflight, whose `* { padding: 0 }` would
// otherwise beat a rewritten component's padding, and below the utilities.
expect(trim($app))->toStartWith('@layer theme, base, material, components, utilities;');
$foundation = strpos($app, "@import '../../../resources/css/foundation.css';");
$tailwind = strpos($app, "@import 'tailwindcss'");
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");
expect($foundation)->toBeInt()
->and($tailwind)->toBeGreaterThan($foundation)
->and($components)->toBeGreaterThan($tailwind)
// material.css would declare the tokens a second time.
->and($app)->not->toContain('material.css');
});