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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user