From 52fd65a39058ae2c135f19f336131b98c1cfa34c Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 19:00:48 +0200 Subject: [PATCH] Add InputStylesheetsTest, the inputs twin of the actions stylesheet test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 36, step 0 of the inputs stream: mirrors ActionStylesheetsTest's five checks over the seventeen inputs components already rewritten, plus pagination (its four views live under resources/views/pagination/**, not components/, so it gets its own small versions of the two view-reading checks). The "no class list" check also recognises the wrapper fields' `:class="$attributes->get('class')"` (Blade's own prop-forwarding syntax, the only way a component with no root element of its own hands the caller's class to its field) and a `'class' => '…'` attribute-bag entry, and exempts field.css's week-long autofill transition from the motion-token check. The new test caught three already-rewritten components writing a bare `md-visually-hidden` class, which a package view no longer does (only the three interaction classes, or the caller's own class, reach a view): field's character-count live region, search's result-count live region, and chip's native checkbox under a bound filter chip. Fixed by giving each its own data-md-* hook with the same visually-hidden declarations in its stylesheet, and chip.css was also missing its `tooltip.css` import for the tooltip chip.blade.php renders. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/chip.css | 18 +- resources/css/components/field.css | 15 + resources/css/components/search.css | 15 + resources/views/components/chip.blade.php | 2 +- resources/views/components/field.blade.php | 2 +- resources/views/components/search.blade.php | 2 +- tests/Feature/Components/ChipTest.php | 2 +- tests/Feature/Components/FieldTest.php | 2 +- .../Components/InputStylesheetsTest.php | 266 ++++++++++++++++++ tests/Feature/Components/SearchTest.php | 2 +- 10 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Components/InputStylesheetsTest.php diff --git a/resources/css/components/chip.css b/resources/css/components/chip.css index 22091714..7779fa60 100644 --- a/resources/css/components/chip.css +++ b/resources/css/components/chip.css @@ -47,7 +47,7 @@ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @import './icon.css'; -/* TODO(step 36): @import './tooltip.css' once leaves Tailwind (actions stream). */ +@import './tooltip.css'; @layer material.components { [data-md-chip] { @@ -195,6 +195,22 @@ pointer-events: none; } + /* A filter chip bound with `x-model` keeps a real, focusable `` under the + label for its native semantics and `:has(:checked)`, visually hidden the way `md-visually-hidden` + hides text — a hook, not a class, because a package view writes no class but the interaction + classes (plan step 36). */ + [data-md-chip-native] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + /* ---- Contents ------------------------------------------------------------------------------ */ [data-md-chip-label] { diff --git a/resources/css/components/field.css b/resources/css/components/field.css index de832056..2de826a3 100644 --- a/resources/css/components/field.css +++ b/resources/css/components/field.css @@ -407,6 +407,21 @@ color: var(--md-sys-color-error); } + /* The counter's spoken form: the same visually-hidden clip the text classes give + `md-visually-hidden`, kept as a hook because a package view writes no class but the + interaction classes (plan step 36). */ + [data-md-field-live] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } + /* ---- The filled text field ---------------------------------------------------------------- */ [data-md-field][data-md-variant='filled'] [data-md-field-box] { diff --git a/resources/css/components/search.css b/resources/css/components/search.css index 035b8bc4..25d2e2bb 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -304,4 +304,19 @@ letter-spacing: var(--md-sys-typescale-body-md-tracking); font-variation-settings: normal; } + + /* The result count's spoken form: the same visually-hidden clip the text classes give + `md-visually-hidden`, kept as a hook because a package view writes no class but the + interaction classes (plan step 36). */ + [data-md-search-status] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + } } diff --git a/resources/views/components/chip.blade.php b/resources/views/components/chip.blade.php index 7c4d156c..e1e63bcf 100644 --- a/resources/views/components/chip.blade.php +++ b/resources/views/components/chip.blade.php @@ -152,7 +152,7 @@ 'value' => $value, 'checked' => $checked ? true : null, 'disabled' => $disabled ? true : null, - 'class' => 'md-visually-hidden', + 'data-md-chip-native' => true, ], fn ($attribute): bool => $attribute !== null)) : null; diff --git a/resources/views/components/field.blade.php b/resources/views/components/field.blade.php index c3e0c587..1a206442 100644 --- a/resources/views/components/field.blade.php +++ b/resources/views/components/field.blade.php @@ -137,7 +137,7 @@ x-bind:data-md-over="used > max" > - + @endif diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php index 742ba8af..eb046389 100644 --- a/resources/views/components/search.blade.php +++ b/resources/views/components/search.blade.php @@ -149,5 +149,5 @@ @endif - + diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php index 93dedfcb..b028c9dc 100644 --- a/tests/Feature/Components/ChipTest.php +++ b/tests/Feature/Components/ChipTest.php @@ -123,7 +123,7 @@ it('makes a bound filter chip a native checkbox under the chip', function () { ->toContain('type="checkbox"') ->toContain('value="photos"') ->toContain('x-model="kinds"') - ->toContain('class="md-visually-hidden"') + ->toContain('data-md-chip-native') ->not->toContain('me-4') ->not->toContain('checked') ->and($html) diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index 50099230..379d3857 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -173,7 +173,7 @@ it('counts the characters of a field that has a maximum', function () { ->toContain('data-md-field-support-row') ->toContain('data-md-field-counter') ->toContain('x-bind:data-md-over="used > max"') - ->toContain('class="md-visually-hidden"') + ->toContain('data-md-field-live') ->toContain('maxlength="60"') ->toContain('max: 60,') ->toContain('>0/60') diff --git a/tests/Feature/Components/InputStylesheetsTest.php b/tests/Feature/Components/InputStylesheetsTest.php new file mode 100644 index 00000000..e69b1046 --- /dev/null +++ b/tests/Feature/Components/InputStylesheetsTest.php @@ -0,0 +1,266 @@ + '…'` + * 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'); diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php index ba177aee..a62f557b 100644 --- a/tests/Feature/Components/SearchTest.php +++ b/tests/Feature/Components/SearchTest.php @@ -34,7 +34,7 @@ it('announces how many results there are, politely', function () { $html = (string) $this->blade(''); expect($html) - ->toContain('data-md-search-status class="md-visually-hidden"') + ->toContain('data-md-search-status') ->toContain('aria-live="polite"') ->toContain('aria-atomic="true"') ->toContain('No results')