Cut duplicated and speculative code across the package

An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 19:29:21 +02:00
co-authored by Claude Opus 5
parent 471d927e64
commit 247c596c3a
233 changed files with 16635 additions and 10579 deletions
@@ -1,89 +0,0 @@
<?php
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 beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
* as px range queries, and is imported from the "Actions and communication" block of all.css.
* Every component stylesheet importing the stylesheets its view renders is
* tests/Feature/StylesheetsTest.php's now, folded into one dataset over every component view (step
* 42) rather than kept once per group.
*
* `icon` and `shape` are the two foundation components (`<x-icon>`, `<x-shape>`) — no group's view
* renders them exclusively, so they were in no group's dataset at all. They follow the same shape,
* token and view-class rules as every other component, so they run with this group's checks too;
* only "is imported from the actions and communication block" needs to read the two of them from
* all.css's first block ("Foundation components") instead, since that is where they sit.
*/
dataset('action components', [
'icon',
'shape',
'loading',
'tooltip',
'badge',
'button',
'group',
'button-group',
'split-button',
'fab',
'menu-separator',
'menu-group',
'menu-item',
'menu',
'fab-menu-item',
'fab-menu',
'rich-tooltip',
'toast',
'progress',
'alert',
'stat',
'empty-state',
]);
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('action components');
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) {
$css = ComponentStylesheet::read($name);
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
// Colours are roles, shadows are elevation levels, type is a type style, motion is a spring.
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)/');
foreach ($css->mediaQueries() as $query) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$name}.css: {$query}")
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$name}.css: {$query} is not at an M3 breakpoint");
}
}
})->with('action components');
it('is imported from the actions and communication block of all.css, or icon and shape from the foundation block before it', function (string $name) {
$block = allCssBlock(in_array($name, ['icon', 'shape'], true) ? 'Foundation components' : 'Actions and communication');
expect($block)->toContain("@import './components/{$name}.css';");
})->with('action components');
+5 -3
View File
@@ -12,9 +12,11 @@ it('states a notice in its state\'s container, with the state\'s icon', function
->toContain('Storage almost full')
->toContain('3.8 of 4 GB')
->toContain('<svg')
->and(ComponentStylesheet::read('alert')->declarations("[data-md-alert][data-md-color='warning']"))->toBe([
'background-color' => 'var(--md-sys-color-warning-container)',
'color' => 'var(--md-sys-color-on-warning-container)',
// Every hue but neutral comes from the shared colour-role table (color.css, ColorTest.php);
// the alert reads its container role straight off it, info by default.
->and(ComponentStylesheet::read('alert')->declarations('[data-md-alert]'))->toMatchArray([
'background-color' => 'var(--md-container, var(--md-sys-color-info-container))',
'color' => 'var(--md-on-container, var(--md-sys-color-on-info-container))',
]);
});
+14 -13
View File
@@ -179,11 +179,11 @@ it('centres a headline on a three-column row and curves the search container', f
$css = ComponentStylesheet::read('app-bar');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons.
->not->toContain('inset-inline: 56px;')
->and($css->declarations('[data-md-app-bar][data-md-variant=\'center\'] [data-md-app-bar-row]'))
->toBe(['display' => 'grid', 'grid-template-columns' => '1fr auto 1fr'])
// 312dp, then half of what is left (N-09).
// 312dp, then half of what is left.
->and($css->declarations('[data-md-app-bar-search]'))
->toHaveKey('max-width', 'calc(312px + (100% - 312px) / 2)');
});
@@ -204,20 +204,21 @@ it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = ComponentStylesheet::read('toolbar');
expect($css->declarations('[data-md-toolbar-place=\'bottom\']'))
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
// Never a sum: --material-bottom-bar already swallows the bottom safe area.
->toHaveKey('bottom', 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))')
->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-toolbar-place=\'bottom\']'))
->toHaveKey('bottom', 'var(--material-bottom-bar, 0px)')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16.
->and($css->declarations('[data-md-toolbar][data-md-vertical][data-md-toolbar-place=\'end\'], [data-md-toolbar-group][data-md-vertical][data-md-toolbar-place=\'end\']'))
->toBe(['inset-inline-end' => 'calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)))'])
// A standard toolbar's standard buttons are primary (N-13).
// A standard toolbar's standard buttons are primary.
->and($css->declarations('[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([data-md-variant=\'filled\'], [data-md-variant=\'tonal\'], [data-md-selected=\'true\'], [aria-pressed=\'true\'], :disabled, [aria-disabled=\'true\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
// N-13 names the *standard* button, so a button that fills its own container keeps the label
// colour that goes with that fill: a filled button painted primary put a primary icon on a
// primary fill, and the current page's button in a navigation toolbar came out a blank circle.
// The rule above names the *standard* button, so a button that fills its own container keeps
// the label colour that goes with that fill: a filled button painted primary put a primary
// icon on a primary fill, and the current page's button in a navigation toolbar came out a
// blank circle.
foreach (['[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]', '[data-md-toolbar][data-md-vibrant] [data-md-icon-button]'] as $scope) {
preg_match_all('/'.preg_quote($scope, '/').':not\([^{]*\{[^}]*\bcolor:/', $css->css, $rules);
@@ -256,7 +257,7 @@ it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-md-theme-toggle="contrast"')
->toContain('aria-label="Contrast"')
// A connected button group over native radios, not the deprecated segmented button (N-04).
// A connected button group over native radios, not the deprecated segmented button.
->toContain('data-md-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
@@ -281,7 +282,7 @@ it('switches the theme through the store in three shapes', function () {
->toContain('data-md-theme-toggle="toggle"')
->toContain('data-md-icon-button')
->toContain('aria-label="Dark theme"')
// 40px drawn, 48px reached: M3's minimum target (N-01).
// 40px drawn, 48px reached: M3's minimum target.
->toContain('md-touch-target')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
@@ -315,8 +316,8 @@ it('opens an account menu from the initials of a name', function () {
expect($html)
->toContain('aria-label="Account"')
->toContain('data-md-account-menu')
// The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01); the trigger
// has a hover and a pressed state like every other trigger in the library (N-15).
// The 40px avatar reaches 48px, and nothing clips the pseudo-target; the trigger
// has a hover and a pressed state like every other trigger in the library.
->toContain('md-state-layer md-touch-target md-focus-ring')
->not->toContain('overflow-hidden')
->toMatch('/>\s*AM\s*<\/button>/')
@@ -335,6 +336,6 @@ it('opens an account menu from the initials of a name', function () {
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
->and($css->declarations('[data-md-account-menu-avatar]'))
// Behind the state layer's ::before (z-index -1), so a hover or a press still washes over
// the picture instead of under it (N-15).
// the picture instead of under it.
->toHaveKey('z-index', '-2');
});
+12 -21
View File
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
*/
function badgeAttributes(string $blade): array
{
preg_match('/<span\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:-]+)="([^"]*)"/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2]])->all();
return layoutRoot((string) test()->blade($blade), 'span');
}
it('is M3\'s small badge without a value, hidden from screen readers', function () {
@@ -107,29 +104,23 @@ it('renders its slot as HTML, and is a dot while the slot holds nothing but comm
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
});
it('paints each colour from its pair of roles, and neutral without a hue', function (string $color, array $roles) {
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='{$color}']"))->toBe([
'--md-badge-color' => "var(--md-sys-color-{$roles[0]})",
'--md-badge-on-color' => "var(--md-sys-color-{$roles[1]})",
'--md-badge-container' => "var(--md-sys-color-{$roles[2]})",
'--md-badge-on-container' => "var(--md-sys-color-{$roles[3]})",
it('paints neutral from its own pair of roles, the one hue the shared table has none for', function () {
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='neutral']"))->toBe([
'--md-badge-color' => 'var(--md-sys-color-on-surface-variant)',
'--md-badge-on-color' => 'var(--md-sys-color-surface)',
'--md-badge-container' => 'var(--md-sys-color-surface-container-high)',
'--md-badge-on-container' => 'var(--md-sys-color-on-surface-variant)',
]);
})->with([
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
'neutral' => ['neutral', ['on-surface-variant', 'surface', 'surface-container-high', 'on-surface-variant']],
]);
});
it('is error by default, and falls back to error on a colour it does not know', function () {
$css = ComponentStylesheet::read('badge');
// Every other hue comes from the shared colour-role table (color.css, ColorTest.php): badge.css
// only has to give its own prefixed variables a fallback for when `data-md-color` is absent.
expect($css->declarations('[data-md-badge]'))->toMatchArray([
'--md-badge-color' => 'var(--md-sys-color-error)',
'--md-badge-on-color' => 'var(--md-sys-color-on-error)',
'--md-badge-color' => 'var(--md-color, var(--md-sys-color-error))',
'--md-badge-on-color' => 'var(--md-on-color, var(--md-sys-color-on-error))',
])
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])"))->toBe([
'background-color' => 'var(--md-badge-color)',
+1 -1
View File
@@ -31,7 +31,7 @@ it('connects buttons 2px apart, with M3\'s inner corners', function () {
->and($css->declarations("[data-md-button-group='connected']"))->toBe(['gap' => 'var(--md-sys-measurement-space25)'])
->and($css->declarations("[data-md-button-group='connected'] > *"))->toMatchArray([
'--md-button-group-corner' => 'var(--md-button-group-inner)',
'border-start-start-radius' => 'var(--md-button-group-corner)',
'border-radius' => 'var(--md-button-group-corner)',
])
->and($css->declarations("[data-md-button-group='connected'] > :active"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-inner-pressed)'])
->and($css->declarations("[data-md-button-group='connected'] > :first-child"))->toBe([
+14 -14
View File
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
*/
function buttonAttributes(string $blade): array
{
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
return layoutRoot((string) test()->blade($blade), 'button|a');
}
function buttonCss(): ComponentStylesheet
@@ -33,7 +30,7 @@ it('is a text button in the primary colour by default, drawn by its stylesheet',
->and(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
'--md-button-container' => 'transparent',
'--md-button-label' => 'var(--md-button-color)',
'--md-button-color' => 'var(--md-sys-color-primary)',
'--md-button-color' => 'var(--md-color, var(--md-sys-color-primary))',
'background-color' => 'var(--md-button-container)',
'color' => 'var(--md-button-label)',
]);
@@ -51,11 +48,17 @@ it('draws each M3 variant', function (string $variant, array $declarations) {
it('draws primary\'s tonal button in secondary-container and its quiet ink in on-surface-variant', function () {
expect(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
'--md-button-tone' => 'var(--md-container, var(--md-sys-color-secondary-container))',
'--md-button-on-tone' => 'var(--md-on-container, var(--md-sys-color-on-secondary-container))',
'--md-button-tone-selected' => 'var(--md-sys-color-secondary)',
'--md-button-quiet' => 'var(--md-sys-color-on-surface-variant)',
])
// The shared colour-role table's own `primary` entry is plain primary-container
// (color.css, ColorTest.php), so an explicit `data-md-color="primary"` keeps this override.
->and(buttonCss()->declarations("[data-md-button][data-md-color='primary']"))->toBe([
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
])
->and(buttonCss()->declarations("[data-md-button]:not([data-md-color='primary'])"))->toBe([
'--md-button-tone-selected' => 'var(--md-button-color)',
'--md-button-on-tone-selected' => 'var(--md-button-on-color)',
@@ -63,13 +66,10 @@ it('draws primary\'s tonal button in secondary-container and its quiet ink in on
]);
});
it('draws a variant in another colour role', function (string $color) {
expect(buttonCss()->declarations("[data-md-button][data-md-color='{$color}']"))->toBe([
'--md-button-color' => "var(--md-sys-color-{$color})",
'--md-button-on-color' => "var(--md-sys-color-on-{$color})",
'--md-button-tone' => "var(--md-sys-color-{$color}-container)",
'--md-button-on-tone' => "var(--md-sys-color-on-{$color}-container)",
]);
it('draws a variant in another colour role, straight off the shared colour-role table', function (string $color) {
// color.css (ColorTest.php) maps every one of these to --md-color/--md-on-color/--md-container/
// --md-on-container; button.css only has to read them (`draws each M3 variant`, above).
expect((string) $this->blade("<x-button label=\"Go\" color=\"{$color}\" />"))->toContain("data-md-color=\"{$color}\"");
})->with(['secondary', 'tertiary', 'error', 'success', 'warning', 'info']);
it('keeps the maryUI and ReStride shorthands', function () {
+2 -2
View File
@@ -135,7 +135,7 @@ it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down
->toContain('data-md-carousel="multi-browse"');
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no corner, no mask,
// and never wider than the medium window it is meant for (C-11).
// and never wider than the medium window it is meant for.
expect($carousel->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller]"))
->toBe([
'margin-inline' => 'auto',
@@ -210,7 +210,7 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu
$item = ComponentStylesheet::read('carousel-item');
// The scrim under the label and the item's literal white ink over it are deliberate (C-25).
// The scrim under the label and the item's literal white ink over it are deliberate.
expect($item->declarations('[data-md-carousel-surface] > [data-md-carousel-label]'))
->toHaveKey('background', 'linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent)')
->and($item->declarations('[data-md-carousel-label] > [data-md-carousel-label-text]'))
+12 -9
View File
@@ -17,7 +17,7 @@ it('is an outlined assist chip, a button, by default', function () {
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="app-chip-gap" />', 'button'))
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="app-chip-gap">')
->toBe('<button class="md-state-layer md-touch-target app-chip-gap" data-md-chip="assist" data-md-icon-start="" type="button">')
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('data-md-icon-end=""')
->not->toContain('data-md-icon-start')
@@ -25,7 +25,7 @@ it('is an outlined assist chip, a button, by default', function () {
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
->toContain('<span data-md-chip-label>Add to calendar</span>')
->not->toContain('class=');
->toContain('class="md-state-layer md-touch-target"');
});
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
@@ -36,8 +36,7 @@ it('draws a chip at Compose\'s size, corner, type and padding from its styleshee
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
// 16px beside a label and 8px beside an icon, each 1px short for the border.
->toContain('padding-inline: 15px;')
->toContain('padding-inline-start: 7px;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/chip.css';");
->toContain('padding-inline-start: 7px;');
});
it('pads a chip without icons by 16px on both sides', function () {
@@ -96,7 +95,7 @@ it('attaches a plain tooltip', function () {
expect($html)
->toContain('popover="manual"')
->toContain('Share this file')
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10};"/');
});
it('makes a filter chip without a model a toggle button the caller owns', function () {
@@ -118,7 +117,7 @@ it('makes a bound filter chip a native checkbox under the chip', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />');
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />', 'label'))
->toBe('<label data-md-chip="filter" class="app-filter-gap" wire:key="photos">')
->toBe('<label class="md-state-layer md-touch-target app-filter-gap" data-md-chip="filter" wire:key="photos">')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" />', 'input'))
->toContain('type="checkbox"')
->toContain('value="photos"')
@@ -177,7 +176,12 @@ it('makes an input chip a button when it has its own action, and selects it', fu
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
->toContain('data-md-actionable=""')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
->not->toContain('data-md-actionable');
->not->toContain('data-md-actionable')
// An input chip draws its state layer on its action, never a second one on the container.
->not->toContain('md-state-layer')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'button'))
->not->toContain('md-state-layer')
->not->toContain('md-touch-target');
});
it('gives a removable input chip a named remove button that calls wire:remove', function () {
@@ -244,8 +248,7 @@ it('draws the set from its stylesheet: 8px between chips, wrapping unless it scr
expect($css)->toContain("@import './chip.css';")
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/chip-set.css';");
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {');
});
it('scrolls a chip set on one line with fading edges', function () {
+1 -2
View File
@@ -105,6 +105,5 @@ it('brings the stylesheets of what it renders, and draws its empty row', functio
expect($css)->toContain("@import './field.css';")
->toContain("@import './chip-set.css';")
->toContain("@import './chip.css';")
->toContain('[data-md-choices-empty] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/choices.css';");
->toContain('[data-md-choices-empty] {');
});
+45
View File
@@ -0,0 +1,45 @@
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The shared colour-role table badge.css, slider.css, button.css, alert.css and progress.css read
* instead of each carrying its own copy: `data-md-color` picks primary, secondary, tertiary,
* error, success, warning or info, and each maps to the same four generic variables.
*/
it('maps every hue to its own role and its tonal container', function (string $color, array $roles) {
expect(ComponentStylesheet::read('color')->declarations("[data-md-color='{$color}']"))->toBe([
'--md-color' => "var(--md-sys-color-{$roles[0]})",
'--md-on-color' => "var(--md-sys-color-{$roles[1]})",
'--md-container' => "var(--md-sys-color-{$roles[2]})",
'--md-on-container' => "var(--md-sys-color-{$roles[3]})",
]);
})->with([
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
'error' => ['error', ['error', 'on-error', 'error-container', 'on-error-container']],
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
]);
it('keeps the generic variables on the element that names the colour', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/color.css');
foreach (['--md-color', '--md-on-color', '--md-container', '--md-on-container'] as $property) {
expect($css)->toMatch('/@property '.preg_quote($property, '/')."\\s*\\{\\s*syntax: '\\*';\\s*inherits: false;\\s*\\}/");
}
});
it('imports the table from every stylesheet that reads it', function () {
$readers = collect(glob(__DIR__.'/../../../resources/css/components/*.css'))
->reject(fn (string $file): bool => basename($file) === 'color.css')
->filter(fn (string $file): bool => preg_match('/var\(--md-(?:on-)?(?:color|container)\b/', (string) file_get_contents($file)) === 1);
expect($readers)->not->toBeEmpty();
foreach ($readers as $file) {
expect(str_contains((string) file_get_contents($file), "@import './color.css';"))->toBeTrue(basename($file).' reads the colour table without importing it');
}
});
@@ -0,0 +1,209 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* Every component and layout-shared stylesheet, rewritten without Tailwind (plan step 36): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as
* px range queries, and is imported from its own block of all.css. This used to be four near-copies
* of the same checks, one per group (Action/Containment/Input/Navigation), plus a fifth in
* ErrorPagesTest.php for the error layout — folded into this one file (plan cleanup) because the
* checks never differed in substance, only in which names they ran over and, for the token check,
* which box-shadow shape a group's own values may take (see $boxShadow below). `icon` and `shape`
* (no group's view renders them exclusively) and `color` (its own ColorTest.php already covers it,
* the shared colour-role table every hue-taking component imports) keep their separate treatment.
*
* Four names in the dataset are not a component view of their own: `selection` (what checkbox,
* radio and toggle share), `navigation-item` (what the bar and the rail's items share), `pagination`
* (four views under resources/views/pagination/, not resources/views/components/) and `error-page`
* (the framework's error layout, not a component at all). The stylesheet-only checks below (shape,
* tokens, block import) still run on all four; the view-scoped ones (class list, element-wide
* selector scoping) run only where the original group already ran them — pagination's own views get
* their own small dataset further down, and error-page's view-scoped checks stay in
* ErrorPagesTest.php, which reads a view outside resources/views/components/.
*/
function componentStylesheets(): array
{
$entries = [];
foreach (['icon', 'shape'] as $name) {
$entries[$name] = ['Foundation components', 'loose'];
}
foreach ([
'loading', 'tooltip', 'badge', 'button', 'group', 'button-group', 'split-button', 'fab',
'menu-separator', 'menu-group', 'menu-item', 'menu', 'fab-menu-item', 'fab-menu',
'rich-tooltip', 'toast', 'progress', 'alert', 'stat', 'empty-state',
] as $name) {
$entries[$name] = ['Actions and communication', 'loose'];
}
foreach ([
'divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet',
'carousel-item', 'carousel',
] as $name) {
$entries[$name] = ['Containment', 'ring'];
}
$entries['error-page'] = ['Containment', 'none'];
foreach ([
'form', 'field', 'input', 'password', 'textarea', 'select', 'file', 'checkbox', 'radio',
'toggle', 'chip', 'chip-set', 'choices', 'slider', 'search', 'table', 'sort-header',
'datepicker', 'timepicker', 'selection', 'pagination',
] as $name) {
$entries[$name] = ['Inputs, selection and data', 'ring'];
}
foreach ([
'app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail',
'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu',
'theme-toggle', 'scheme-picker', 'navigation-item',
] as $name) {
$entries[$name] = ['Navigation', 'ring'];
}
return $entries;
}
dataset('component stylesheets', function (): Generator {
foreach (componentStylesheets() as $name => [$block, $boxShadow]) {
yield $name => [$name, $block, $boxShadow];
}
});
// The names with exactly one view of their own at resources/views/components/<name>.blade.php —
// every dataset entry above but the four stylesheet-only names (see the file header) — for the two
// checks that read that view. `tabs` renders two (tabs.blade.php and tab.blade.php).
dataset('component views', array_values(array_diff(array_keys(componentStylesheets()), ['selection', 'navigation-item', 'pagination', 'error-page'])));
/**
* The names whose stylesheet travels in every page's bundle (all.css), so a bare `html`, `body`,
* `dialog` or `:root` rule would restyle an application's own pages — containment and navigation,
* the two groups whose original tests carried this check. `[data-md-` is the exception in both: a
* rule that only applies while a package component is on the page at all. `:has([data-md-` needs
* no `>` combinator only in navigation's own stylesheets (toolbar.css's
* `:root:has([data-md-toolbar-place=…])` has none) — containment keeps the strict, `>`-only form
* its own original test held it to.
*/
dataset('scoped selectors', function (): Generator {
$names = [
...array_fill_keys(['divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet', 'carousel-item', 'carousel'], true),
...array_fill_keys(['app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail', 'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu', 'theme-toggle', 'scheme-picker', 'navigation-item'], false),
];
foreach ($names as $name => $strict) {
yield $name => [$name, $strict];
}
});
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
// The header, the layer statement, the plain imports and freedom from Tailwind are
// StylesheetsTest's, checked over every file all.css reaches; only the block layer and the
// imports' existence are this dataset's own to check.
expect($css->blocks())->each->toBe('@layer material.components');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('component stylesheets');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
// `tabs` renders two views of its own, tabs.blade.php and tab.blade.php; every other name here
// has exactly one.
foreach (($name === 'tabs' ? ['tabs', 'tab'] : [$name]) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('component views');
/**
* `$boxShadow` is the shape a stylesheet's own box-shadow values may take: 'loose' for actions and
* communication (a component paints its own elevation through a local custom property, e.g.
* button.css's `--md-button-elevation`, not `--md-sys-elevation-N` directly, so any `var(--md-*)`
* passes); 'ring' for containment, inputs/selection/data and navigation (`none`, an elevation level,
* or a literal ring — `[inset] 0 0 0 <n>px` in a colour role, a focus or a selected day); 'none' for
* the error layout, which draws no box-shadow of its own.
*
* Breakpoints (px, at one of M3's four) are StylesheetsTest's, checked over every file all.css
* reaches. field.css's autofill workaround (`transition: background-color 604800s, color
* 604800s`) delays Chrome's autofill background for a week — not a spring, and not meant to be
* one — so it is stripped before the transition check.
*/
it('takes its values from the tokens', function (string $name, string $block, string $boxShadow) {
$label = "{$name}.css";
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
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)/');
if ($boxShadow === 'loose') {
expect($source)->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/');
} elseif ($boxShadow === 'ring') {
$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]) {
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");
}
}
})->with('component stylesheets');
it('is imported from its own block of all.css', function (string $name, string $block) {
expect(allCssBlock($block))->toContain("@import './components/{$name}.css';");
})->with('component stylesheets');
it('scopes every element-wide selector to a data-md hook', function (string $name, bool $strict) {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
$has = $strict ? ':has\(> \[data-md-' : ':has\((?:> )?\[data-md-';
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|'.$has.')/m');
})->with('scoped selectors');
// ---- pagination: its views live outside resources/views/components/ --------------------------
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()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->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 and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('pagination views');
// ---- partials: fragments a component view @includes, not a component view of their own --------
dataset('partial views', array_map(
fn (string $file): string => 'partials/'.basename($file, '.blade.php'),
glob(__DIR__.'/../../../resources/views/partials/*.blade.php'),
));
it('writes no class list into a partial but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('partial views');
@@ -1,109 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The containment components, rewritten without Tailwind (plan step 36): each view renders
* `data-md-*` attributes and no class list of its own beyond the interaction and text classes
* (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as px
* range queries, and is imported from the "Containment" block of all.css. Every component
* stylesheet importing the stylesheets its view renders is tests/Feature/StylesheetsTest.php's
* now, folded into one dataset over every component view (step 42) rather than kept once per
* group.
*
* The dataset grew by one name per component commit, the same rule InputStylesheetsTest.php and
* ActionStylesheetsTest.php follow. The error layout, whose view is not a component, has the same
* checks in tests/Feature/ErrorPagesTest.php.
*/
dataset('containment components', [
'divider',
'collapse',
'card',
'list',
'list-item',
'modal',
'drawer',
'bottom-sheet',
'carousel-item',
'carousel',
]);
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
*/
function assertContainmentBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
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 or a ring, type is a type style, and breakpoints
* are px, at one of M3's four.
*/
function assertContainmentTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
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 box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
$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]) {
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");
}
assertContainmentBreakpointsInPx($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('containment components');
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('containment components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertContainmentTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('containment components');
it('is imported from the containment block of all.css', function (string $name) {
expect(allCssBlock('Containment'))->toContain("@import './components/{$name}.css';");
})->with('containment components');
it('scopes every element-wide selector to a data-md hook', function (string $name) {
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
// `dialog` or `:root` rule would restyle the application's own pages.
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m');
})->with('containment components');
+3 -6
View File
@@ -25,8 +25,7 @@ it('draws 52px rows, 36px dense ones, from its stylesheet in the components laye
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/table.css';");
->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))');
});
it('sorts by its column, ascending first and then flipping', function () {
@@ -110,14 +109,12 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
->toContain('@layer material.components')
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/sort-header.css';");
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/');
});
it('draws the paginators from their stylesheet, trading the numbers for "Page n of m" below 600px', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/pagination.css'))
->toContain('@layer material.components')
->toMatch('/@media \\(width < 600px\\) \\{\\s*\\[data-md-pagination-page\\],\\s*\\[data-md-pagination-range\\] \\{\\s*display: none;/')
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-pagination-compact\\] \\{\\s*display: none;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/pagination.css';");
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-pagination-compact\\] \\{\\s*display: none;/');
});
+1 -4
View File
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
*/
function fabAttributes(string $blade): array
{
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
return layoutRoot((string) test()->blade($blade), 'button|a');
}
it('draws a FAB in its container colour, named by its tooltip', function () {
+3 -20
View File
@@ -16,27 +16,10 @@ it('takes a minimum item width as a length or a number of px', function () {
expect(layoutRoot((string) $this->blade('<x-feed min-item="a third" />'))['style'])->toBe('--md-min-item: 240px;');
});
it('replaces the spacer with a spacing token, and with no gap for an unknown one', function () {
expect(layoutRoot((string) $this->blade('<x-feed gap="space400" />'))['data-md-gap'])->toBe('space400')
->and(layoutRoot((string) $this->blade('<x-feed gap="wide" />'))['data-md-gap'])->toBe('none');
});
it('takes the element, the visibility props, and the caller\'s class and style after its own', function () {
expect(layoutRoot((string) $this->blade('<x-feed as="ul" hide-below="medium" class="photos" style="padding-block: 1rem" />')))
->toMatchArray([
'<' => 'ul',
'data-md-hide-below' => 'medium',
'class' => 'photos',
'style' => '--md-min-item: 240px; padding-block: 1rem;',
]);
});
it('keeps one column on a compact window and fills columns from medium, in the layout layer', function () {
it('keeps one column on a compact window and fills columns from medium', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/feed.css');
expect($css)->toContain('@layer material.layout')
->toContain("[data-md-feed] {\n display: grid;\n grid-template-columns: minmax(0, 1fr);")
expect($css)->toContain("[data-md-feed] {\n display: grid;\n grid-template-columns: minmax(0, 1fr);")
->toContain("@media (width >= 600px) {\n grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--md-min-item, 240px)), 1fr));")
->not->toContain('grid-auto-flow')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/feed.css';");
->not->toContain('grid-auto-flow');
});
+7 -14
View File
@@ -248,8 +248,7 @@ it('draws a form\'s column and its actions from its stylesheet', function () {
expect($css)->toContain('@layer material.components')
->toContain('grid-template-columns: minmax(0, 1fr);')
->toContain('gap: var(--md-sys-measurement-space200);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/form.css';");
->toContain('gap: var(--md-sys-measurement-space200);');
});
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
@@ -258,8 +257,7 @@ it('keeps the field\'s chrome in its own stylesheet in the components layer', fu
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toContain('[data-md-field-box]')
->not->toMatch('/(?<![\\w-])\\.field\\b/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/field.css';");
->not->toMatch('/(?<![\\w-])\\.field\\b/');
});
it('takes a custom control marked as the field\'s control, and a hint class', function () {
@@ -280,15 +278,13 @@ it('gives the one-line field a stylesheet that brings the field and its icons',
expect($css)->toStartWith('/*')
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/input.css';");
->toContain("@import './icon.css';");
});
it('gives the password field a stylesheet that brings the field and its icons', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/password.css';");
->toContain("@import './icon.css';");
});
it('draws what a textarea changes in the field from its own stylesheet', function () {
@@ -298,8 +294,7 @@ it('draws what a textarea changes in the field from its own stylesheet', functio
->toContain('@layer material.components')
->toContain('textarea[data-md-field-control][data-md-autogrow]')
->toContain('field-sizing: content;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/textarea.css';");
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[');
});
it('draws what a select changes in the field from its own stylesheet', function () {
@@ -308,8 +303,7 @@ it('draws what a select changes in the field from its own stylesheet', function
expect($css)->toContain("@import './field.css';")
->toContain('@supports (appearance: base-select)')
->toContain('select[data-md-field-control]:open')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/select.css';");
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[');
});
it('draws the file picker\'s button from its own stylesheet', function () {
@@ -318,8 +312,7 @@ it('draws the file picker\'s button from its own stylesheet', function () {
expect($css)->toContain("@import './field.css';")
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
->toContain('background-color: var(--md-sys-color-secondary-container);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/file.css';");
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']");
});
it('keeps a button written among a form\'s fields at its label\'s width', function () {
+2 -17
View File
@@ -34,11 +34,6 @@ it('fills each breakpoint left out from the nearest smaller one', function () {
expect(Layout::columns(['extra-large' => 6]))->toBe(['compact' => 1, 'medium' => 1, 'expanded' => 1, 'large' => 1, 'extra-large' => 6]);
});
it('spaces the columns with a spacing token only, and with none for anything else', function () {
expect(layoutRoot((string) $this->blade('<x-grid gap="space300" />'))['data-md-gap'])->toBe('space300')
->and(layoutRoot((string) $this->blade('<x-grid gap="1.5rem" />'))['data-md-gap'])->toBe('none');
});
it('fills a row with columns of a minimum width, capped by the counts when both are given', function () {
$fill = layoutRoot((string) $this->blade('<x-grid min-item="280px" />'));
$capped = layoutRoot((string) $this->blade('<x-grid min-item="18rem" :columns="[\'medium\' => 2, \'expanded\' => 3]" />'));
@@ -58,22 +53,12 @@ it('fills a row with columns of a minimum width, capped by the counts when both
}
});
it('takes the element, the visibility props, and the caller\'s class and style after its own', function () {
$root = layoutRoot((string) $this->blade('<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />'));
expect($root)->toMatchArray(['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery'])
->and($root['style'])->toEndWith('--md-columns-extra-large: 1; align-items: start;');
});
it('reads its own column count at each breakpoint in the layout layer', function () {
it('reads its own column count at each breakpoint', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/grid.css');
expect($css)->toContain('@layer material.layout')
->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));');
expect($css)->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));');
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
expect($css)->toContain("@media (width >= {$width}px) {\n --md-columns: var(--md-columns-{$breakpoint}, 1);");
}
expect((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/grid.css';");
});
+2
View File
@@ -18,6 +18,8 @@ it('draws a choice as connected radios', function () {
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
->toMatch('/<label data-md-group-segment class="md-state-layer\s+md-touch-target\s*">/')
->toContain('type="radio"')
// The native control stays in the form and the accessibility tree, out of the drawing.
->toMatch('/<input[^>]*class="md-visually-hidden"/')
->toContain('name="theme"')
->toContain('x-model="theme"')
->toContain('value="light"')
+1 -2
View File
@@ -72,8 +72,7 @@ it('draws its size from a stylesheet in the components layer', function () {
expect($css)->toContain('@layer material.components')
->toContain('inline-size: var(--md-icon-size, 24px)')
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/icon.css';");
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)");
});
it('names the icon for a screen reader when it carries the meaning alone', function () {
@@ -1,195 +0,0 @@
<?php
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 beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
* as px range queries, and is imported from the "Inputs, selection and data" block of all.css.
* Every component stylesheet importing the stylesheets its view renders is
* tests/Feature/StylesheetsTest.php's now, folded into one dataset over every component view (step
* 42) rather than kept once per group.
*
* `pagination` is not in this dataset: its four views live in `resources/views/pagination/**`, not
* `resources/views/components/`, so the check that reads a single `resources/views/components/
* {name}.blade.php` file does not apply to it. It gets the two checks that do not depend on that
* path below, and its own small version of the third.
*/
function inputComponents(): array
{
return [
'form',
'field',
'input',
'password',
'textarea',
'select',
'file',
'checkbox',
'radio',
'toggle',
'chip',
'chip-set',
'choices',
'slider',
'search',
'table',
'sort-header',
'datepicker',
'timepicker',
];
}
dataset('input components', inputComponents());
/**
* The checks that read a stylesheet alone also run on the one no view renders by itself:
* selection.css, what the checkbox, radio and switch share (its own file header names them), which
* each of their stylesheets imports too. Nothing renders it directly, so without this its shape
* and token rules would go unchecked, the way navigation-item.css did before
* NavigationStylesheetsTest.php picked it up.
*/
dataset('input stylesheets', [...inputComponents(), 'selection']);
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* 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) {
$orientation = preg_match('/\(\s*orientation\s*:/', $query) === 1;
preg_match_all('/\(([^()]*)\)/', $query, $features);
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');
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");
}
}
}
}
}
/**
* 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's height aside; see
* assertBreakpointsInPx()).
*
* 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);
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 box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>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]) {
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);
}
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 stylesheets');
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) {
assertTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('input stylesheets');
it('is imported from the inputs, selection and data block of all.css', function (string $name) {
expect(allCssBlock('Inputs, selection and data'))->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 all.css for pagination', function () {
expect(allCssBlock('Inputs, selection and data'))->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()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->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 and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('pagination views');
@@ -0,0 +1,68 @@
<?php
/**
* Two checks every layout component repeated once per file: the root passes through `as`, a
* visibility prop and the caller's own `class`/`style` (after its own style, where it writes one)
* and any other attribute; the four with a `gap` prop replace it with a spacing token or drop to
* `data-md-gap="none"` for anything else. Folded into one dataset each here, keyed by tag, rather
* than kept once per component's own test file — <x-grid>'s style differs (a long per-breakpoint
* column list before the caller's own, checked with `$styleEndsWith` instead of the exact string
* the others match) and <x-stack>'s gap check has an extra invalid case (`space1000`, a token name
* past the scale); both are still one dataset row, not a reason to keep the test itself apart.
*/
dataset('layout component root attributes', [
'feed' => [
'<x-feed as="ul" hide-below="medium" class="photos" style="padding-block: 1rem" />',
['<' => 'ul', 'data-md-hide-below' => 'medium', 'class' => 'photos', 'style' => '--md-min-item: 240px; padding-block: 1rem;'],
],
'grid' => [
'<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />',
['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery'],
'--md-columns-extra-large: 1; align-items: start;',
],
'row' => [
'<x-row as="nav" hide-from="expanded" class="toolbar-row" style="min-height: 3rem" aria-label="Filters" />',
['<' => 'nav', 'data-md-hide-from' => 'expanded', 'class' => 'toolbar-row', 'style' => 'min-height: 3rem;', 'aria-label' => 'Filters'],
],
'stack' => [
'<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />',
['<' => 'ul', 'data-md-hide-below' => 'medium', 'data-md-hide-from' => 'large', 'class' => 'steps', 'style' => 'max-width: 40rem;'],
],
'pane' => [
'<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />',
['<' => 'section', 'data-md-hide-below' => 'expanded', 'class' => 'checkout', 'style' => 'max-width: 50rem;', 'aria-labelledby' => 'checkout-title'],
],
'list-detail' => [
'<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />',
['<' => 'section', 'data-md-hide-below' => 'medium', 'class' => 'inbox', 'style' => 'min-height: 30rem;', 'x-model' => 'chosen', 'x-modelable' => 'selected'],
],
'supporting-pane' => [
'<x-supporting-pane as="main" hide-from="extra-large" class="editor" style="min-height: 20rem" />',
['<' => 'main', 'data-md-hide-from' => 'extra-large', 'class' => 'editor', 'style' => 'min-height: 20rem;'],
],
]);
it('takes the element, the visibility props, and the caller\'s class and style after its own', function (string $blade, array $expected, ?string $styleEndsWith = null) {
$root = layoutRoot((string) $this->blade($blade));
expect($root)->toMatchArray($expected);
if ($styleEndsWith !== null) {
expect($root['style'])->toEndWith($styleEndsWith);
}
})->with('layout component root attributes');
dataset('layout component gaps', [
'feed' => ['feed', 'space400', ['wide']],
'grid' => ['grid', 'space300', ['1.5rem']],
'row' => ['row', 'space100', ['tight']],
'stack' => ['stack', 'space200', ['16px', 'space1000']],
]);
it('replaces the gap prop with a spacing token, and with none for anything else', function (string $tag, string $token, array $invalid) {
expect(layoutRoot((string) $this->blade("<x-{$tag} gap=\"{$token}\" />"))['data-md-gap'])->toBe($token);
foreach ($invalid as $bad) {
expect(layoutRoot((string) $this->blade("<x-{$tag} gap=\"{$bad}\" />"))['data-md-gap'])->toBe('none');
}
})->with('layout component gaps');
+1 -15
View File
@@ -68,26 +68,12 @@ it('draws its own back row above the detail, unless the detail brings one', func
->and($pane)->not->toContain('data-md-list-detail-back-row');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />')))
->toMatchArray([
'<' => 'section',
'data-md-hide-below' => 'medium',
'class' => 'inbox',
'style' => 'min-height: 30rem;',
'x-model' => 'chosen',
'x-modelable' => 'selected',
]);
});
it('shows one pane below expanded and both from it, at M3\'s fixed pane widths', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/list-detail.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
expect($css)->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
->toContain("@media (width >= 1200px) {\n grid-template-columns: 412px minmax(0, 1fr);")
->toContain("@media (width < 840px) {\n [data-md-list-detail]:not([data-md-selected]) > [data-md-list-detail-pane='detail'],\n [data-md-list-detail][data-md-selected] > [data-md-list-detail-pane='list'] {\n display: none;")
->toContain("[data-md-list-detail-back] [data-md-icon]:is([dir='rtl'], [dir='rtl'] *)")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/list-detail.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/js/material.js'))->toContain("import './layout.js'");
});
+4 -2
View File
@@ -30,9 +30,11 @@ it('opens a popover menu from its trigger', function () {
});
it('scrolls a menu too long for the window instead of running off it', function () {
// The size limit is the popover chrome a submenu shares (menu-item.css imports this file for
// it), so it sits under the selector list both are drawn from.
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-md-menu-popover')
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover]'))->toMatchArray([
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover], [data-md-submenu]'))->toMatchArray([
'max-block-size' => 'min(288px, calc(100dvh - 32px))',
'overflow-y' => 'auto',
]);
@@ -297,7 +299,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
// A modal bottom sheet, named by the menu's label. Its id is the same in every render, so
// a Livewire morph patches it rather than swapping it (menu.js makes it unique and keeps
// this one as the key); the lists carry keys of their own, as the popover does.
->toContain('...materialBottomSheet(false, JSON.parse(')
->toContain('...materialBottomSheet(JSON.parse(')
->toContain('id="material-menu-sheet"')
->not->toContain("material-menu-{$key[1]}-sheet\"")
// A key given to the Blade component would become the key of the loop around the menu.
@@ -53,10 +53,10 @@ it('keeps the bar item on the label and state-layer colours M3 tokens', function
$bar = ComponentStylesheet::read('navigation-bar-item');
$shared = ComponentStylesheet::read('navigation-item');
// NavigationBarTokens names one label font for both icon positions: label-medium (N-08).
// NavigationBarTokens names one label font for both icon positions: label-medium.
expect($bar->declarations('[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill]', ['@container (width >= 600px)']))
->not->toHaveKey('font')
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19), from
// The indicator and the pill wash in on-secondary-container, as the rail's do, from
// the shared file both the bar's and the rail's items import.
->and($shared->declarations('[data-md-navigation-bar-item] [data-md-navigation-indicator]::before, [data-md-navigation-bar-item] [data-md-navigation-pill]::before, [data-md-navigation-rail-item]::before, [data-md-navigation-rail-item] [data-md-navigation-indicator]::before'))
->toHaveKey('background-color', 'var(--md-sys-color-on-secondary-container)');
@@ -103,7 +103,7 @@ it('hides on a scroll down and springs back on a scroll up', function () {
->toBe(['translate' => '0 100%'])
// Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
// `material.layout`, which this rule's own `material.components` always outranks, so both
// sit in a layer now (N-02's era of an unlayered Tailwind utility is gone).
// sit in a layer now (the era of an unlayered Tailwind utility is gone).
->and($css->declarations('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden])'))
->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
+65 -32
View File
@@ -24,7 +24,7 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
->toContain('data-md-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"')
// The 40px menu button reaches M3's 48px target through the shared classes (N-01).
// The 40px menu button reaches M3's 48px target through the shared classes.
->toContain('class="md-state-layer md-focus-ring md-touch-target"')
->not->toContain('data-md-navigation-rail-scrim')
->not->toContain('x-trap')
@@ -63,13 +63,13 @@ it('takes M3\'s optional divider and turns the container fill off', function ()
$css = ComponentStylesheet::read('navigation-rail');
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22), nor one
// Neither reaches a rail open over a scrim, which is a surface over the page, nor one
// sliding off the window as it closes.
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['border-inline-end' => '1px solid var(--md-sys-color-outline-variant)'])
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['background-color' => 'transparent'])
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead.
->and($css->declarations('[data-md-navigation-rail=\'collapsible\']', ['@media (width < 600px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
});
@@ -97,7 +97,7 @@ it('takes M3\'s narrow collapsed width, icons alone but still named', function (
->and($css->declarations('[data-md-navigation-rail]'))
->toHaveKey('--navigation-rail-collapsed-width', '96px')
// Out of the drawing, not out of the page: clipped only while collapsed.
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]", ['@container style(--md-navigation-rail-value: collapsed)']))
->toHaveKey('clip-path', 'inset(50%)');
});
@@ -206,18 +206,18 @@ it('reproduces every branch of the old rail-collapsed variant for the rail\'s ow
it('flattens a FAB nested in the rail header and morphs its label', function () {
$css = ComponentStylesheet::read('navigation-rail');
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03). Now layered, like
// A nested FAB rests at elevation 0, not the 3 a standalone one has. Now layered, like
// toolbar.css's own docked-FAB override: fab.css's `[data-md-fab]` is one attribute, so the
// doubled selector here (two) always wins without needing to sit outside the layer.
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-fab], [data-md-navigation-rail-header] [data-md-fab][data-md-fab]:hover'))
->toBe(['box-shadow' => 'none'])
// One FAB whose label springs shut, not two swapped by display (N-23).
// One FAB whose label springs shut, not two swapped by display.
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span'))
->toHaveKey('max-width', '256px');
expect($css->declarations("[data-md-navigation-rail-header] [data-md-fab] > span:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span', ['@container style(--md-navigation-rail-value: collapsed)']))
->toBe(['max-width' => '0', 'opacity' => '0'])
->and($css->declarations("[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]', ['@container style(--md-navigation-rail-value: collapsed)']))
->toBe(['min-inline-size' => '0', 'aspect-ratio' => '1', 'gap' => '0']);
});
@@ -260,21 +260,13 @@ it('draws the item\'s own state layer split between the item and its indicator',
});
/**
* Plain CSS has no variant to name "drawn collapsed" once, so every rule with a collapsed shape
* writes the conditions out: navigation-rail.css, navigation-rail-item.css,
* navigation-rail-section.css, and layout/scaffold.css for the actions row inside the rail's footer.
* This walks every copy in those files — not one pinned rule — and fails if any drifts from the
* seven conditions the Tailwind-era `rail-collapsed:` variant had, or if a rule has its collapsed
* shape in some window bands but not in all five.
* navigation-rail.css alone still writes "drawn collapsed" branch for branch: the two rules that
* set the rail's own width and publish `--md-navigation-rail-value`, which the rail cannot query
* on itself (a `@container style()` reads an ancestor, never the element that publishes the
* property). This walks every copy of that seven-condition set and fails if any drifts from it, or
* has its collapsed shape in some window bands but not in all five.
*/
dataset('stylesheets that draw a collapsed rail', [
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'../layout/scaffold',
]);
it('writes "collapsed" with the same seven conditions in every copy, in every band', function (string $name) {
it('writes the rail\'s own "collapsed" condition with the same seven conditions, in every band', function () {
$closed = ':not([data-md-open])';
$bands = [
'always' => ["[data-md-navigation-rail='collapsed']", "[data-rail='collapsed'] [data-md-navigation-rail='collapsible']{$closed}", "[data-md-navigation-rail='modal']{$closed}"],
@@ -284,14 +276,12 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
'@media (width >= 1200px)' => ["[data-rail='collapsed'] [data-md-navigation-rail='adaptive']{$closed}"],
];
// A condition matches the rail itself and everything in it; the scaffold's actions row can only
// ever be inside a rail, so its copy keeps the descendant half alone.
// A condition matches the rail itself and everything in it.
$both = fn (array $conditions): array => array_merge(...array_map(fn (string $condition): array => [$condition, "{$condition} *"], $conditions));
$inside = fn (array $conditions): array => array_map(fn (string $condition): string => "{$condition} *", $conditions);
$groups = [];
foreach (ComponentStylesheet::read($name)->rules() as $rule) {
foreach (ComponentStylesheet::read('navigation-rail')->rules() as $rule) {
preg_match_all('/:where\(((?:[^()]++|\((?1)\))*)\)/', $rule['selector'], $wheres, PREG_SET_ORDER);
$wheres = array_filter($wheres, fn (array $where): bool => str_contains($where[1], 'data-md-navigation-rail='));
@@ -302,17 +292,15 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
$media = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@media')));
$band = match ($media) {
[] => 'always',
['@media (width >= 840px)', '@media (width < 1200px)'] => '@media (840px <= width < 1200px)',
default => implode(' ', $media),
};
expect(array_key_exists($band, $bands))->toBeTrue("{$name}.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
expect(array_key_exists($band, $bands))->toBeTrue("navigation-rail.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
foreach ($wheres as [$where, $conditions]) {
$written = array_map(trim(...), preg_split('/,(?![^()]*\))/', $conditions));
expect(in_array($written, [$both($bands[$band]), $inside($bands[$band])], true))
->toBeTrue("{$name}.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
expect($written)->toBe($both($bands[$band]), "navigation-rail.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
}
$key = str_replace(array_column($wheres, 0), '', $rule['selector']).' '.json_encode($rule['declarations']);
@@ -322,6 +310,51 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
expect($groups)->not->toBeEmpty();
foreach ($groups as $key => $found) {
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "{$name}.css: {$key} is drawn collapsed in some bands only");
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "navigation-rail.css: {$key} is drawn collapsed in some bands only");
}
})->with('stylesheets that draw a collapsed rail');
});
/**
* Everything that takes a collapsed shape and is not the rail element itself reads the value the
* rules above publish, one `@container style()` per rule, instead of a copy of the rail's own
* seven conditions — the reason `--md-navigation-rail-value` is published at all (UPGRADE.md,
* "From 2.0.0 to 2.1.0"). This walks every copy across navigation-rail.css,
* navigation-rail-item.css, navigation-rail-section.css and layout/scaffold.css, fails if any of
* them spells the query differently or wraps no declarations, and — outside navigation-rail.css,
* where the two rules above are the only rules allowed to — fails if the old `:where()` copy of
* the rail's conditions comes back.
*/
dataset('stylesheets with a rail descendant that draws a collapsed shape', [
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'../layout/scaffold',
]);
it('reads the rail\'s published value with the same style container query in every copy', function (string $name) {
$css = ComponentStylesheet::read($name);
$rules = $css->rules();
$matches = array_values(array_filter(
$rules,
fn (array $rule): bool => array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')) !== [],
));
expect($matches)->not->toBeEmpty();
foreach ($matches as $rule) {
$containers = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')));
expect($containers)->toBe(['@container style(--md-navigation-rail-value: collapsed)'], "{$name}.css: `{$rule['selector']}` wraps a different container query")
->and($rule['declarations'])->not->toBeEmpty("{$name}.css: `{$rule['selector']}` wraps an empty container query");
}
if ($name === 'navigation-rail') {
return;
}
foreach ($rules as $rule) {
expect(str_contains($rule['selector'], "data-md-navigation-rail='collapsed'"))
->toBeFalse("{$name}.css: `{$rule['selector']}` copies the rail's conditions instead of querying its value");
}
})->with('stylesheets with a rail descendant that draws a collapsed shape');
@@ -1,143 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The navigation components, rewritten without Tailwind (plan step 36, the last audit group): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
* as px range queries, and is imported from the "Navigation" block of all.css. Every component
* stylesheet importing the stylesheets its view renders is tests/Feature/StylesheetsTest.php's
* now, folded into one dataset over every component view (step 42, `tabs`/`tab`'s N-16 pairing
* included) rather than kept once per group.
*
* The dataset grows by one name per component commit, the same rule ContainmentStylesheetsTest.php
* and the earlier groups follow. `navigation-bar` rejoined the dataset with the scaffold's own
* rewrite: its one rule reading `--material-bottom-bar` stayed unlayered only while `<x-scaffold>`
* published that variable through a Tailwind utility, which no layered rule could outrank; now
* scaffold.css sets it in `material.layout`, which this file's `material.components` always beats,
* so the whole stylesheet fits one `@layer material.components` block like every other entry here.
* `section-nav` renders `<x-tabs>`'s hooks rather than `<x-tabs>` itself (it reuses its stylesheet
* whole, tabs.css, N-16), so `navigationViews()` need not special-case it the way it does 'tabs'.
*/
function navigationComponents(): array
{
return [
'app-bar',
'toolbar',
'tabs',
'navigation-bar',
'navigation-bar-item',
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'section-nav',
'account-menu',
'theme-toggle',
'scheme-picker',
];
}
dataset('navigation components', navigationComponents());
/**
* The checks that read a stylesheet alone also run on the one no view renders by itself:
* navigation-item.css, the indicator fill and state layer the bar's and the rail's items share and
* both import. Nothing else reaches it — the layout stylesheet tests skip component files they
* import — so without this its layer, tokens and scoping would go unchecked.
*/
dataset('navigation stylesheets', [...navigationComponents(), 'navigation-item']);
/**
* The blade view(s) a dataset name's checks read. One file for every name but 'tabs', whose panel
* lives in a second view, tab.blade.php, sharing tabs.css with tabs.blade.php.
*
* @return list<string>
*/
function navigationViews(string $name): array
{
return $name === 'tabs' ? ['tabs', 'tab'] : [$name];
}
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
*/
function assertNavigationBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
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 or a ring, type is a type style, and breakpoints
* are px, at one of M3's four.
*/
function assertNavigationTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
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 box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
$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]) {
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");
}
assertNavigationBreakpointsInPx($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('navigation stylesheets');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
foreach (navigationViews($name) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('navigation components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertNavigationTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('navigation stylesheets');
it('is imported from the navigation block of all.css', function (string $name) {
expect(allCssBlock('Navigation'))->toContain("@import './components/{$name}.css';");
})->with('navigation stylesheets');
it('scopes every element-wide selector to a data-md hook', function (string $name) {
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
// `dialog` or `:root` rule would restyle the application's own pages. One that applies only
// while a package component is on the page (`:root:has([data-md-toolbar-place…])`) is scoped.
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\((?:> )?\[data-md-)/m');
})->with('navigation stylesheets');
+7 -12
View File
@@ -57,7 +57,7 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Only a subtitle<\/p>/')
->not->toContain('data-md-modal-title');
// The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which
// The markup is the same at every width; modal.css (ComponentStylesheetsTest) decides which
// of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle.
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle]))')
@@ -100,7 +100,6 @@ it('divides a scrolling body from its header and actions only while content is h
->toContain('position: absolute;')
// A token that reduced motion sets to zero, so there the rule simply appears.
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/modal.css';")
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
->toContain("directive('dialog-dividers'")
@@ -209,7 +208,7 @@ it('slides a side sheet in from either edge', function () {
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
expect((string) $this->blade('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(false, JSON.parse(')
->toContain('...materialBottomSheet(JSON.parse(')
->toContain('data-md-bottom-sheet-scrim')
->toContain('x-trap.inert.noscroll="open"')
->toContain('x-layer="open" x-on:material-escape="close()"')
@@ -220,7 +219,7 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
->toContain('md-focus-ring md-touch-target')
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true, JSON.parse(')
->toContain('...materialBottomSheet(JSON.parse(')
->not->toContain('data-md-bottom-sheet-scrim')
->not->toContain('aria-modal')
->toContain('data-md-standard')
@@ -289,7 +288,7 @@ it('slides a side sheet out as it slid in, from its own edge in either direction
->toMatch('/<aside\s+x-cloak\s+x-show="open"\s+x-transition:enter="md-transition"\s+x-transition:leave="md-transition"/');
});
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
it('draws the drawer with the pane prop gone', function () {
$view = File::get(__DIR__.'/../../../resources/views/components/drawer.blade.php');
expect($view)
@@ -298,9 +297,7 @@ it('draws the drawer from a stylesheet imported from the containment block, with
->not->toContain('paneCloseOnEscape')
->not->toContain('pane-width')
->not->toContain('pane-close-on-escape')
->not->toContain('data-pane')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))
->toContain("@import './components/drawer.css';");
->not->toContain('data-pane');
});
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
@@ -330,10 +327,8 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun
->toContain('aria-label="Close"');
});
it('draws the bottom sheet from a stylesheet imported from the containment block and by the menu', function () {
expect(File::get(__DIR__.'/../../../resources/css/all.css'))
->toContain("@import './components/bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
it('draws the bottom sheet from a stylesheet imported by the menu', function () {
expect(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
->toContain("@import './bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/js/bottom-sheet.js'))
->toContain('data-md-bottom-sheet-handle')
+3 -16
View File
@@ -68,24 +68,11 @@ it('caps its width with M3\'s narrow measure or the wider steps', function () {
expect(layoutRoot((string) $this->blade('<x-pane width="40rem" />')))->not->toHaveKey('data-md-width');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />')))
->toMatchArray([
'<' => 'section',
'data-md-hide-below' => 'expanded',
'class' => 'checkout',
'style' => 'max-width: 50rem;',
'aria-labelledby' => 'checkout-title',
]);
});
it('keeps M3\'s margin on the body, once, in the layout layer', function () {
it('keeps M3\'s margin on the body, once', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/pane.css');
expect($css)->toContain('@layer material.layout')
->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
expect($css)->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
->toContain("@media (width >= 600px) {\n padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space300));")
->toContain("[data-md-pane-body] > * {\n --md-layout-margin: 0px;")
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/pane.css';");
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;");
});
+12 -11
View File
@@ -56,24 +56,25 @@ it('draws the first frame on the server, at the value', function () {
->toContain('<path d="M36.579 27.01A18 18 0 1 1 12.99 3.421" />');
});
it('draws the active indicator in the colour and the track in its container', function (string $color, array $declarations) {
$css = ComponentStylesheet::read('progress');
it('draws the active indicator in the colour and the track in its container', function (string $color) {
// Every hue but primary comes straight off the shared colour-role table (color.css,
// ColorTest.php): `stroke` reads `--md-container`, which is the hue's own container for
// every one of them.
expect((string) $this->blade('<x-progress value="50" :color="$color" />', ['color' => $color]))
->toContain("data-md-color=\"{$color}\"")
->and($css->declarations("[data-md-progress][data-md-color='{$color}']"))->toBe($declarations);
})->with([
'secondary' => ['secondary', ['color' => 'var(--md-sys-color-secondary)', 'stroke' => 'var(--md-sys-color-secondary-container)']],
'tertiary' => ['tertiary', ['color' => 'var(--md-sys-color-tertiary)', 'stroke' => 'var(--md-sys-color-tertiary-container)']],
'error' => ['error', ['color' => 'var(--md-sys-color-error)', 'stroke' => 'var(--md-sys-color-error-container)']],
]);
->toContain("data-md-color=\"{$color}\"");
})->with(['secondary', 'tertiary', 'error']);
it('falls back to primary for an unknown colour, secondary-container track included', function () {
$css = ComponentStylesheet::read('progress');
expect((string) $this->blade('<x-progress value="50" color="purple" />'))->toContain('data-md-color="primary"')
->and($css->declarations('[data-md-progress]'))->toMatchArray([
'color' => 'var(--md-sys-color-primary)',
'color' => 'var(--md-color, var(--md-sys-color-primary))',
'stroke' => 'var(--md-container, var(--md-sys-color-secondary-container))',
])
// Unlike every other hue, primary keeps a track of its own: the shared table's `primary`
// entry is plain primary-container, which progress does not want.
->and($css->declarations("[data-md-progress][data-md-color='primary']"))->toBe([
'stroke' => 'var(--md-sys-color-secondary-container)',
]);
});
+3 -21
View File
@@ -7,11 +7,6 @@ it('lays its children side by side in a div, with nothing set but itself', funct
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-justify', 'data-md-wrap', 'data-md-stack-below']);
});
it('spaces its children with a spacing token only, and with none for anything else', function () {
expect(layoutRoot((string) $this->blade('<x-row gap="space100" />'))['data-md-gap'])->toBe('space100')
->and(layoutRoot((string) $this->blade('<x-row gap="tight" />'))['data-md-gap'])->toBe('none');
});
it('aligns its children across it and places them along it', function () {
foreach (['center', 'start', 'end', 'stretch', 'baseline'] as $align) {
expect(layoutRoot((string) $this->blade('<x-row :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
@@ -39,22 +34,9 @@ it('stacks below one of M3\'s breakpoints, and never below compact', function ()
}
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-row as="nav" hide-from="expanded" class="toolbar-row" style="min-height: 3rem" aria-label="Filters" />')))
->toMatchArray([
'<' => 'nav',
'data-md-hide-from' => 'expanded',
'class' => 'toolbar-row',
'style' => 'min-height: 3rem;',
'aria-label' => 'Filters',
]);
});
it('draws the row from a stylesheet in the layout layer, stacking at the breakpoint in px', function () {
it('draws the row from a stylesheet, stacking at the breakpoint in px', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/row.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;")
->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/row.css';");
expect($css)->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;")
->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;");
});
+6 -12
View File
@@ -197,15 +197,11 @@ it('stacks the actions row into a column through every branch a collapsed rail c
$css = scaffoldCss();
// The three width-independent branches share one :where() group; the four width-gated ones
// each keep their own @media block, since CSS cannot merge different queries — the same shape
// navigation-rail.css's own rewrite gave its internal parts.
// The actions row is always inside a rail, never the rail element itself, so it reads the
// rail's published value with one style container query instead of copying every branch a
// collapsed rail can take.
expect($css)
->toContain("&:where(\n [data-md-navigation-rail='collapsed'] *,\n [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,\n [data-md-navigation-rail='modal']:not([data-md-open]) *\n ) {\n flex-direction: column;\n }")
->toContain("@media (width < 600px) {\n &:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
->toContain("@media (width < 840px) {\n &:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
->toContain("@media (width >= 840px) {\n @media (width < 1200px) {\n &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }\n }")
->toContain("@media (width >= 1200px) {\n &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }");
->toContain("@container style(--md-navigation-rail-value: collapsed) {\n flex-direction: column;\n }");
});
it('places each slot once', function () {
@@ -264,8 +260,7 @@ it('marks the scaffold, and draws the FAB after the page\'s bar and before the p
it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar, in the layout layer', function () {
$css = scaffoldCss();
expect($css)->toContain('@layer material.layout')
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
expect($css)->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
->toContain("@media (width >= 600px) {\n --md-scaffold-fab-margin: var(--md-sys-measurement-space300);")
->toContain('max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)))')
->toContain('+ var(--material-snackbar-height, 0px)')
@@ -273,8 +268,7 @@ it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar,
// The content region already has the window margin, so a pane inside draws none.
// Only the region's own <main>: one an application nests in the page takes none of this.
->toContain("[data-md-scaffold-content] > main {\n --md-layout-margin: 0px;")
->not->toContain('[data-md-scaffold] main')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/scaffold.css';");
->not->toContain('[data-md-scaffold] main');
});
it('imports the stylesheets of the navigation rail, the navigation bar and the snackbar host', function () {
@@ -27,7 +27,8 @@ it('draws a radio per generated profile, bound, named and previewing on change',
->toContain('Applies after saving')
->toContain('data-md-scheme-picker-option="indigo"')
->toContain('data-md-scheme-picker-option="teal"')
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
->toContain('class="md-visually-hidden"')
->toMatch('/<input\s+class="md-visually-hidden"\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
->toContain('>Teal</span>')
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
+1 -2
View File
@@ -117,6 +117,5 @@ it('draws the bar and the view from its stylesheet, at M3\'s widths', function (
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {")
// Leaving full screen, the header bar fades with the view instead of going on the first frame.
->toContain('[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {')
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';");
->not->toMatch('/\\d(?:\\.\\d+)?rem/');
});
+2 -5
View File
@@ -150,8 +150,7 @@ it('draws the checkbox from its stylesheet, on the row the selection controls sh
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
->toContain('[data-md-selection-row]')
->toContain('@layer material.components')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/checkbox.css';");
->toContain('@layer material.components');
});
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
@@ -159,7 +158,6 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
expect($css)->toContain("@import './selection.css';")
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/radio.css';")
->and((string) $this->blade('<x-radio name="size" class="app-field-spacing" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
->toMatch('/data-md-radio\\s+class="app-field-spacing" style="order: 1"/');
});
@@ -167,6 +165,5 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
it('draws the switch from its stylesheet', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/toggle.css'))
->toContain("@import './selection.css';")
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/toggle.css';");
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/');
});
+26 -17
View File
@@ -4,6 +4,7 @@ use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
use Livewire\Component;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('is a labelled native range input under a drawing only the script touches', function () {
$html = (string) $this->blade('<x-slider label="Volume" name="volume" value="40" :min="0" :max="200" :step="5" id="volume" />');
@@ -46,8 +47,7 @@ it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track',
->and($css)
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/slider.css';");
->toContain("@import './icon.css';");
});
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
@@ -249,23 +249,32 @@ it('keeps a range slider horizontal, as M3 asks', function () {
->not->toContain('aria-orientation');
});
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
it('draws in the colour and its container, and greys out when disabled', function (string $color) {
// Every hue but primary comes straight off the shared colour-role table (color.css,
// ColorTest.php): the active pair is `--md-color`/`--md-on-color`, the inactive one is the
// hue's own `--md-container`/`--md-on-container`, which for every one of them is its container.
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
->toMatch('/<input[^>]*\sdisabled[\s>]/');
})->with(['primary', 'tertiary', 'error', 'unknown' => 'pink']);
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
it('reads the shared colour-role table, keeping its own secondary-container for primary', function () {
$css = ComponentStylesheet::read('slider');
if ($color === 'primary' || $color === 'pink') {
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
} else {
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
}
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
})->with([
'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'error', 'error-container'],
'unknown' => ['pink', 'primary', 'secondary-container'],
]);
expect($css->declarations('[data-md-slider]'))->toMatchArray([
'--slider-active' => 'var(--md-color, var(--md-sys-color-primary))',
'--slider-inactive' => 'var(--md-container, var(--md-sys-color-secondary-container))',
'--slider-on-active' => 'var(--md-on-color, var(--md-sys-color-on-primary))',
'--slider-on-inactive' => 'var(--md-on-container, var(--md-sys-color-on-secondary-container))',
])
// Unlike every other hue, primary keeps its inactive track of its own: the shared table's
// `primary` entry is plain primary-container, which the slider does not want.
->and($css->declarations("[data-md-slider][data-md-color='primary']"))->toBe([
'--slider-inactive' => 'var(--md-sys-color-secondary-container)',
'--slider-on-inactive' => 'var(--md-sys-color-on-secondary-container)',
])
->and($css->declarations('[data-md-slider]:has([data-md-slider-input]:disabled)'))->toMatchArray([
'--slider-active' => 'var(--slider-disabled-active)',
'--slider-inactive' => 'var(--slider-disabled-inactive)',
]);
});
+1 -4
View File
@@ -23,10 +23,7 @@ it('leaves the corners to the pair, growing the inner ones under the finger', fu
expect($css->declarations('[data-md-split-button] [data-md-split]'))->toBe([
'--md-split-button-corner' => 'var(--md-split-button-inner)',
'border-start-start-radius' => 'var(--md-split-button-corner)',
'border-end-start-radius' => 'var(--md-split-button-corner)',
'border-start-end-radius' => 'var(--md-split-button-corner)',
'border-end-end-radius' => 'var(--md-split-button-corner)',
'border-radius' => 'var(--md-split-button-corner)',
])
->and($css->declarations('[data-md-split-button] [data-md-split]:active'))->toBe(['--md-split-button-corner' => 'var(--md-split-button-inner-pressed)'])
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true']"))->toBe(['--md-split-button-corner' => 'var(--md-split-button-full)'])
+3 -22
View File
@@ -7,12 +7,6 @@ it('stacks its children in a div with no gap and no alignment of its own', funct
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-hide-below', 'data-md-hide-from']);
});
it('spaces its children with a spacing token only, and with none for anything else', function () {
expect(layoutRoot((string) $this->blade('<x-stack gap="space200" />'))['data-md-gap'])->toBe('space200')
->and(layoutRoot((string) $this->blade('<x-stack gap="16px" />'))['data-md-gap'])->toBe('none')
->and(layoutRoot((string) $this->blade('<x-stack gap="space1000" />'))['data-md-gap'])->toBe('none');
});
it('aligns its children across it', function () {
foreach (['stretch', 'start', 'center', 'end'] as $align) {
expect(layoutRoot((string) $this->blade('<x-stack :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
@@ -21,25 +15,12 @@ it('aligns its children across it', function () {
expect(layoutRoot((string) $this->blade('<x-stack align="baseline" />')))->not->toHaveKey('data-md-align');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />')))
->toMatchArray([
'<' => 'ul',
'data-md-hide-below' => 'medium',
'data-md-hide-from' => 'large',
'class' => 'steps',
'style' => 'max-width: 40rem;',
]);
});
it('draws the stack from a stylesheet in the layout layer, imported by all.css', function () {
it('draws the stack from a stylesheet in the layout layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/stack.css');
expect($css)->toContain('@layer material.layout')
->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
expect($css)->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
// A stack inside another never takes its parent's gap.
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/stack.css';");
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;");
});
it('keeps a button at its label\'s width while the stack stretches the rest', function () {
@@ -50,23 +50,11 @@ it('docks the supporting pane as a bottom sheet below expanded, opened by its ha
->and(layoutRoot((string) $this->blade('<x-supporting-pane compact="drawer" />'))['data-md-compact'])->toBe('below');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-supporting-pane as="main" hide-from="extra-large" class="editor" style="min-height: 20rem" />')))
->toMatchArray([
'<' => 'main',
'data-md-hide-from' => 'extra-large',
'class' => 'editor',
'style' => 'min-height: 20rem;',
]);
});
it('places the supporting pane at M3\'s widths from expanded, in the layout layer', function () {
it('places the supporting pane at M3\'s widths from expanded', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/supporting-pane.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width >= 840px) {\n grid-template-columns: minmax(0, 1fr) 360px;")
expect($css)->toContain("@media (width >= 840px) {\n grid-template-columns: minmax(0, 1fr) 360px;")
->toContain("@media (width >= 1200px) {\n grid-template-columns: minmax(0, 1fr) 412px;")
->toContain("[data-md-supporting-pane][data-md-width='split'] {\n grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);")
->toContain('inset-block-end: max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)));')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/supporting-pane.css';");
->toContain('inset-block-end: max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)));');
});
+2 -4
View File
@@ -61,13 +61,11 @@ it('puts the caller\'s class and style on the surface untouched', function () {
it('draws the surface from a stylesheet in the layout layer, imported by all.css', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/surface.css');
expect($css)->toContain('@layer material.layout')
->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
expect($css)->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
->toContain('background-color: var(--md-sys-color-surface-container-high);')
->toContain('border: 1px solid var(--md-sys-color-outline-variant);')
->toContain("@import './spacing.css';")
->toContain("@import './visibility.css';")
// A pane on a surface keeps its content off the surface's edge again.
->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/surface.css';");
->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;");
});
+8 -8
View File
@@ -22,7 +22,7 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
->toContain('data-md-tab-indicator')
->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"')
// The panel's id is the server's, so aria-controls never dangles (N-05).
// The panel's id is the server's, so aria-controls never dangles.
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
->toContain('>3</span>');
});
@@ -69,7 +69,7 @@ it('entangles the chosen tab with Livewire and renders the one the property name
->toContain(".entangle('tab').live")
->not->toContain('x-modelable')
->toMatch('/data-md-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
// The panel the property names is the one drawn, before Alpine entangles anything.
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
});
@@ -108,17 +108,17 @@ it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', functio
$css = ComponentStylesheet::read('tabs');
expect($css->declarations('[data-md-tabs-bar][data-md-scrollable]'))
// 52dp before the first of a scrollable set (N-10).
// 52dp before the first of a scrollable set.
->toHaveKey('padding-inline-start', '52px')
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
// A primary indicator is inset 2dp each side; a secondary one spans the tab.
->and($css->declarations('[data-md-tab-indicator]'))
->toHaveKey('inset-inline', '2px')
->and($css->declarations('[data-md-tabs-bar][data-md-variant=\'secondary\'] [data-md-tab-indicator]'))
->toHaveKey('inset-inline', '0')
// The ring is 2px outside, as everywhere else in the package (N-20).
// The ring is 2px outside, as everywhere else in the package.
->and($css->declarations('[data-md-tab]:focus-visible'))
->toBe(['color' => 'var(--md-sys-color-on-surface)', 'outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
// A link marked as the page is the chosen tab too (N-21).
// A link marked as the page is the chosen tab too.
->and($css->declarations('[data-md-tab]:is([aria-selected=\'true\'], [aria-current=\'page\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
});
@@ -200,7 +200,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
->toMatch('/data-md-section-nav-picker.*Security.*>\s*1\s*<.*<nav/s');
// The picker shows only below medium, the tab bar only from it (N-16's compact fallback).
// The picker shows only below medium, the tab bar only from it (the compact fallback).
$css = ComponentStylesheet::read('section-nav');
expect($css->declarations('[data-md-section-nav-picker]', ['@media (width >= 600px)']))
@@ -218,7 +218,7 @@ it('marks the section whose url is the request\'s, and scrolls many sections', f
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows.
->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/')
->not->toContain('grid-cols-')
// Its label is `<x-tabs>`'s anatomy, drawn by tabs.css, not a one-line `md-truncate`.