Add InputStylesheetsTest, the inputs twin of the actions stylesheet test

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 19:00:48 +02:00
co-authored by Claude Opus 5
parent a29a147f1a
commit 52fd65a390
10 changed files with 319 additions and 7 deletions
+17 -1
View File
@@ -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 <x-tooltip> 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 `<input type="checkbox">` 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] {
+15
View File
@@ -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] {
+15
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -137,7 +137,7 @@
x-bind:data-md-over="used > max"
>
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
<span class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
<span data-md-field-live aria-live="polite" aria-atomic="true" x-text="spoken"></span>
</span>
@endif
</div>
+1 -1
View File
@@ -149,5 +149,5 @@
@endif
</div>
<span data-md-search-status class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="announcement"></span>
<span data-md-search-status aria-live="polite" aria-atomic="true" x-text="announcement"></span>
</div>
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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</span>')
@@ -0,0 +1,266 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* 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
* `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.
*
* `pagination` is not in this dataset: its four views live in `resources/views/pagination/**`, not
* `resources/views/components/`, so the two checks that read a single `resources/views/components/
* {name}.blade.php` file do not apply to it. It gets the three checks that do not depend on that
* path below, and its own small versions of the other two.
*/
dataset('input components', [
'form',
'field',
'input',
'password',
'textarea',
'select',
'file',
'checkbox',
'radio',
'toggle',
'chip',
'chip-set',
'choices',
'slider',
'search',
'table',
'sort-header',
]);
/**
* 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<string>
*/
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 `<x-livewire-material::…>` (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('/(?<![\w:-])class=(["\'])(.*?)\1/s', $view, $classAttributes, PREG_SET_ORDER);
foreach ($classAttributes as [, , $content]) {
if (preg_match('/^\{\{\s*\$/', trim($content)) === 1) {
continue;
}
$stripped = (string) preg_replace('/\{\{.*?\}\}|@if\s*\([^()]*\)|@unless\s*\([^()]*\)|@else|@endif|@endunless/s', ' ', $content);
$tokens = array_values(array_filter(preg_split('/\s+/', trim($stripped))));
expect(array_diff($tokens, $allowed))->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('/(?<!x-bind):class="(.*?)"/s', $view, $classProps, PREG_SET_ORDER);
foreach ($classProps as [, $content]) {
expect(trim($content))->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('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
$rendered = collect($tags[1])->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('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
$rendered = collect($tags[1])->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');
+1 -1
View File
@@ -34,7 +34,7 @@ it('announces how many results there are, politely', function () {
$html = (string) $this->blade('<x-search />');
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')