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:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -2,7 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
/**
|
||||
@@ -23,8 +22,6 @@ const BOOST_TEXTS = [
|
||||
*/
|
||||
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
|
||||
|
||||
beforeEach(fn () => Stylesheets::resetCache());
|
||||
|
||||
/**
|
||||
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
|
||||
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
|
||||
|
||||
@@ -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');
|
||||
@@ -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))',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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]'))
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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] {');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
@@ -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;/');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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');
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -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))']);
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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')
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
|
||||
@@ -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;/');
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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)'])
|
||||
|
||||
@@ -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)));');
|
||||
});
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+460
-473
File diff suppressed because it is too large
Load Diff
@@ -288,24 +288,12 @@ it('sends an expired form back to its page to refresh', function () {
|
||||
/**
|
||||
* Plan step 36: `errors::minimal`'s class lists moved into
|
||||
* resources/css/components/error-page.css, keyed on `data-md-error-*`. Its view lives outside
|
||||
* resources/views/components/, so it is not in ContainmentStylesheetsTest's dataset (whose
|
||||
* "imports what its view renders" check reads a fixed resources/views/components/<name>.blade.php
|
||||
* path); this is its own small stylesheet-shape test instead, checking the same things.
|
||||
* resources/views/components/, so it is not in ComponentStylesheetsTest's view-scoped datasets
|
||||
* (whose "imports what its view renders" and "no class list" checks read a fixed
|
||||
* resources/views/components/<name>.blade.php path) — those two stay here, reading its real view
|
||||
* path, while the stylesheet-only checks (shape, tokens, block import) run there instead, on the
|
||||
* `error-page` entry of its dataset.
|
||||
*/
|
||||
it('draws the error layout from a stylesheet shaped like every package stylesheet', function () {
|
||||
$css = ComponentStylesheet::read('error-page');
|
||||
|
||||
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('error-page')).'/'.$import))->toBeTrue("error-page.css imports {$import}, which does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it('imports the stylesheet of every component the error layout renders', function () {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php'), $tags);
|
||||
|
||||
@@ -322,33 +310,6 @@ it('writes no class list into the error layout but the interaction and text clas
|
||||
expect(ViewClasses::violations(File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php')))->toBe([]);
|
||||
});
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function () {
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->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)/');
|
||||
|
||||
preg_match_all('/@media\s*([^{]+)\{/', $source, $queries);
|
||||
|
||||
foreach ($queries[1] 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', $query);
|
||||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is imported from the containment block of all.css', function () {
|
||||
expect(allCssBlock('Containment'))->toContain("@import './components/error-page.css';");
|
||||
});
|
||||
|
||||
it('styles the body only when it holds the error layout, and turns the shape on both paths', function () {
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
|
||||
|
||||
@@ -361,7 +322,7 @@ it('styles the body only when it holds the error layout, and turns the shape on
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan step 40: the fallback is `Stylesheets::bundle()` of the foundation and the error layout
|
||||
* Plan step 40: the fallback is the prebuilt bundle of the foundation and the error layout
|
||||
* (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries
|
||||
* their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the
|
||||
* bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face`
|
||||
@@ -382,13 +343,15 @@ it('bundles the foundation and the error layout into the fallback, without an im
|
||||
->toContain('[data-md-error-shape]')
|
||||
->toContain('[data-md-button]')
|
||||
->toContain('[data-md-shape]')
|
||||
// The foundation's reset and tokens travelled in too, not only the error layout.
|
||||
->toContain('[data-md-icon]')
|
||||
// The foundation's reset, tokens and page travelled in too, not only the error layout.
|
||||
->toContain('box-sizing: border-box')
|
||||
->toContain('--md-sys-color-surface');
|
||||
->toContain('--md-sys-color-surface')
|
||||
->toMatch('/html \{\s*background-color/');
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan step 46: what the page inlines beside a build is `Stylesheets::bundle()` of the error layout
|
||||
* Plan step 46: what the page inlines beside a build is the prebuilt bundle of the error layout
|
||||
* alone. It reaches no `@font-face` — the package's only one is tokens/font.css's, which only the
|
||||
* foundation imports, and the application's build serves it — and no `url()` a request from the
|
||||
* page would have to resolve, so nothing needs dropping from it the way the fallback drops the face.
|
||||
@@ -398,33 +361,11 @@ it('inlines beside a build the error layout\'s bundle, which holds no font face,
|
||||
$css = (string) ErrorPage::layoutStyles();
|
||||
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
|
||||
|
||||
expect($css)->toBe(Stylesheets::bundle([$layout]))
|
||||
expect($css)->toBe(File::get(__DIR__.'/../../resources/dist/error-page.css'))
|
||||
->and(array_map(basename(...), Stylesheets::resolvedFiles([$layout])))
|
||||
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css'])
|
||||
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css', 'color.css'])
|
||||
->and($plain)->not->toContain('@font-face')
|
||||
->not->toContain('@import')
|
||||
->not->toMatch('/\burl\(/i')
|
||||
->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;');
|
||||
});
|
||||
|
||||
/**
|
||||
* The bundle's comments name `@font-face` too (foundation/base.css's and error-page.css's headers).
|
||||
* Read as the rule, each mention cut from inside its comment to the end of the next balanced block
|
||||
* and left the comment open, so base.css's `html` rule and icon.css's rules went missing from the
|
||||
* fallback. Only the real block may leave: what remains is the bundle, rule for rule, without it.
|
||||
*/
|
||||
it('drops only the real font face from the fallback, not the rules after a comment that names one', function () {
|
||||
$files = [
|
||||
realpath(__DIR__.'/../../resources/css/foundation.css'),
|
||||
realpath(__DIR__.'/../../resources/css/components/error-page.css'),
|
||||
];
|
||||
$withoutComments = fn (string $css): string => (string) preg_replace('~/\*.*?\*/~s', '', $css);
|
||||
$bundle = Stylesheets::bundle($files);
|
||||
|
||||
expect(substr_count($withoutComments($bundle), '@font-face'))->toBe(1)
|
||||
->and(substr_count($bundle, '@font-face'))->toBeGreaterThan(1)
|
||||
->and($withoutComments((string) ErrorPage::fallbackStyles()))
|
||||
->toStartWith($withoutComments((string) preg_replace('/@font-face\s*\{[^{}]*\}/', '', $bundle)))
|
||||
->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);/')
|
||||
->toContain('[data-md-icon] {');
|
||||
});
|
||||
|
||||
+10
-14
@@ -4,17 +4,19 @@ use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* PHP cannot open a woff2 without the Brotli extension, so the axes are read by Node:
|
||||
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node the test is skipped, the
|
||||
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node every test here is skipped, the
|
||||
* way `material:scheme` reports the same missing binary instead of guessing.
|
||||
*/
|
||||
it('keeps the variable axes the typescale depends on in the packaged font', function () {
|
||||
$node = config('livewire-material.node', 'node');
|
||||
beforeEach(function () {
|
||||
$this->node = config('livewire-material.node', 'node');
|
||||
|
||||
if (Process::run([$node, '--version'])->failed()) {
|
||||
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
|
||||
if (Process::run([$this->node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/fontkit')) {
|
||||
$this->markTestSkipped("Node ({$this->node}) or fontkit is missing; bin/check-font.mjs reads the woff2's axes.");
|
||||
}
|
||||
});
|
||||
|
||||
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs']);
|
||||
it('keeps the variable axes the typescale depends on in the packaged font', function () {
|
||||
$result = Process::run([$this->node, __DIR__.'/../../bin/check-font.mjs']);
|
||||
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
@@ -27,14 +29,8 @@ it('keeps the variable axes the typescale depends on in the packaged font', func
|
||||
->and($font['axes']['ROND'])->toMatchArray(['min' => 0, 'max' => 100]);
|
||||
});
|
||||
|
||||
it('fails loudly when the font has no variable axes at all', function () {
|
||||
$node = config('livewire-material.node', 'node');
|
||||
|
||||
if (Process::run([$node, '--version'])->failed()) {
|
||||
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
|
||||
}
|
||||
|
||||
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
|
||||
it('fails loudly when the font file cannot be opened', function () {
|
||||
$result = Process::run([$this->node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
|
||||
|
||||
expect($result->failed())->toBeTrue()
|
||||
->and($result->errorOutput())->toContain('no-such-font.woff2');
|
||||
|
||||
@@ -33,12 +33,6 @@ it('compiles an unprefixed component to the same view on every machine', functio
|
||||
->not->toContain(hash('xxh128', LivewireMaterialServiceProvider::componentPath()));
|
||||
});
|
||||
|
||||
it('still renders a view compiled when the namespace came from the component path', function () {
|
||||
$legacy = hash('xxh128', LivewireMaterialServiceProvider::componentPath());
|
||||
|
||||
expect(view()->exists($legacy.'::input'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('registers the components under a configured prefix', function () {
|
||||
config(['livewire-material.prefix' => 'm']);
|
||||
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
|
||||
|
||||
/**
|
||||
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, bundled by
|
||||
* `Stylesheets::bundle()` and served without the application's Vite build, and the two package
|
||||
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, prebuilt into
|
||||
* resources/dist/showcase.css and served without the application's Vite build, and the two package
|
||||
* folders its relative `url()`s point into.
|
||||
*/
|
||||
afterEach(function () {
|
||||
Stylesheets::resetCache();
|
||||
});
|
||||
|
||||
it('serves the bundle as long-cached CSS at the hash of its own content', function () {
|
||||
$response = $this->get(ShowcaseAssetController::url())
|
||||
->assertOk()
|
||||
@@ -35,11 +32,14 @@ it('redirects a hash that does not match the current bundle to the current one',
|
||||
it('serves the font and svg files the bundle references, mime-typed by extension', function () {
|
||||
$css = $this->get(ShowcaseAssetController::url())->getContent();
|
||||
|
||||
// The stylesheet sits in assets/css/, so its relative `../fonts/…` and `../svg/…` land on the asset route.
|
||||
expect(dirname(parse_url(ShowcaseAssetController::url(), PHP_URL_PATH)))->toBe(route('livewire-material.asset', ['path' => 'css'], false))
|
||||
->and($css)->toContain("url('../fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')")
|
||||
->toContain('url("../svg/symbols/outlined/check.svg")');
|
||||
|
||||
$fontUrl = route('livewire-material.asset', ['path' => 'fonts/google-sans-flex/GoogleSansFlex-Latin.woff2'], false);
|
||||
$svgUrl = route('livewire-material.asset', ['path' => 'svg/symbols/outlined/check.svg'], false);
|
||||
|
||||
expect($css)->toContain($fontUrl)->toContain($svgUrl);
|
||||
|
||||
$this->get($fontUrl)
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'font/woff2')
|
||||
@@ -93,7 +93,6 @@ it('bundles the application generated scheme, unlayered, when one sits beside it
|
||||
|
||||
file_put_contents($dir.'/material-scheme.css', ":root { --md-showcase-test-marker: #123456; }\n");
|
||||
config(['livewire-material.scheme' => $dir.'/material-scheme.json']);
|
||||
Stylesheets::resetCache();
|
||||
|
||||
try {
|
||||
$this->get(ShowcaseAssetController::url())
|
||||
@@ -105,15 +104,12 @@ it('bundles the application generated scheme, unlayered, when one sits beside it
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to Scheme::load() in the default shape when no generated stylesheet is found', function () {
|
||||
it('falls back to the scheme JSON in the generated shape when no generated stylesheet is found', function () {
|
||||
config(['livewire-material.scheme' => sys_get_temp_dir().'/livewire-material-showcase-missing-'.Str::random(8).'.json']);
|
||||
Stylesheets::resetCache();
|
||||
|
||||
$css = $this->get(ShowcaseAssetController::url())->assertOk()->getContent();
|
||||
|
||||
// The fallback's own one-line selector list, distinct from tokens/scheme.css's multi-line one
|
||||
// (already in all.css), so its presence here can only come from the appended fallback block.
|
||||
expect($css)->toContain("[data-contrast='medium'], [data-contrast='medium'][data-theme='light'], [data-contrast='medium'] [data-theme='light']");
|
||||
expect($css)->toEndWith(SchemeStylesheet::withProfiles(...Scheme::forStylesheet()))
|
||||
->toContain("[data-contrast='medium'][data-theme='dark']");
|
||||
});
|
||||
|
||||
it('mounts none of the showcase asset routes when the showcase is off', function () {
|
||||
@@ -122,7 +118,7 @@ it('mounts none of the showcase asset routes when the showcase is off', function
|
||||
$this->refreshApplication();
|
||||
|
||||
try {
|
||||
$this->get('/material/showcase.0000000000000000.css')->assertNotFound();
|
||||
$this->get('/material/assets/css/showcase.0000000000000000.css')->assertNotFound();
|
||||
$this->get('/material/assets/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')->assertNotFound();
|
||||
} finally {
|
||||
putenv('MATERIAL_SHOWCASE=true');
|
||||
|
||||
@@ -4,84 +4,25 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* `Stylesheets::bundle()` (plan step 37) is what the showcase and the error page's fallback serve
|
||||
* without the application's own Vite build, so it has to do in PHP what Vite's bundled
|
||||
* postcss-import already does for a normal build: inline every `@import` once, first occurrence
|
||||
* kept, and leave a `url()` reachable from wherever the bundle ends up.
|
||||
* The stylesheets the package serves on its own — the showcase's and the error page's, outside an
|
||||
* application's Vite build — are prebuilt into resources/dist/ by `npm run build:stylesheets`
|
||||
* (bin/stylesheets.mjs), with Vite's own postcss-import: every `@import` inlined once, first
|
||||
* occurrence kept. This file pins that the committed files are current, the Vite deduplication they
|
||||
* rely on, and `Stylesheets::resolvedFiles()`, the import graph `DesignGuard` reads.
|
||||
*
|
||||
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
|
||||
* file's own header, layer statement and imports) — a concern that holds whether or not anything
|
||||
* ever bundles them. This file is about the bundler itself: what `bundle()` produces, and, at the
|
||||
* end, a real Vite build to pin the claim it is modelled on.
|
||||
*
|
||||
* Every helper here is named `stylesheetsBundle*` rather than reusing StylesheetsTest.php's
|
||||
* `stylesheet*` names — the two files may run in the same Pest process, and a duplicate top-level
|
||||
* function name is a fatal error, the same reason the four component-group stylesheet test files
|
||||
* each name their own `assert…TokensAndPxBreakpoints()` uniquely.
|
||||
* file's own header, layer statement and imports). `stylesheetsBundlePath()` is named apart from
|
||||
* StylesheetsTest.php's `stylesheetPath()` for the same reason: the two files may run in the same
|
||||
* Pest process, and a duplicate top-level function name is a fatal error.
|
||||
*/
|
||||
function stylesheetsBundlePath(string $path = ''): string
|
||||
{
|
||||
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
|
||||
return (string) realpath(ComponentStylesheet::cssPath($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* `$css` with every comment and quoted string blanked out — the same idea StylesheetsTest.php's
|
||||
* `stylesheetWithoutComments()` uses, kept local here for the reason above.
|
||||
*/
|
||||
function stylesheetsBundleWithoutComments(string $css): string
|
||||
{
|
||||
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first line of a stylesheet's header comment, the sentence naming what it draws — unique per
|
||||
* file (checked below), untouched by `bundle()` since it only ever rewrites an `@import` or a
|
||||
* `url()`, never a comment. A reliable per-file fingerprint in `bundle()`'s output, which is not
|
||||
* minified.
|
||||
*/
|
||||
function stylesheetsBundleMarker(string $file): string
|
||||
{
|
||||
$lines = explode("\n", File::get($file));
|
||||
|
||||
return trim((string) preg_replace('/^\s*\*\s?/', '', $lines[1] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function stylesheetsBundleComponentAndLayoutFiles(): array
|
||||
{
|
||||
return collect(File::files(stylesheetsBundlePath('components')))
|
||||
->merge(File::files(stylesheetsBundlePath('layout')))
|
||||
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
it('bundles all.css with every import resolved, foundation first, every component and layout file once', function () {
|
||||
$bundled = Stylesheets::bundle([stylesheetsBundlePath('all.css')]);
|
||||
|
||||
expect(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
|
||||
|
||||
$files = stylesheetsBundleComponentAndLayoutFiles();
|
||||
$markers = collect($files)->mapWithKeys(fn (string $file): array => [$file => stylesheetsBundleMarker($file)]);
|
||||
|
||||
expect($markers)->toHaveCount(count($files))
|
||||
->and($markers->unique()->count())->toBe($markers->count(), 'two files share a header marker');
|
||||
|
||||
foreach ($markers as $file => $marker) {
|
||||
expect(substr_count($bundled, $marker))->toBe(1, basename($file).' should appear exactly once in the bundle');
|
||||
}
|
||||
|
||||
$foundationPosition = strpos($bundled, stylesheetsBundleMarker(stylesheetsBundlePath('foundation.css')));
|
||||
$earliestOtherPosition = $markers->map(fn (string $marker): int => (int) strpos($bundled, $marker))->min();
|
||||
|
||||
expect($foundationPosition)->toBeInt()
|
||||
->and($foundationPosition)->toBeLessThan($earliestOtherPosition);
|
||||
});
|
||||
|
||||
it('resolves the files an entry imports, transitively, without bundling any content', function () {
|
||||
$files = Stylesheets::resolvedFiles([stylesheetsBundlePath('components/split-button.css')]);
|
||||
|
||||
@@ -93,7 +34,7 @@ it('resolves the files an entry imports, transitively, without bundling any cont
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves past what an application entry imports and bundle() refuses, without throwing', function () {
|
||||
it('resolves past what an application entry imports and only a Vite build resolves, without throwing', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put($dir.'/theme.css', '.theme {}');
|
||||
@@ -114,131 +55,6 @@ it('resolves past what an application entry imports and bundle() refuses, withou
|
||||
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.css']);
|
||||
});
|
||||
|
||||
it('keeps a file two others import once, at its first position', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
|
||||
File::put("{$dir}/shared.css", "@layer material.reset;\n.shared { color: green; }\n");
|
||||
File::put("{$dir}/a.css", "@import './shared.css';\n.a { color: red; }\n");
|
||||
File::put("{$dir}/b.css", "@import './shared.css';\n.b { color: blue; }\n");
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/a.css", "{$dir}/b.css"]);
|
||||
|
||||
expect(substr_count($bundled, '.shared { color: green; }'))->toBe(1)
|
||||
->and(strpos($bundled, '.shared'))->toBeLessThan(strpos($bundled, '.a { color: red; }'))
|
||||
->and($bundled)->toContain('.b { color: blue; }')
|
||||
->and(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('inlines every plain import form once, and nothing written in a comment or a string', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory("{$dir}/components", recursive: true);
|
||||
|
||||
foreach (['double', 'quoted-url', 'bare-url'] as $name) {
|
||||
// A leading byte-order mark and @charset mean nothing past a stylesheet's first byte.
|
||||
File::put("{$dir}/components/{$name}.css", "\u{FEFF}@charset \"UTF-8\";\n.{$name} {}\n");
|
||||
}
|
||||
|
||||
File::put("{$dir}/entry.css", <<<'CSS'
|
||||
/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */
|
||||
@layer a, b;
|
||||
@import "./components/double.css";
|
||||
@import url('./components/quoted-url.css');
|
||||
@IMPORT url( ./components/bare-url.css );
|
||||
@import './components/../components/double.css';
|
||||
.entry::before { content: "@import './components/never.css'; url(never.png)"; }
|
||||
CSS);
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/entry.css"]);
|
||||
|
||||
expect(substr_count($bundled, '.double {}'))->toBe(1)
|
||||
->and($bundled)->toContain('.quoted-url {}')
|
||||
->toContain('.bare-url {}')
|
||||
->toContain("/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */")
|
||||
->toContain(".entry::before { content: \"@import './components/never.css'; url(never.png)\"; }")
|
||||
->not->toContain('@charset')
|
||||
->not->toContain("\u{FEFF}")
|
||||
// The two left are the comment's and the string's.
|
||||
->and(substr_count(strtolower($bundled), '@import'))->toBe(2);
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws, naming the file, for an import the bundle could not inline without changing its meaning', function (string $css, string $reason) {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put("{$dir}/x.css", '.x {}');
|
||||
File::put("{$dir}/entry.css", $css);
|
||||
|
||||
try {
|
||||
Stylesheets::bundle(["{$dir}/entry.css"]);
|
||||
|
||||
$this->fail("Stylesheets::bundle() should have thrown for {$css}");
|
||||
} catch (RuntimeException $exception) {
|
||||
expect($exception->getMessage())->toContain('entry.css')->toContain($reason);
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
})->with([
|
||||
'a layer() condition' => ["@import './x.css' layer(x);", 'without layer()'],
|
||||
'a supports() condition' => ["@import './x.css' supports(display: grid);", 'without layer()'],
|
||||
'a media condition' => ['@import url(./x.css) screen;', 'without layer()'],
|
||||
'an absolute URL' => ["@import 'https://example.com/x.css';", 'absolute URL'],
|
||||
'an absolute path' => ["@import '/x.css';", 'absolute URL'],
|
||||
'a package specifier' => ["@import 'tailwindcss';", '"tailwindcss", which does not exist'],
|
||||
'an import after a rule' => [".a {}\n@import './x.css';", 'after a rule'],
|
||||
'an import inside a block' => ["@layer b { @import './x.css'; }", 'inside a block'],
|
||||
]);
|
||||
|
||||
it('rewrites a relative url() against the entry file\'s directory, or against $base when given', function () {
|
||||
// Mirrors resources/css/'s own shape: an entry (all.css) sitting where the bundle is "from",
|
||||
// a component one level under it (components/menu.css), and an asset one level above that
|
||||
// again (resources/fonts/, resources/svg/, siblings of resources/css/) — so a url() two
|
||||
// directories away from the file that wrote it lands one directory away from the entry.
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory("{$dir}/css/components", recursive: true);
|
||||
File::makeDirectory("{$dir}/fonts");
|
||||
|
||||
File::put("{$dir}/css/entry.css", "@import './components/widget.css';\n");
|
||||
File::put("{$dir}/css/components/widget.css", <<<'CSS'
|
||||
@font-face { src: url('../../fonts/widget.woff2') format('woff2'); }
|
||||
.widget { background: url("icon.svg"), url(https://example.com/a.png), url(#gradient), url(data:image/png;base64,AA==); }
|
||||
CSS);
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/css/entry.css"]);
|
||||
|
||||
// Resolved against the entry's own directory (css/) when no $base is given: the font
|
||||
// climbs out of css/ to the sibling fonts/ directory, the same shape resources/css/ has
|
||||
// relative to resources/fonts/; the icon, sitting beside widget.css, stays inside components/.
|
||||
expect($bundled)->toContain("url('../fonts/widget.woff2')")
|
||||
->toContain('url("components/icon.svg")')
|
||||
// An absolute URL, a # fragment and a data: URI are left exactly as written.
|
||||
->toContain('url(https://example.com/a.png)')
|
||||
->toContain('url(#gradient)')
|
||||
->toContain('url(data:image/png;base64,AA==)');
|
||||
|
||||
// $base stands for wherever the entry itself (the thing "resources/css/" is here) is
|
||||
// served from: the icon, which never left that directory, lands right under $base, while
|
||||
// the font's one directory climbed above the entry the same way it did with no $base at
|
||||
// all, so it climbs one directory above $base too — off the end of "assets", not inside it.
|
||||
Stylesheets::resetCache();
|
||||
$withBase = Stylesheets::bundle(["{$dir}/css/entry.css"], 'https://cdn.example.com/assets');
|
||||
|
||||
expect($withBase)->toContain("url('https://cdn.example.com/fonts/widget.woff2')")
|
||||
->toContain('url("https://cdn.example.com/assets/components/icon.svg")')
|
||||
->toContain('url(https://example.com/a.png)');
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
|
||||
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
|
||||
@@ -252,7 +68,7 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
|
||||
$walk = function (string $file, array $stack) use (&$walk, &$edges): void {
|
||||
expect(in_array($file, $stack, true))->toBeFalse('import cycle: '.implode(' → ', array_map(basename(...), [...$stack, $file])));
|
||||
|
||||
preg_match_all("/@import\\s+'([^']+)'/", stylesheetsBundleWithoutComments(File::get($file)), $matches);
|
||||
preg_match_all("/@import\\s+'([^']+)'/", ComponentStylesheet::withoutComments(File::get($file)), $matches);
|
||||
|
||||
foreach ($matches[1] as $import) {
|
||||
$edges++;
|
||||
@@ -265,84 +81,32 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
|
||||
expect($edges)->toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('does not loop on a cycle, and keeps both files\' rules', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
/**
|
||||
* resources/dist/ is committed so applications need no Node; a stylesheet change without
|
||||
* `npm run build:stylesheets` would leave the showcase and the error pages serving the old rules.
|
||||
*/
|
||||
it('keeps the prebuilt stylesheets in resources/dist/ in step with resources/css/', function () {
|
||||
$node = config('livewire-material.node', 'node');
|
||||
|
||||
File::put("{$dir}/a.css", "@import './b.css';\n.a {}\n");
|
||||
File::put("{$dir}/b.css", "@import './a.css';\n.b {}\n");
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/a.css"]);
|
||||
|
||||
expect($bundled)->toContain('.a {}')->toContain('.b {}');
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
if (Process::run([$node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/vite')) {
|
||||
$this->markTestSkipped("Node ({$node}) or Vite is missing; `npm run build:stylesheets` builds resources/dist/.");
|
||||
}
|
||||
});
|
||||
|
||||
it('throws naming the importer when an import does not exist', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
|
||||
File::put("{$dir}/broken.css", "@import './missing.css';\n");
|
||||
$outDir = sys_get_temp_dir().'/livewire-material-dist-'.Str::random(8);
|
||||
|
||||
try {
|
||||
Stylesheets::bundle(["{$dir}/broken.css"]);
|
||||
$result = Process::run([$node, __DIR__.'/../../bin/stylesheets.mjs', $outDir]);
|
||||
|
||||
$this->fail('Stylesheets::bundle() should have thrown.');
|
||||
} catch (RuntimeException $exception) {
|
||||
expect($exception->getMessage())->toContain('broken.css')->toContain('missing.css');
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
foreach (File::files($outDir) as $built) {
|
||||
$committed = __DIR__.'/../../resources/dist/'.$built->getFilename();
|
||||
|
||||
expect(is_file($committed) ? File::get($committed) : null)
|
||||
->toBe($built->getContents(), "resources/dist/{$built->getFilename()} is stale: run `npm run build:stylesheets`");
|
||||
}
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('caches per file list and mtime, and resetCache() forces a fresh read', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put("{$dir}/cached.css", '.before {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_000);
|
||||
|
||||
try {
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))->toBe('.before {}');
|
||||
|
||||
// The mtime is unchanged, so the cache serves the old content even though the file itself
|
||||
// now holds something else.
|
||||
File::put("{$dir}/cached.css", '.changed-but-same-mtime {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_000);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
||||
->toBe('.before {}', 'an unchanged mtime should still be served from the cache');
|
||||
|
||||
Stylesheets::resetCache();
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
||||
->toBe('.changed-but-same-mtime {}', 'resetCache() should force a fresh read');
|
||||
|
||||
// A later mtime invalidates the cache on its own, with no resetCache() needed.
|
||||
File::put("{$dir}/cached.css", '.after {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_100);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
||||
->toBe('.after {}', 'a changed mtime should invalidate the cache on its own');
|
||||
|
||||
// So does a later mtime on a file the bundle reached only through an @import — a changed
|
||||
// button.css must not leave a cached all.css bundle standing.
|
||||
File::put("{$dir}/entry.css", "@import './cached.css';\n");
|
||||
touch("{$dir}/entry.css", 1_700_000_000);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/entry.css"]))->toContain('.after {}');
|
||||
|
||||
File::put("{$dir}/cached.css", '.nested-change {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_200);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/entry.css"]))
|
||||
->toContain('.nested-change {}')
|
||||
->and(Stylesheets::bundle(["{$dir}/entry.css"], ''))->toContain('.nested-change {}');
|
||||
} finally {
|
||||
Stylesheets::resetCache();
|
||||
File::deleteDirectory($dir);
|
||||
File::deleteDirectory($outDir);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -398,11 +162,11 @@ function stylesheetsBundleLeafRules(string $css): array
|
||||
}
|
||||
|
||||
/**
|
||||
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler
|
||||
* (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph): `all.css`, and
|
||||
* tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside the package, the
|
||||
* foundation, then split button, app bar, modal and pagination, which each import button.css, and
|
||||
* button.css once more.
|
||||
* The Vite deduplication the prebuilt stylesheets and every application's build rely on, pinned
|
||||
* against the real bundler (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph):
|
||||
* `all.css`, and tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside
|
||||
* the package, the foundation, then split button, app bar, modal and pagination, which each
|
||||
* import button.css, and button.css once more.
|
||||
*
|
||||
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
|
||||
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
||||
@@ -18,87 +16,14 @@ use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
* below run over the whole tree `all.css` reaches (foundation, layout and components together)
|
||||
* rather than the foundation alone, and — one file it does not reach — `showcase.css`, checked on
|
||||
* its own further down. A component's own values (colours as roles, shadows as elevation levels,
|
||||
* and so on) are each group's own concern, in tests/Feature/Components/*StylesheetsTest.php;
|
||||
* `bundle()`, the PHP bundler `all.css` is built for, is tests/Feature/StylesheetsBundleTest.php's.
|
||||
* and so on) are ComponentStylesheetsTest's own concern.
|
||||
* The prebuilt bundles in resources/dist/ are tests/Feature/StylesheetsBundleTest.php's.
|
||||
*/
|
||||
const MATERIAL_LAYER_STATEMENT = '@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;';
|
||||
|
||||
function stylesheetPath(string $path = ''): string
|
||||
{
|
||||
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A stylesheet without its comments; a quoted string keeps whatever it holds.
|
||||
*/
|
||||
function stylesheetWithoutComments(string $css): string
|
||||
{
|
||||
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level statements (`@layer …;`, `@import …;`) and blocks of a stylesheet, in order, with
|
||||
* whitespace collapsed in statements and preludes.
|
||||
*
|
||||
* @return list<array{statement: string}|array{prelude: string, body: string}>
|
||||
*/
|
||||
function stylesheetItems(string $css): array
|
||||
{
|
||||
$css = stylesheetWithoutComments($css);
|
||||
$items = [];
|
||||
$start = 0;
|
||||
$depth = 0;
|
||||
$quote = null;
|
||||
$opening = 0;
|
||||
|
||||
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
|
||||
$char = $css[$i];
|
||||
|
||||
if ($quote !== null) {
|
||||
if ($char === '\\') {
|
||||
$i++;
|
||||
} elseif ($char === $quote) {
|
||||
$quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"' || $char === "'") {
|
||||
$quote = $char;
|
||||
} elseif ($char === '{') {
|
||||
if ($depth++ === 0) {
|
||||
$opening = $i;
|
||||
}
|
||||
} elseif ($char === '}' && --$depth === 0) {
|
||||
$items[] = [
|
||||
'prelude' => (string) preg_replace('/\s+/', ' ', trim(substr($css, $start, $opening - $start))),
|
||||
'body' => substr($css, $opening + 1, $i - $opening - 1),
|
||||
];
|
||||
$start = $i + 1;
|
||||
} elseif ($char === ';' && $depth === 0) {
|
||||
$items[] = ['statement' => preg_replace('/\s+/', ' ', trim(substr($css, $start, $i - $start))).';'];
|
||||
$start = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(trim(substr($css, $start)))->toBe('', 'The stylesheet ends inside an unclosed rule.');
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declarations of a flat block body, by property.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function stylesheetDeclarations(string $body): array
|
||||
{
|
||||
return collect(explode(';', $body))
|
||||
->map(fn (string $declaration): string => trim($declaration))
|
||||
->filter()
|
||||
->mapWithKeys(fn (string $declaration): array => [trim(strstr($declaration, ':', true)) => trim(substr(strstr($declaration, ':'), 1))])
|
||||
->all();
|
||||
return (string) realpath(ComponentStylesheet::cssPath($path));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,13 +31,13 @@ function stylesheetDeclarations(string $body): array
|
||||
*/
|
||||
function stylesheetBlock(string $css, string $prelude): string
|
||||
{
|
||||
foreach (stylesheetItems($css) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||||
if (($item['prelude'] ?? null) === $prelude) {
|
||||
return $item['body'];
|
||||
}
|
||||
|
||||
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
|
||||
foreach (stylesheetItems($item['body']) as $inner) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body'])) as $inner) {
|
||||
if (($inner['prelude'] ?? null) === $prelude) {
|
||||
return $inner['body'];
|
||||
}
|
||||
@@ -130,8 +55,8 @@ function stylesheetBlock(string $css, string $prelude): string
|
||||
*/
|
||||
function stylesheetImports(string $file): array
|
||||
{
|
||||
return collect(stylesheetItems(File::get($file)))
|
||||
->map(fn (array $item): ?string => preg_match('/^@import\s+([\'"])(.+?)\1/', $item['statement'] ?? '', $match) === 1 ? $match[2] : null)
|
||||
return collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))))
|
||||
->map(fn (array $item): ?string => ComponentStylesheet::importPath($item['statement'] ?? ''))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
@@ -205,8 +130,10 @@ it('brings every foundation file and text.css into the foundation, and nothing e
|
||||
|
||||
expect($parts->diff($foundation)->values()->all())->toBe([])
|
||||
->and(array_map(fn (string $import): string => basename($import), stylesheetImports(stylesheetPath('foundation.css'))))
|
||||
// In the order of their layers, the rules outside every layer beside the reset.
|
||||
->toBe(['reset.css', 'hidden.css', 'tokens.css', 'base.css', 'interaction.css', 'text.css'])
|
||||
// In the order of their layers, the rules outside every layer beside the reset — the seven
|
||||
// token files imported directly (plan step 33: foundation/tokens.css held only these
|
||||
// imports, and only foundation.css reached it, so it was folded in here).
|
||||
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.css', 'base.css', 'interaction.css', 'text.css'])
|
||||
->and($foundation)
|
||||
->toContain('tokens/scheme.css', 'tokens/shape.css', 'tokens/elevation.css', 'tokens/motion.css', 'tokens/type.css', 'tokens/state.css', 'tokens/spacing.css', 'tokens/font.css')
|
||||
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
|
||||
@@ -253,7 +180,7 @@ it('leaves no stylesheet under resources/css/ outside all.css or showcase.css',
|
||||
it('opens every stylesheet all.css reaches with a header, the layer statement and plain imports', function () {
|
||||
foreach (allStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
$items = stylesheetItems(File::get($file));
|
||||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file)));
|
||||
|
||||
expect(File::get($file))->toStartWith('/*', "{$name} has no header comment")
|
||||
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, "{$name} does not open with the layer statement");
|
||||
@@ -275,12 +202,11 @@ it('opens every stylesheet all.css reaches with a header, the layer statement an
|
||||
});
|
||||
|
||||
it('keeps every foundation rule inside its material layer, but the two that hide', function () {
|
||||
// Which layer each file writes into; tokens.css only imports.
|
||||
// Which layer each file writes into.
|
||||
$layers = [
|
||||
'foundation.css' => null,
|
||||
'foundation/reset.css' => 'material.reset',
|
||||
'foundation/hidden.css' => null,
|
||||
'foundation/tokens.css' => null,
|
||||
'foundation/base.css' => 'material.base',
|
||||
'foundation/interaction.css' => 'material.base',
|
||||
'text.css' => 'material.text',
|
||||
@@ -293,15 +219,15 @@ it('keeps every foundation rule inside its material layer, but the two that hide
|
||||
foreach (foundationStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
$layer = array_key_exists($name, $layers) ? $layers[$name] : 'material.tokens';
|
||||
$important += substr_count(stylesheetWithoutComments(File::get($file)), '!important');
|
||||
$important += substr_count(ComponentStylesheet::withoutComments(File::get($file)), '!important');
|
||||
|
||||
foreach (stylesheetItems(File::get($file)) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))) as $item) {
|
||||
if (! isset($item['prelude'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($item['prelude'], '@layer ')) {
|
||||
$unlayered["{$name}: {$item['prelude']}"] = stylesheetDeclarations($item['body']);
|
||||
$unlayered["{$name}: {$item['prelude']}"] = ComponentStylesheet::flatDeclarations($item['body']);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -321,7 +247,7 @@ it('keeps every foundation rule inside its material layer, but the two that hide
|
||||
it('defines x-cloak once, reached from both the foundation and all.css', function () {
|
||||
$definitions = collect(File::allFiles(stylesheetPath()))
|
||||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
|
||||
->filter(fn (SplFileInfo $file): bool => str_contains(stylesheetWithoutComments($file->getContents()), '[x-cloak]'))
|
||||
->filter(fn (SplFileInfo $file): bool => str_contains(ComponentStylesheet::withoutComments($file->getContents()), '[x-cloak]'))
|
||||
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
|
||||
->values()
|
||||
->all();
|
||||
@@ -333,37 +259,21 @@ it('defines x-cloak once, reached from both the foundation and all.css', functio
|
||||
|
||||
it('writes no Tailwind directive anywhere all.css reaches', function () {
|
||||
foreach (allStylesheets() as $file) {
|
||||
expect(stylesheetWithoutComments(File::get($file)))
|
||||
expect(ComponentStylesheet::withoutComments(File::get($file)))
|
||||
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', stylesheetName($file))
|
||||
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', stylesheetName($file));
|
||||
}
|
||||
});
|
||||
|
||||
it('writes no Tailwind utility class anywhere under resources/ or tests/', function () {
|
||||
// DesignGuard's own family table (check (i)) is the standing definition of a Tailwind
|
||||
// utility or variant — this test is that guard turned on the package's own source, so the
|
||||
// two never drift apart. tests/Feature/DesignGuardTest.php and tests/Fixtures/design-guard/
|
||||
// carry Tailwind on purpose (they are the guard's own fixtures) and are left out.
|
||||
$root = __DIR__.'/../..';
|
||||
$designGuardTest = (string) realpath($root.'/tests/Feature/DesignGuardTest.php');
|
||||
|
||||
$paths = collect(['resources/views', 'resources/js', 'src', 'tests/Browser', 'tests/Feature'])
|
||||
->flatMap(fn (string $directory): Collection => collect(File::allFiles($root.'/'.$directory)))
|
||||
->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'js', 'ts'], true))
|
||||
->reject(fn (SplFileInfo $file): bool => (string) $file->getRealPath() === $designGuardTest)
|
||||
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($paths)->not->toBeEmpty();
|
||||
|
||||
expect(DesignGuard::scan($paths)->violations())->toBe([]);
|
||||
});
|
||||
// No Tailwind-shaped class anywhere the package ships or tests itself is
|
||||
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
|
||||
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
|
||||
// either.
|
||||
|
||||
it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, in px', function () {
|
||||
$queries = collect(allStylesheets())
|
||||
->flatMap(function (string $file): array {
|
||||
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments(File::get($file)), $matches);
|
||||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments(File::get($file)), $matches);
|
||||
|
||||
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
|
||||
});
|
||||
@@ -376,9 +286,9 @@ it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, i
|
||||
$feature = substr($query, strpos($query, ': ') + 2);
|
||||
|
||||
// The one named exception (docs/reference/m3/components-navigation-selection-inputs.md §
|
||||
// Time pickers/Behaviour, and tests/Feature/Components/InputStylesheetsTest.php's
|
||||
// assertBreakpointsInPx()): a viewport *height* in an `orientation` query is not a
|
||||
// breakpoint, since M3 does not make one of it. A width, in any query, still is.
|
||||
// Time pickers/Behaviour, and tests/Feature/Components/ComponentStylesheetsTest.php's
|
||||
// token-values test): a viewport *height* in an `orientation` query is not a breakpoint,
|
||||
// since M3 does not make one of it. A width, in any query, still is.
|
||||
$orientationHeight = preg_match('/\(\s*orientation\s*:/', $feature) === 1
|
||||
&& preg_match('/(?:^|[\s:<>=(])(?:min-|max-)?height\b/', $feature) === 1
|
||||
&& ! str_contains($feature, 'width');
|
||||
@@ -408,7 +318,7 @@ it('declares M3\'s spacing scale as the reference gives it', function () {
|
||||
);
|
||||
|
||||
$reference = collect($rows)->mapWithKeys(fn (array $row): array => ["--md-sys-measurement-space{$row[1]}" => "{$row[3]}px"])->all();
|
||||
$tokens = stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
|
||||
$tokens = ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
|
||||
|
||||
expect($reference)->toHaveCount(13)
|
||||
->and($tokens)->toBe($reference);
|
||||
@@ -495,11 +405,11 @@ it('gives every component view a stylesheet, or lists it on a short, documented
|
||||
});
|
||||
|
||||
/**
|
||||
* Every component view the four group stylesheet tests (Action/Input/Containment/Navigation) used
|
||||
* to check one at a time, in one dataset instead: every `resources/views/components/*.blade.php`
|
||||
* backed by a `components/*.css` file of its own, `tabs` and `tab` folded into the one entry
|
||||
* `imports…()` below already special-cases (they share tabs.css, N-16), `theme-script` (no
|
||||
* stylesheet at all) and every layout component (the test above covers those) left out.
|
||||
* Every component view ComponentStylesheetsTest used to check one at a time, in one dataset
|
||||
* instead: every `resources/views/components/*.blade.php` backed by a `components/*.css` file of
|
||||
* its own, `tabs` and `tab` folded into the one entry `imports…()` below already special-cases
|
||||
* (they share tabs.css), `theme-script` (no stylesheet at all) and every layout component (the
|
||||
* test above covers those) left out.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -517,17 +427,34 @@ function componentStylesheetViews(): array
|
||||
|
||||
dataset('component stylesheet views', componentStylesheetViews());
|
||||
|
||||
/**
|
||||
* The `<x-livewire-material::…>` tags a view renders, including any inside a
|
||||
* `@include('livewire-material::partials.<name>', …)` it pulls in — markup a view moved out to
|
||||
* resources/views/partials/<name>.blade.php (menu's filter field, the timepicker's period picker)
|
||||
* still renders the same tags, just not inline where a plain regex over the view alone would see
|
||||
* them.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function renderedComponentTags(string $view): array
|
||||
{
|
||||
$content = File::get($view);
|
||||
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', $content, $tags);
|
||||
preg_match_all('/@include\(\'livewire-material::partials\.([\w.-]+)\'/', $content, $partials);
|
||||
|
||||
return collect($tags[1])
|
||||
->merge(collect($partials[1])->flatMap(fn (string $partial): array => renderedComponentTags(__DIR__."/../../resources/views/partials/{$partial}.blade.php")))
|
||||
->all();
|
||||
}
|
||||
|
||||
it('imports, from every component stylesheet, the stylesheet of each component its view renders', function (string $name) {
|
||||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php (N-16); every other
|
||||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php; every other
|
||||
// component stylesheet in this dataset has exactly one view of its own name.
|
||||
$views = $name === 'tabs' ? ['tabs', 'tab'] : [$name];
|
||||
|
||||
$rendered = collect($views)
|
||||
->flatMap(function (string $view): array {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../resources/views/components/{$view}.blade.php"), $tags);
|
||||
|
||||
return $tags[1];
|
||||
})
|
||||
->flatMap(fn (string $view): array => renderedComponentTags(__DIR__."/../../resources/views/components/{$view}.blade.php"))
|
||||
->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.
|
||||
@@ -607,7 +534,7 @@ it('imports, from every layout stylesheet, the stylesheet of each component its
|
||||
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
|
||||
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
|
||||
// file draws on (pane.css: app-bar.css and button.css, for its own top app bar and back
|
||||
// button) — each already checked, in material.components, by its own group's stylesheet test.
|
||||
// button) — each already checked, in material.components, by ComponentStylesheetsTest.
|
||||
foreach (layoutStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
|
||||
@@ -621,9 +548,9 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
|
||||
default => 'material.layout',
|
||||
};
|
||||
|
||||
expect(stylesheetWithoutComments($css))->not->toContain('!important', $name);
|
||||
expect(ComponentStylesheet::withoutComments($css))->not->toContain('!important', $name);
|
||||
|
||||
foreach (stylesheetItems($css) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||||
if (! isset($item['prelude'])) {
|
||||
continue;
|
||||
}
|
||||
@@ -637,21 +564,21 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
|
||||
it('gives every spacing token a gap and a padding, and the layout components read only those', function () {
|
||||
$tokens = array_map(
|
||||
fn (string $property): string => substr($property, strlen('--md-sys-measurement-')),
|
||||
array_keys(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
|
||||
array_keys(ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
|
||||
);
|
||||
|
||||
expect(Layout::SPACING)->toBe($tokens);
|
||||
|
||||
$spacing = File::get(stylesheetPath('layout/spacing.css'));
|
||||
|
||||
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||||
->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||||
->and(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||||
}
|
||||
|
||||
expect(collect(stylesheetItems(stylesheetBlock($spacing, '@layer material.layout')))->pluck('prelude')->all())
|
||||
expect(collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($spacing, '@layer material.layout'))))->pluck('prelude')->all())
|
||||
->toHaveCount(1 + 2 * count($tokens));
|
||||
});
|
||||
|
||||
@@ -668,10 +595,10 @@ it('hides an element below and from every breakpoint but compact, and nothing el
|
||||
$expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']";
|
||||
}
|
||||
|
||||
$actual = collect(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
|
||||
$rule = stylesheetItems($item['body'])[0];
|
||||
$actual = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments($visibility)))->mapWithKeys(function (array $item): array {
|
||||
$rule = ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body']))[0];
|
||||
|
||||
expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||
expect(ComponentStylesheet::flatDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||
|
||||
return [$item['prelude'] => $rule['prelude']];
|
||||
})->all();
|
||||
@@ -686,7 +613,7 @@ it('gives every md-type class exactly font, letter-spacing and font-variation-se
|
||||
// asserted directly rather than cross-checked against a second copy.
|
||||
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
|
||||
|
||||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
|
||||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
|
||||
|
||||
expect($classes)->toHaveCount(30);
|
||||
|
||||
@@ -730,18 +657,18 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
|
||||
],
|
||||
];
|
||||
|
||||
$classes = collect(stylesheetItems(stylesheetBlock($text, '@layer material.text')))
|
||||
$classes = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($text, '@layer material.text'))))
|
||||
->map(fn (array $item): string => $item['prelude'] ?? $item['statement'])
|
||||
->all();
|
||||
|
||||
expect($classes)->toBe($styles->merge(array_keys($inks))->merge(array_keys($rest))->map(fn (string $class): string => ".{$class}")->all());
|
||||
|
||||
foreach ($inks as $class => $role) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
|
||||
}
|
||||
|
||||
foreach ($rest as $class => $declarations) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -754,7 +681,7 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
|
||||
// file keeps to.
|
||||
|
||||
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
|
||||
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||||
$app = ComponentStylesheet::withoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||||
|
||||
// Opens by ordering the layers, so the order holds whichever the page links first — an
|
||||
// application with Tailwind of its own still opens its own entry the same way (its header).
|
||||
@@ -775,11 +702,11 @@ it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left
|
||||
it('shapes showcase.css like a package stylesheet, with its documented unlayered exceptions', function () {
|
||||
$path = __DIR__.'/../../resources/css/showcase.css';
|
||||
$css = File::get($path);
|
||||
$items = stylesheetItems($css);
|
||||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($css));
|
||||
|
||||
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
|
||||
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement')
|
||||
->and(stylesheetWithoutComments($css))
|
||||
->and(ComponentStylesheet::withoutComments($css))
|
||||
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
|
||||
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
|
||||
|
||||
@@ -814,7 +741,7 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
|
||||
// Bundled with all.css on every showcase page, so it styles its own hooks and classes only,
|
||||
// and each of those is still drawn by some showcase view.
|
||||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', stylesheetWithoutComments($css)), $preludes);
|
||||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', ComponentStylesheet::withoutComments($css)), $preludes);
|
||||
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
|
||||
|
||||
foreach ($preludes[1] as $prelude) {
|
||||
@@ -823,13 +750,13 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
}
|
||||
}
|
||||
|
||||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', stylesheetWithoutComments($css), $hooks);
|
||||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', ComponentStylesheet::withoutComments($css), $hooks);
|
||||
|
||||
foreach (array_unique($hooks[0]) as $hook) {
|
||||
expect(str_contains($views, ltrim($hook, '.')))->toBeTrue("showcase.css draws `{$hook}`, which no showcase view renders");
|
||||
}
|
||||
|
||||
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments($css), $matches);
|
||||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments($css), $matches);
|
||||
|
||||
foreach ($matches[1] as $query) {
|
||||
$feature = trim($query);
|
||||
@@ -845,17 +772,17 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps all.css under a gzip size budget, measured through Stylesheets::bundle()', function () {
|
||||
// 106,838 bytes gzipped today (2026-09-15), rounded up by about 10% to a number CI can hold
|
||||
// to without Node: PHP's own gzencode() on the same bytes Stylesheets::bundle() serves the
|
||||
// showcase and the error page's fallback. A little organic growth should not retrigger this
|
||||
// on every commit; a real jump means look at what grew since the budget was last raised.
|
||||
$budget = 120_000;
|
||||
it('keeps the showcase bundle, all.css with showcase.css, under a gzip size budget', function () {
|
||||
// 40,167 bytes gzipped today (2026-09-17), measured with PHP's own gzencode() on
|
||||
// resources/dist/showcase.css, the prebuilt bundle the showcase serves, comments stripped. A
|
||||
// little organic growth should not retrigger this on every commit; a real jump means look at
|
||||
// what grew since the budget was last raised.
|
||||
$budget = 44_000;
|
||||
|
||||
$gzipped = strlen((string) gzencode(Stylesheets::bundle([stylesheetPath('all.css')]), 9));
|
||||
$gzipped = strlen((string) gzencode(File::get(__DIR__.'/../../resources/dist/showcase.css'), 9));
|
||||
|
||||
expect($gzipped)->toBeLessThanOrEqual($budget, sprintf(
|
||||
'all.css now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||||
'The showcase bundle now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||||
number_format($gzipped),
|
||||
number_format($budget),
|
||||
));
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
function packageCss(string $path = ''): string
|
||||
{
|
||||
return __DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path);
|
||||
return ComponentStylesheet::cssPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user