Files
livewire-material/tests/Feature/Components/InputStylesheetsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 d46b07c13c Draw the timepicker without Tailwind
Plan step 36 (inputs group, last of the "not started" pair): <x-timepicker>
renders data-md-timepicker-* throughout (data-md-mode, data-md-cycle,
data-md-view, data-md-inner, data-md-dragging, data-md-disabled,
data-md-error and data-md-value replace their unprefixed hooks), and
timepicker.css moves into material.components, importing field.css,
icon.css and button.css. The dialog's Tailwind utility class (position,
the scrim, the spatial-fast pop and its @starting-style) becomes plain
declarations under data-md-timepicker-dialog, matching the datepicker's
modal dialog. The landscape rule stays keyed on orientation and viewport
height, in px (560/364/346), never a breakpoint, as M3 asks.

The hour and minute boxes and the period buttons swap their Tailwind
`state-layer focus-ring` utilities for `md-state-layer md-focus-ring`:
same declarations, so nothing else in the stylesheet changes for them.
The dial swaps in `md-focus-ring` for its own hand-rolled, byte-identical
outline-on-:focus-visible rule, which timepicker.css no longer carries.
The period selector was already the radio-in-a-list M3's labelling table
asks for (role="radio" in a role="radiogroup", moved by the arrow keys),
and Shift+M/Y already reached the date picker's dropdowns — both
predate this rewrite.

field.css's data-md-field-button note is tidied now that every component
that selects it (input, password, datepicker, timepicker) is rewritten.

Browser tests written but not run (the inputs group's Chromium run
follows this stream): the period radios moving with the arrow keys, and
the dial lying on its side below the landscape height threshold.

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

279 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',
'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<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');