From a3eef657cc3c367fe72bc27066209c3629165da4 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 19:31:02 +0200 Subject: [PATCH] Share one view class rule between the stylesheet tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 36 review. The brief and step 34 let a package view write the md-* text classes beside the three interaction classes, but both stylesheet tests allowed only the latter, and each kept its own copy of the check. tests/Support/ViewClasses.php is now the one rule: the interaction classes, the text classes read from text.css, and a caller's class handed on whole. It also closes the holes the copies left: a single-quoted :class, an echo mixed into a class list, a hint-, icon- or box-class literal, a 'class' => entry that is not a plain literal, and ->class('…') as a string. InputStylesheetsTest's exemptions narrow too: the autofill transition is skipped in field.css alone; an orientation query may name any px height but never a width off a breakpoint; a box-shadow must be none, an elevation level, or a 0 0 0 ring in a colour role. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../Components/ActionStylesheetsTest.php | 56 +------ .../Components/InputStylesheetsTest.php | 154 +++++------------- tests/Support/ViewClasses.php | 149 +++++++++++++++++ 3 files changed, 192 insertions(+), 167 deletions(-) create mode 100644 tests/Support/ViewClasses.php diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 3d25d5ec..0652205d 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -2,10 +2,12 @@ use Illuminate\Support\Facades\File; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; +use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses; /** * The actions and communication components, rewritten without Tailwind (plan step 36): each view - * renders `data-md-*` attributes and no class list of its own, and each has a stylesheet in + * renders `data-md-*` attributes and no class list of its own beyond the interaction and text + * classes (tests/Support/ViewClasses.php), and each has a stylesheet in * `material.components` that imports the stylesheets of the components its view renders, writes * its values from the tokens and its breakpoints as px range queries, and is imported from the * "Actions and communication" block of components.css. @@ -61,56 +63,8 @@ it('imports the stylesheet of every component its view renders', function (strin expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]); })->with('action components'); -it('writes no class list into the view but the interaction classes', function (string $name) { - $view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"); - $allowed = ['md-state-layer', 'md-focus-ring', 'md-touch-target']; - - // `@class()`, `Arr::toCssClasses` maps and Alpine's `:class` binding are never how a class - // reaches the page here; the interaction classes and a caller's own class are the only source. - 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([], "{$name}.blade.php writes class=\"{$content}\", not only the interaction classes"); - } - - // `$attributes->class([…])` merges the interaction classes with the caller's own, per the - // brief: "merges through $attributes->class() only where the root takes the caller's class". - // Only the array's own items are checked — a condition may nest its own brackets - // (`in_array($size, ['xs', 'sm'], true)`), 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("{$name}.blade.php: ->class() writes [{$item}], not an interaction class"); - - $i = $end; - } - } - } +it('writes no class list into the view but the interaction and text classes', function (string $name) { + expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]); })->with('action components'); it('takes its values from the tokens and its breakpoints in px', function (string $name) { diff --git a/tests/Feature/Components/InputStylesheetsTest.php b/tests/Feature/Components/InputStylesheetsTest.php index a6085dac..d52410e0 100644 --- a/tests/Feature/Components/InputStylesheetsTest.php +++ b/tests/Feature/Components/InputStylesheetsTest.php @@ -2,10 +2,12 @@ use Illuminate\Support\Facades\File; use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; +use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses; /** * The inputs, selection and data components, rewritten without Tailwind (plan step 36): each view - * renders `data-md-*` attributes and no class list of its own, and each has a stylesheet in + * renders `data-md-*` attributes and no class list of its own beyond the interaction and text + * classes (tests/Support/ViewClasses.php), and each has a stylesheet in * `material.components` that imports the stylesheets of the components its view renders, writes * its values from the tokens and its breakpoints as px range queries, and is imported from the * "Inputs, selection and data" block of components.css. @@ -37,115 +39,31 @@ dataset('input components', [ 'timepicker', ]); -/** - * The interaction classes a package view may write, in a literal `class="…"`, a `'class' => '…'` - * 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. + * breakpoints. The one exception is a viewport *height* in a query keyed on `orientation`: the time + * picker lies its dial down by device orientation and height, which M3 explicitly does not make a + * breakpoint (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour), + * so that height may be any px. A width, in any query, is still a breakpoint. `rem` and `em` would + * grow with the reader's text size, which neither should. */ function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void { foreach ($css->mediaQueries() as $query) { - $isOrientation = str_contains($query, 'orientation:'); + $orientation = preg_match('/\(\s*orientation\s*:/', $query) === 1; - preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER); + preg_match_all('/\(([^()]*)\)/', $query, $features); - foreach ($lengths as [, $number, $unit]) { - expect($unit)->toBe('px', "{$label}: {$query}"); + foreach ($features[1] as $feature) { + preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER); + $height = preg_match('/(?:^|[\s:<>=])(?:min-|max-)?height\b/', $feature) === 1 && ! str_contains($feature, 'width'); - if (! $isOrientation) { - expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint"); + foreach ($lengths as [, $number, $unit]) { + expect($unit)->toBe('px', "{$label}: {$query}"); + + if (! ($orientation && $height)) { + expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint"); + } } } } @@ -153,30 +71,34 @@ function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void /** * 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()). + * breakpoints are px, at one of M3's four (an orientation query's height 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. + * One named exception, in field.css alone: its 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 there 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); + + if ($label === 'field.css') { + $source = str_replace('transition: 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('/\bfont:(?!\s*var\(--md-sys-typescale-)/') ->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/'); - // A flat elevation (`var(--md-sys-elevation-N)`, `none`) or a ring built from a colour token - // (`inset 0 0 0 1px var(--md-sys-color-…)`, a day's or a year's "current" ring) — never a - // literal length paired with a colour the first check would not otherwise catch on its own. + // A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 px` in a colour + // role (or a role mixed toward transparent, a disabled ring) — a day's or a year's "current" + // outline, a focused time field's edge. Never a hand-made shadow. + $colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)'; + preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER); foreach ($shadows as [, $value]) { - $value = trim($value); - - expect($value === 'none' || str_contains($value, 'var(--md-'))->toBeTrue("{$label}: box-shadow: {$value} is not none or built from a token"); + expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role"); } assertBreakpointsInPx($css, $label); @@ -210,8 +132,8 @@ it('imports the stylesheet of every component its view renders', function (strin 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"); +it('writes no class list into the view but the interaction and text classes', function (string $name) { + expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]); })->with('input components'); it('takes its values from the tokens and its breakpoints in px', function (string $name) { @@ -273,6 +195,6 @@ it('imports the stylesheet of every component pagination\'s views render', funct 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"); +it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) { + expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]); })->with('pagination views'); diff --git a/tests/Support/ViewClasses.php b/tests/Support/ViewClasses.php new file mode 100644 index 00000000..7d778721 --- /dev/null +++ b/tests/Support/ViewClasses.php @@ -0,0 +1,149 @@ + */ + public const INTERACTION = ['md-state-layer', 'md-focus-ring', 'md-touch-target']; + + /** @var list|null */ + private static ?array $text = null; + + /** + * Every class a view may write. + * + * @return list + */ + public static function allowed(): array + { + return [...self::INTERACTION, ...self::text()]; + } + + /** + * The text classes, read from the rules text.css declares, so a class added there is allowed + * here without a second list. + * + * @return list + */ + public static function text(): array + { + if (self::$text === null) { + preg_match_all('/^\s*\.(md-[a-z0-9-]+)\s*\{/m', (string) file_get_contents(dirname(__DIR__, 2).'/resources/css/text.css'), $matches); + + self::$text = array_values(array_unique($matches[1])); + } + + return self::$text; + } + + /** + * What the Blade source writes as a class beyond the allowed ones, one line per offence; empty + * when the view keeps to the rule. + * + * @return list + */ + public static function violations(string $view): array + { + // A Blade comment documents a caller's usage (`icon-class="text-sport-run"`); it renders nothing. + $view = (string) preg_replace('/\{\{--.*?--\}\}/s', '', $view); + $allowed = self::allowed(); + $violations = []; + $check = function (string $where, string $list) use ($allowed, &$violations): void { + $extra = array_diff(preg_split('/\s+/', trim($list), -1, PREG_SPLIT_NO_EMPTY), $allowed); + + if ($extra !== []) { + $violations[] = "{$where} writes ".implode(' ', $extra); + } + }; + + // `@class()`, `Arr::toCssClasses` maps and Alpine's `x-bind:class` are never how a class + // reaches the page. + if (preg_match_all('/@class\(|toCssClasses|x-bind:class=/', $view, $matches) > 0) { + $violations[] = 'uses '.implode(', ', array_unique($matches[0])); + } + + // A literal `class="…"` (or a nested component's `hint-class`, `icon-class`, `box-class`): + // allowed classes, with a simple `@if (…) … @endif` around one of them, or a caller's value + // forwarded whole (`class="{{ $hintClass }}"`). + preg_match_all('/(?get('class')") { + $violations[] = ":class=\"{$content}\" is not the caller's class forwarded whole"; + } + } + + // A PHP `'class' => …` entry (an attribute bag built by hand) holds a plain string literal + // of allowed classes, never an expression that could add others. + preg_match_all('/([\'"])class\1\s*=>\s*(?:([\'"])([^\'"]*)\2(?=\s*[,\])])|([^,\]\n]*))/', $view, $entries, PREG_SET_ORDER); + + foreach ($entries as $entry) { + if (($entry[4] ?? '') !== '' || ! isset($entry[3])) { + $violations[] = "'class' => ".trim($entry[4] ?? '').' is not a string literal'; + + continue; + } + + $check("'class' => '{$entry[3]}'", $entry[3]); + } + + // `->class('…')` and the items of `->class([…])`: only the array's own keys and values at + // its top level — a condition may nest brackets of its own (`in_array($size, ['xs'])`). + preg_match_all('/->class\(\s*([\'"])([^\'"]*)\1\s*\)/', $view, $strings, PREG_SET_ORDER); + + foreach ($strings as [, , $list]) { + $check("->class('{$list}')", $list); + } + + preg_match_all('/->class\(\[(.*?)\]\)/s', $view, $calls, PREG_SET_ORDER); + + foreach ($calls as [, $arguments]) { + $depth = 0; + + for ($i = 0, $length = strlen($arguments); $i < $length; $i++) { + $char = $arguments[$i]; + + if ($char === '[' || $char === '(') { + $depth++; + } elseif ($char === ']' || $char === ')') { + $depth--; + } elseif ($depth === 0 && $char === "'") { + $end = (int) strpos($arguments, "'", $i + 1); + $check("->class(['".substr($arguments, $i + 1, $end - $i - 1)."'])", substr($arguments, $i + 1, $end - $i - 1)); + $i = $end; + } + } + } + + return $violations; + } +}