Files
livewire-material/tests/Feature/Components/InputStylesheetsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 72d9dbfc89 Draw the datepicker without Tailwind
Plan step 36 (inputs group): <x-datepicker> renders data-md-datepicker
with every part data-md-datepicker-* (data-md-presentation replaces
data-presentation; data-md-range, data-md-docked, data-md-concealed and
data-md-value replace their unprefixed hooks), and datepicker.css moves
into material.components on the same px, spacing and colour tokens,
importing field.css, icon.css and button.css for what the view renders.
The month's live-announced text drops its `sr-only` class for a
data-md-datepicker-month-live hook with the same visually-hidden clip
(a package view writes no class but the interaction classes), matching
field's and search's counter/status live regions. The day, year, menu
button and list option keep their own hand-rolled state layer and focus
ring rather than the foundation's classes, as data-md-field-button
already does: each is a bigger interactive box than the round indicator
drawn inside it, which the foundation's fixed-geometry classes can't
draw, and `:focus-visible` only ever matches the actually-focused
element. Cancel, OK and the icon buttons are <x-button>, which already
draws from the classes. datepicker.js follows the renamed hooks and
dataset properties.

Shift+M/Y (reaching the month/year dropdowns) and the full-screen range
picker at compact were already implemented; this commit adds the browser
tests the plan owed for them plus the month list's keyboard-driven growth
in both directions and Save/close from the full-screen app bar
(docs/plans/material-3-browser-tests.md), written but not run — the
inputs group's Chromium run follows this stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:12:58 +02:00

278 lines
13 KiB
PHP

<?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',
'datepicker',
]);
/**
* 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('/\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.
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");
}
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');