Plan step 37: all.css replaces the two interim import lists with one entry for an application that wants everything — the foundation, every layout stylesheet and every component stylesheet, grouped under the same block comments components.css used, plus a Layout block. It also directly imports the three files nothing imported by name before (layout/spacing.css, layout/visibility.css, components/selection.css), so every file under components/ and layout/ is now one @import away. Every test that read a block of components.css or layout.css now reads the matching block of all.css through one shared helper (allCssBlock(), in tests/Pest.php so it loads for any test run) instead of repeating the same substr() search in each file. StylesheetsTest.php's shape, Tailwind-free and breakpoint checks, previously run twice (once for the foundation, once for the layout tree), now run once over the whole tree all.css reaches, since every component and layout stylesheet is plain CSS after step 36; a new test asserts all.css imports everything under components/ and layout/ exactly once. The Workbench imports all.css in place of layout.css and components.css. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
653 lines
27 KiB
PHP
653 lines
27 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.
|
||
*
|
||
* Every package component and layout stylesheet is plain CSS now (plan step 36), so the shape,
|
||
* Tailwind-free and breakpoint checks below run over the whole tree `all.css` reaches (foundation,
|
||
* layout and components together) rather than the foundation alone — everything but
|
||
* `tailwind.css` and `tokens/theme.css`/`tokens/utilities.css`, which stay Tailwind until plan
|
||
* step 39 and are simply never reached from `all.css`. A component's own values (colours as
|
||
* roles, shadows as elevation levels, and so on) are each group's own concern, in
|
||
* tests/Feature/Components/*StylesheetsTest.php; `bundle()`, the PHP bundler `all.css` is built
|
||
* for, is 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(__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'));
|
||
}
|
||
|
||
/**
|
||
* Everything all.css reaches: the foundation, every layout stylesheet and every component
|
||
* stylesheet, each once. What a check runs over now that every one of them is plain CSS (plan
|
||
* step 36) — `tailwind.css` and its two token files are never in this tree, since all.css does
|
||
* not import them.
|
||
*
|
||
* @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 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'])))->toBe([])
|
||
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
|
||
});
|
||
|
||
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 = 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, 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(stylesheetWithoutComments($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'));
|
||
});
|
||
|
||
it('writes no Tailwind directive anywhere all.css reaches', function () {
|
||
foreach (allStylesheets() 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, 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([^{]*)\{/', stylesheetWithoutComments(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/InputStylesheetsTest.php's
|
||
// assertBreakpointsInPx()): 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');
|
||
|
||
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('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: 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('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 freedom from
|
||
// Tailwind and from a media query 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 its own group's stylesheet test.
|
||
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(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('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 in place of the old one, and M3's
|
||
// 48 CSS pixels for the target where the utility reads 3rem.
|
||
['&[data-md-dragged]', 'var(--md-sys-measurement-space600)'],
|
||
['&[data-dragged]', '3rem'],
|
||
$class,
|
||
)))->toBe($normalise(stylesheetBlock($utilities, "@utility {$name}")), $name);
|
||
}
|
||
|
||
expect(stylesheetBlock($interaction, '.md-state-layer'))->toContain('&[data-md-dragged]::before')
|
||
->not->toContain('[data-dragged]')
|
||
->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;');
|
||
|
||
$all = strpos($app, "@import '../../../resources/css/all.css';");
|
||
$tailwind = strpos($app, "@import 'tailwindcss'");
|
||
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");
|
||
|
||
expect($all)->toBeInt()
|
||
->and($tailwind)->toBeGreaterThan($all)
|
||
->and($components)->toBeGreaterThan($tailwind)
|
||
// material.css declared the tokens a second time; components.css and layout.css are the
|
||
// interim lists all.css replaced.
|
||
->and($app)->not->toContain('material.css')
|
||
->not->toContain("resources/css/components.css'")
|
||
->not->toContain("resources/css/layout.css'");
|
||
});
|