575 lines
24 KiB
PHP
575 lines
24 KiB
PHP
<?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');
|
||
});
|