'…'` * array entry, or the items of `->class([…])` — the only classes a package view writes at all * (plan step 36, "Interaction is the shared classes"). * * @var list */ function inputAllowedClasses(): array { return ['md-state-layer', 'md-focus-ring', 'md-touch-target']; } /** * Fails unless every class list the given Blade source writes is one of the allowed interaction * classes, or a caller's own class forwarded whole — `class="{{ $var }}"` on a plain element, * `:class="$attributes->get('class')"` forwarding it to a nested `` (the * pattern a wrapper with no root element of its own uses, since it has no HTML tag to interpolate * the value into), or a slot's own attributes. */ function assertNoClassList(string $view, string $label): void { $allowed = inputAllowedClasses(); // `@class()`, `Arr::toCssClasses` maps and Alpine's `:class`/`x-bind:class` binding on a plain // element are never how a class reaches the page here. expect($view)->not->toMatch('/@class\(|toCssClasses|x-bind:class=/'); // A literal `class="…"` may hold only the interaction classes — any subset, any order, with a // simple `@if (…) … @endif` around one token — or a caller's value forwarded whole (`hint-class`, // `icon-class`: `class="{{ $…}}"`). preg_match_all('/(?toBe([], "{$label} writes class=\"{$content}\", not only the interaction classes"); } // A PHP `'class' => '…'` array entry (an attribute bag built by hand, as a native control nested // in a component often is) follows the same rule. preg_match_all("/'class'\s*=>\s*'([^']*)'/", $view, $classArrays, PREG_SET_ORDER); foreach ($classArrays as [, $value]) { $tokens = array_values(array_filter(explode(' ', $value))); expect(array_diff($tokens, $allowed))->toBe([], "{$label}: 'class' => '{$value}' is not only the interaction classes"); } // A bare `:class="…"` (never `x-bind:class`, excluded above) only ever forwards the caller's own // class whole to a nested component's root, which has no HTML tag of its own to interpolate it // into — input, password, textarea, select and file hand `class` to the field this way. preg_match_all('/(?toBe("\$attributes->get('class')", "{$label} writes :class=\"{$content}\", not the caller's class forwarded whole"); } // `$attributes->class([…])` merges the interaction classes with the caller's own. Only the // array's own items are checked — a condition may nest its own brackets, which are no class // list of the view's writing. preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $classCalls, PREG_SET_ORDER); foreach ($classCalls as [, $args]) { $depth = 0; for ($i = 0, $length = strlen($args); $i < $length; $i++) { $char = $args[$i]; if ($char === '[' || $char === '(') { $depth++; } elseif ($char === ']' || $char === ')') { $depth--; } elseif ($depth === 0 && $char === "'") { $end = strpos($args, "'", $i + 1); $item = substr($args, $i + 1, $end - $i - 1); expect(in_array($item, $allowed, true))->toBeTrue("{$label}: ->class() writes [{$item}], not an interaction class"); $i = $end; } } } } /** * Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four * breakpoints — except a query keyed on `orientation`, which is never a breakpoint (the timepicker's * landscape layout is orientation and viewport *height*, a device fact M3 explicitly does not make a * window size class of; docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/ * Behaviour) and so is free to name any px height. The px rule itself still holds: `rem` and `em` * would grow with the reader's text size, which a breakpoint or a device height never should. */ function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void { foreach ($css->mediaQueries() as $query) { $isOrientation = str_contains($query, 'orientation:'); preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER); foreach ($lengths as [, $number, $unit]) { expect($unit)->toBe('px', "{$label}: {$query}"); if (! $isOrientation) { expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint"); } } } } /** * Colours are roles, shadows are elevation levels, type is a type style, motion is a spring — and * breakpoints are px, at one of M3's four (an orientation query aside; see assertBreakpointsInPx()). * * One named exception: field.css's autofill workaround (`transition: background-color 604800s, * color 604800s`) delays Chrome's autofill background for a week, which is not a spring and is not * meant to be one — a `var(--md-sys-motion-*)` duration here would visibly repaint on load. */ function assertTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void { $source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css); $source = str_replace('background-color 604800s, color 604800s', '', $source); expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i') ->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/') ->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/') ->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/'); assertBreakpointsInPx($css, $label); } it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { $css = ComponentStylesheet::read($name); expect($css->css)->toStartWith('/*') ->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;') ->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/') ->and($css->blocks())->each->toBe('@layer material.components') ->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/'); foreach ($css->imports() as $import) { expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist"); } })->with('input components'); it('imports the stylesheet of every component its view renders', function (string $name) { preg_match_all('/unique() // A component still written for Tailwind has no stylesheet of its own to import yet; its // old stylesheet, if it has one, lives in tailwind.css without the layer statement. ->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components')) ->map(fn (string $tag): string => "./{$tag}.css") ->values() ->all(); expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]); })->with('input components'); it('writes no class list into the view but the interaction classes', function (string $name) { assertNoClassList(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), "{$name}.blade.php"); })->with('input components'); it('takes its values from the tokens and its breakpoints in px', function (string $name) { assertTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css"); })->with('input components'); it('is imported from the inputs, selection and data block of components.css', function (string $name) { $components = File::get(__DIR__.'/../../../resources/css/components.css'); $block = substr($components, (int) strpos($components, '/* Inputs, selection and data */')); $block = substr($block, 0, (int) strpos($block, '/* Containment */')); expect($block)->toContain("@import './components/{$name}.css';"); })->with('input components'); // ---- pagination: its views live outside resources/views/components/ -------------------------- it('draws pagination from a stylesheet shaped like every package stylesheet', function () { $css = ComponentStylesheet::read('pagination'); expect($css->css)->toStartWith('/*') ->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;') ->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/') ->and($css->blocks())->each->toBe('@layer material.components') ->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/'); foreach ($css->imports() as $import) { expect(is_file(dirname(ComponentStylesheet::path('pagination')).'/'.$import))->toBeTrue("pagination.css imports {$import}, which does not exist"); } }); it('takes pagination\'s values from the tokens and its breakpoints in px', function () { assertTokensAndPxBreakpoints(ComponentStylesheet::read('pagination'), 'pagination.css'); }); it('is imported from the inputs, selection and data block of components.css for pagination', function () { $components = File::get(__DIR__.'/../../../resources/css/components.css'); $block = substr($components, (int) strpos($components, '/* Inputs, selection and data */')); $block = substr($block, 0, (int) strpos($block, '/* Containment */')); expect($block)->toContain("@import './components/pagination.css';"); }); dataset('pagination views', [ 'pagination/laravel/tailwind', 'pagination/laravel/simple-tailwind', 'pagination/livewire/tailwind', 'pagination/livewire/simple-tailwind', ]); it('imports the stylesheet of every component pagination\'s views render', function (string $view) { preg_match_all('/unique() ->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components')) ->map(fn (string $tag): string => "./{$tag}.css") ->values() ->all(); expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]); })->with('pagination views'); it('writes no class list into pagination\'s views but the interaction classes', function (string $view) { assertNoClassList(File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), "{$view}.blade.php"); })->with('pagination views');