Cut duplicated and speculative code across the package

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

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

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

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

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

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

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 19:29:21 +02:00
co-authored by Claude Opus 5
parent 471d927e64
commit 247c596c3a
233 changed files with 16635 additions and 10579 deletions
+8 -16
View File
@@ -122,14 +122,12 @@ function menuMorphProbe()
</html>
BLADE));
return visit('/menu-morph-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/menu-morph-probe'));
}
function showcase(string $section = 'buttons')
{
return visit("/material/{$section}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit("/material/{$section}"));
}
function focused(string $expression): string
@@ -308,8 +306,7 @@ it('hangs a menu on its menu button, even when the button is fixed to a corner o
$placed = menuAgainst('fab', 'Create');
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/menu-anchor-probe')->resize(400, 800));
$page->click('@fab')
->assertAttribute('@fab', 'aria-expanded', 'true')
@@ -559,8 +556,7 @@ it('hides a disabled fab button below 600px, as M3 removes a FAB whose action is
return [getComputedStyle(button).display !== 'none' && button.checkVisibility(), getComputedStyle(button).position === 'fixed'];
})()";
$page = visit('/disabled-fab-probe')->resize(393, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/disabled-fab-probe')->resize(393, 800), alpine: false);
expect($page->script($state('enabled-fab')))->toBe([true, true])
->and($page->script($state('disabled-fab')))->toBe([false, true])
@@ -627,8 +623,7 @@ function groupSizeProbe()
</html>
BLADE));
return visit('/group-size-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
return ready(visit('/group-size-probe'), livewire: false);
}
it('takes a press at an xs or sm group segment\'s 48px edge, past what it draws, from the shared classes', function () {
@@ -767,8 +762,7 @@ function longMenuProbe()
</html>
BLADE));
return visit('/long-menu-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
return ready(visit('/long-menu-probe'), livewire: false);
}
function sheetMenuProbe()
@@ -790,8 +784,7 @@ function sheetMenuProbe()
</html>
BLADE));
return visit('/sheet-menu-probe')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/sheet-menu-probe')->resize(400, 800));
}
it('opens a submenu with Right, Enter or Space, and closes it with Left or Escape, back on the item', function () {
@@ -984,8 +977,7 @@ function longFabMenuProbe()
</html>
BLADE));
return visit('/long-fab-menu-probe')->resize(320, 420)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
return ready(visit('/long-fab-menu-probe')->resize(320, 420), livewire: false);
}
it('scrolls the FAB menu\'s items on a short window, behind the close button, which stays put', function () {
+6 -11
View File
@@ -82,8 +82,7 @@ function barsProbe()
</html>
BLADE));
return visit('/bars-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/bars-probe'));
}
it('moves between tabs with the arrow keys, skipping disabled ones, and tells Livewire', function () {
@@ -285,8 +284,7 @@ function appBarOverflowProbe()
</html>
BLADE));
return visit('/app-bar-overflow-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/app-bar-overflow-probe'));
}
it('shows the app bar overflow at exactly 599 and 600px', function () {
@@ -370,8 +368,7 @@ function toolbarScaffoldProbe()
</html>
BLADE));
return visit('/toolbar-scaffold-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/toolbar-scaffold-probe'));
}
it('keeps a docked toolbar clear of the navigation bar below medium', function () {
@@ -414,8 +411,7 @@ it('lifts a snackbar above a floating toolbar placed at the bottom, without a sc
</html>
BLADE));
$page = visit('/floating-toolbar-probe')->resize(393, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/floating-toolbar-probe')->resize(393, 800));
$page->script("materialToast('Link copied', { timeout: 10000 })");
$page->wait(1);
@@ -490,11 +486,10 @@ it('never flashes a tab panel before Alpine boots', function () {
// Only the chosen ("people") panel is on screen at the moment the inline script right after
// the panels runs — the server's own `style="display: none"`, not Alpine's x-show, is what
// kept the other two off screen up to here (N-05).
// kept the other two off screen up to here.
$page = visit('/tabs-first-paint-probe')
->assertScript("window.eval('window.firstPaint.join(\",\")') === 'none,block,none'");
$page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'")
ready($page, livewire: false)
->assertNoJavaScriptErrors();
});
+3 -3
View File
@@ -40,8 +40,8 @@ class CarouselMorphProbe extends Component
* offset that brings item i into focus) and `at(i)`; the script's value is the result. Pest
* retries a failing assertion and gives each attempt a second: keep scripts well inside it.
*
* `open(i)` rather than a zero inset: M3's 16dp of content padding, the carousel's default since
* containment.md C-12, shrinks every keyline near the ends (Compose's
* `open(i)` rather than a zero inset: M3's 16dp of content padding, the carousel's default,
* shrinks every keyline near the ends (Compose's
* createShiftedKeylineListForContentPadding), so a large item resting at the start or the end is
* masked by that share of the padding — 1.33px on each side in the showcase's first carousel.
*/
@@ -188,7 +188,7 @@ it('scrolls instantly and leaves every item unmasked under reduced motion', func
it('brings an item cut off at the row\'s edge into view when it is pressed under reduced motion', function () {
// Owed by the Chromium baseline (step 32, docs/plans/material-3-browser-tests.md): reduced
// motion writes a zero inset for every item (C-05), so a mask cannot say an item is only partly
// motion writes a zero inset for every item, so a mask cannot say an item is only partly
// shown; carousel.js reads the item's box against the row's instead, as for multi-aspect.
$page = carouselShowcase(['reducedMotion' => 'reduce'])
->assertScript(onCarousel(0, <<<'JS'
+9 -11
View File
@@ -1,10 +1,10 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
/**
* The central claims of the `@layer material` design (plan step 42, docs/plans/material-3-alignment.md
@@ -17,8 +17,7 @@ use NoNameWeb\LivewireMaterial\Support\Stylesheets;
*/
function cascadeReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready($page);
}
it('lets an application\'s unlayered rule beat a component default', function () {
@@ -73,14 +72,13 @@ it('lets [hidden] beat a component\'s display', function () {
it('keeps the layer order even when a component file is imported before the foundation', function () {
// Every package stylesheet opens with the same full `@layer` statement (plan step 33), so the
// order it establishes holds whichever file a page reaches first — button.css's own copy of it,
// reached here before foundation.css's. Inlined through Stylesheets::bundle(), the same PHP
// bundler the showcase and the error page's fallback use, in that deliberate order: button.css's
// rules, in `material.components`, physically precede foundation.css's reset, in
// `material.reset` — and still lose the layer priority fight, so the button's own padding
// survives the reset's `padding: 0`.
$css = Stylesheets::bundle([__DIR__.'/../../resources/css/components/button.css'])
.Stylesheets::bundle([__DIR__.'/../../resources/css/foundation.css']);
// order it establishes holds whichever file a page reaches first — the error layout's copy of
// it, reached here before the foundation's. The two prebuilt error-page stylesheets, in that
// deliberate order: the layout's bundle, whose button.css rules sit in `material.components`,
// physically precedes the fallback's foundation reset, in `material.reset` — and still loses
// the layer priority fight, so the button's own padding survives the reset's `padding: 0`.
$css = File::get(__DIR__.'/../../resources/dist/error-page.css')
.File::get(__DIR__.'/../../resources/dist/error-page-fallback.css');
// Served as its own response, not inlined through Blade::render(): CSS's own `@media`,
// `@layer` and `@import` would otherwise reach Blade's directive compiler along with the page.
+3 -6
View File
@@ -68,14 +68,12 @@ function chipProbe()
</html>
BLADE));
return visit('/chip-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/chip-probe'));
}
function chipShowcase()
{
return visit('/material/chips')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/material/chips'));
}
/**
@@ -234,8 +232,7 @@ function chipScrollProbe(string $dir)
</html>
BLADE));
return visit("/chip-scroll-probe?dir={$dir}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
return ready(visit("/chip-scroll-probe?dir={$dir}"), livewire: false);
}
it('shows a scroll button only on the edge the row can still scroll towards, in either direction', function (string $dir) {
+1 -2
View File
@@ -39,8 +39,7 @@ function pagePrimary(): string
function profilesReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready($page);
}
it('draws each profile in light and dark, and the default without the attribute', function () {
+17 -32
View File
@@ -39,14 +39,13 @@ class RichTooltipMorphProbe extends Component
}
}
// The live region is the host itself, in the page before any message is (actions.md ACT-02).
// The live region is the host itself, in the page before any message is.
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"][aria-live]')";
const TOAST = "document.querySelector('[data-md-toast-snackbar]')";
it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
@@ -60,8 +59,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
});
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
// carries a detail object Firefox's sandbox will not let the page read.
@@ -71,8 +69,7 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
});
it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
@@ -83,8 +80,7 @@ it('runs a toast\'s action and dismisses it', function () {
});
it('does not let a toast dismissed early cut the next one short', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->script("window.eval(\"materialToast('Share deleted', { timeout: 800, action: { label: 'Undo' } }); materialToast('Link copied', { timeout: 2500 })\")");
@@ -99,8 +95,7 @@ it('does not let a toast dismissed early cut the next one short', function () {
});
it('keeps a sticky toast aside while a passing one shows, brings back the newest, and lets it go once dismissed', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
// As an application dispatches it; built in the page's realm (see above).
$page->script("window.eval(\"window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, timeout: 300, action: { label: 'Reload', event: 'material:test-reload' } } }))\")");
@@ -130,8 +125,7 @@ it('keeps a sticky toast aside while a passing one shows, brings back the newest
});
it('dispatches a toast action\'s window event alongside its handler, and closes', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")");
@@ -142,10 +136,9 @@ it('dispatches a toast action\'s window event alongside its handler, and closes'
});
it('keeps an actioned snackbar on screen past the default timeout, until it is acted on', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
// No timeout written out: M3 forbids an actioned snackbar from auto-dismissing (ACT-03).
// No timeout written out: M3 forbids an actioned snackbar from auto-dismissing.
$page->script("materialToast('Share deleted', { action: { label: 'Undo', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Share deleted')")
@@ -157,8 +150,7 @@ it('keeps an actioned snackbar on screen past the default timeout, until it is a
});
it('dismisses a focused snackbar with Escape', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->script("materialToast('Share deleted', { action: { label: 'Undo', handler: () => {} } })");
@@ -171,8 +163,7 @@ it('dismisses a focused snackbar with Escape', function () {
});
it('keeps the snackbar\'s live region in the page, polite, before any message arrives', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->assertScript(SNACKBAR.' !== null')
->assertScript(SNACKBAR."?.getAttribute('aria-live') === 'polite'")
@@ -181,8 +172,7 @@ it('keeps the snackbar\'s live region in the page, polite, before any message ar
});
it('grows to M3\'s two-line height with a description, and Alt+G reaches its action from anywhere on the page', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
$page->script("materialToast('Upload paused', { description: 'The connection dropped at 64%.', action: { label: 'Retry now', handler: () => {} } })");
@@ -198,8 +188,7 @@ it('grows to M3\'s two-line height with a description, and Alt+G reaches its act
});
it('keeps a two-line snackbar\'s action on the title\'s row from 600px, wrapping under it only below that', function () {
$page = visit('/material/communication')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication')->resize(400, 800));
$page->script("materialToast('Upload paused', { description: 'The connection dropped at 64%.', action: { label: 'Retry now', handler: () => {} } })");
@@ -220,8 +209,7 @@ it('keeps a two-line snackbar\'s action on the title\'s row from 600px, wrapping
it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
ready(visit('/material/communication'))
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')");
});
@@ -230,8 +218,7 @@ it('closes a transient rich tooltip 1.5s after focus leaves, not at once', funct
$trigger = '#communication button[aria-label="About expiry"]';
$bubble = "document.querySelector('#communication [role=\"tooltip\"][popover]')";
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
// A key press first, so the browser is in keyboard modality, as a keyboard user would be.
$page->keys('#communication', 'Tab');
@@ -272,8 +259,7 @@ it('keeps a rich tooltip open while its action renders the component, and opens
$persistent = "document.querySelector('[role=\"dialog\"][popover]')";
$transient = "document.querySelector('[role=\"tooltip\"][popover]')";
$page = visit('/rich-tooltip-morph-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
$page = ready(visit('/rich-tooltip-morph-probe'))
->assertNoJavaScriptErrors();
$page->click('@details')
@@ -300,8 +286,7 @@ it('keeps a rich tooltip open while its action renders the component, and opens
it('fades a persistent rich tooltip out when a second press closes it', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/communication'));
// Slow tokens, so the exit copy (resources/js/popover-exit.js) is still on screen after the round trip.
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: 1500ms; }</style>')");
+60 -103
View File
@@ -104,8 +104,7 @@ function collapseProbe()
</html>
BLADE));
return visit('/collapse-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/collapse-probe'));
}
function overlayProbe()
@@ -127,14 +126,12 @@ function overlayProbe()
</html>
BLADE));
return visit('/overlay-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/overlay-probe'));
}
function containment()
{
return visit('/material/containment')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/material/containment'));
}
it('writes back what closed means: false for a flag, null for an id', function () {
@@ -265,8 +262,7 @@ function nestedDialogProbe()
</html>
BLADE));
return visit('/nested-dialog-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/nested-dialog-probe'));
}
it('marks a scrolling dialog\'s divider at the edge with more content, and a nested dialog keeps its own marks', function () {
@@ -356,8 +352,7 @@ it('opens a dialog with no fade under reduced motion, but a real one without it'
// With reduced motion (the plugin's own emulation, `visit(..., ['reducedMotion' => 'reduce'])`):
// tokens/motion.css takes the same spring's duration to zero, so there is no transition to run,
// and the dialog is at its final opacity at once rather than mid-fade.
$reduced = visit('/material/containment', ['reducedMotion' => 'reduce'])->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
$reduced = ready(visit('/material/containment', ['reducedMotion' => 'reduce']))
->assertScript("window.matchMedia('(prefers-reduced-motion: reduce)').matches");
$reduced->click('#containment button:has-text("Basic dialog")');
@@ -403,8 +398,7 @@ function textOnlyDialogProbe()
</html>
BLADE));
return visit('/text-only-dialog-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/text-only-dialog-probe'));
}
it('gives the scrolling body an inset focus ring when a text-only dialog opens from the keyboard', function () {
@@ -634,8 +628,7 @@ function layersProbe(): mixed
</html>
BLADE));
return visit('/layers-probe')->resize(1000, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
return ready(visit('/layers-probe')->resize(1000, 800), livewire: false);
}
function sheetOpen(string $id): string
@@ -963,12 +956,39 @@ it('binds a collapse to an Alpine property both ways', function () {
->assertScript("{$collapse}.open === false");
});
it('fades the side sheet\'s scrim out on close, rather than making it vanish', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
/**
* The side sheet and the bottom sheet close the same way — a fade on the scrim, a slide on the
* sheet itself — differing only in the selector each renders under, the click that opens it, the
* scrim's own hook attribute, the dimension it slides across, the `translate` component that
* carries the offset (a single value for the side sheet's horizontal slide, the second of a
* `translate: x y` pair for the bottom sheet's vertical one) and whether that offset is read
* signed: the side sheet's can slide either way (a right-to-left page), so its mid-slide sample
* takes `Math.abs()`; the bottom sheet only ever slides down, so its own stays signed.
*/
dataset('containment sheets', [
'side sheet' => [
"document.querySelector('#containment aside[role=\"dialog\"]')",
'#containment button:has-text("Side sheet")',
'data-md-drawer-scrim',
'width',
'',
true,
],
'bottom sheet' => [
"document.querySelector('#containment section[role=\"dialog\"]')",
'#containment button:has(> span:text-is("Bottom sheet"))',
'data-md-bottom-sheet-scrim',
'height',
".split(' ')[1]",
false,
],
]);
it('fades the sheet\'s scrim out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr) {
$scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
$page = containment()
->click('#containment button:has-text("Side sheet")')
->click($trigger)
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
@@ -994,57 +1014,31 @@ it('fades the side sheet\'s scrim out on close, rather than making it vanish', f
expect($midFade)->toBeTrue();
$page->assertScript("getComputedStyle({$scrim}).display === 'none'");
});
})->with('containment sheets');
it('fades the bottom sheet\'s scrim out on close, rather than making it vanish', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-bottom-sheet-scrim]')";
it('slides the sheet out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr, string $dimension, string $axis, bool $absolute) {
$scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
$offset = "(parseFloat(getComputedStyle({$sheet}).translate{$axis}) || 0)";
$sample = $absolute ? "Math.abs(parseFloat(style.translate{$axis}) || 0)" : "(parseFloat(style.translate{$axis}) || 0)";
$page = containment()
->click('#containment button:has(> span:text-is("Bottom sheet"))')
->click($trigger)
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
$midFade = $page->script(<<<JS
(async () => {
{$scrim}.click()
for (let i = 0; i < 60; i++) {
const opacity = parseFloat(getComputedStyle({$scrim}).opacity)
if (opacity > 0.02 && opacity < 0.98) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midFade)->toBeTrue();
$page->assertScript("getComputedStyle({$scrim}).display === 'none'");
});
it('slides the side sheet out on close, rather than making it vanish', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
$page = containment()
->click('#containment button:has-text("Side sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("Math.abs(parseFloat(getComputedStyle({$sheet}).translate)) < 0.5");
->assertScript("Math.abs({$offset}) < 0.5");
// Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the
// sheet must be caught still displayed and part of the way to its closed offset (one sheet
// width past its edge), not gone at once — which is what Firefox showed while the exit leaned
// on `allow-discrete` holding `display`.
// sheet must be caught still displayed and part of the way to its closed offset (one sheet's
// own size past its edge), not gone at once — which is what Firefox showed while the exit
// leaned on `allow-discrete` holding `display`.
$midSlide = $page->script(<<<JS
(async () => {
const width = {$sheet}.getBoundingClientRect().width
const size = {$sheet}.getBoundingClientRect().{$dimension}
{$scrim}.click()
for (let i = 0; i < 60; i++) {
const style = getComputedStyle({$sheet})
const offset = Math.abs(parseFloat(style.translate) || 0)
if (style.display !== 'none' && offset > 1 && offset < width - 1) return true
const offset = {$sample}
if (style.display !== 'none' && offset > 1 && offset < size - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
@@ -1054,36 +1048,7 @@ it('slides the side sheet out on close, rather than making it vanish', function
expect($midSlide)->toBeTrue();
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('slides the bottom sheet down on close, rather than making it vanish', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-bottom-sheet-scrim]')";
$offset = "(parseFloat(getComputedStyle({$sheet}).translate.split(' ')[1]) || 0)";
$page = containment()
->click('#containment button:has(> span:text-is("Bottom sheet"))')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("Math.abs({$offset}) < 0.5");
$midSlide = $page->script(<<<JS
(async () => {
const height = {$sheet}.getBoundingClientRect().height
{$scrim}.click()
for (let i = 0; i < 60; i++) {
const offset = {$offset}
if (getComputedStyle({$sheet}).display !== 'none' && offset > 1 && offset < height - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midSlide)->toBeTrue();
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
})->with('containment sheets');
it('slides the side sheet in from its own edge in a right-to-left page', function () {
Route::middleware('web')->get('/drawer-rtl-probe', fn () => Blade::render(<<<'BLADE'
@@ -1104,8 +1069,7 @@ it('slides the side sheet in from its own edge in a right-to-left page', functio
</html>
BLADE));
$page = visit('/drawer-rtl-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
$page = ready(visit('/drawer-rtl-probe'), livewire: false);
$sheet = "document.querySelector('aside[data-md-side]')";
@@ -1171,8 +1135,7 @@ it('keeps a basic dialog\'s own title and padding when it is nested inside a ful
</html>
BLADE));
$page = visit('/nested-fullscreen-dialog-probe')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/nested-fullscreen-dialog-probe')->resize(400, 800));
$page->click('button:has-text("Open settings")');
@@ -1234,8 +1197,7 @@ it('keeps a selected segmented row\'s fill under the hover and focus tint', func
</html>
BLADE));
$page = visit('/segmented-selected-row-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
$page = ready(visit('/segmented-selected-row-probe'), livewire: false);
$selected = "document.querySelector('[data-md-selected]')";
$plain = "document.querySelector('[data-md-list-item]:not([data-md-selected])')";
@@ -1301,8 +1263,7 @@ it('fills a selected row written by hand in secondary-container in both themes,
</html>
BLADE));
$page = visit('/hand-made-selected-row-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
$page = ready(visit('/hand-made-selected-row-probe'), livewire: false);
$swatch = settledPaint("document.getElementById('swatch')");
$row = settledPaint("document.getElementById('selected-row')");
@@ -1376,8 +1337,7 @@ function collapseMotionProbe(string $durations = ''): mixed
</html>
BLADE));
$page = visit('/collapse-motion-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
$page = ready(visit('/collapse-motion-probe'), livewire: false);
if ($durations !== '') {
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-spatial-fast-duration: {$durations}; }</style>')");
@@ -1658,8 +1618,7 @@ it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but
// A phone, where the sheet is modal and traps the focus, which it moves to its ✕ on open: here
// on a page loaded with the sheet open, as a link to a session opens it.
$page = visit('/opened-layers-probe?workout=7', ['viewport' => ['width' => 393, 'height' => 800]])->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'")
$page = ready(visit('/opened-layers-probe?workout=7', ['viewport' => ['width' => 393, 'height' => 800]]), livewire: false)
->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')")
->wait(0.3)
->assertScript('! '.closeTooltipOpen($sheet));
@@ -1670,8 +1629,7 @@ it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but
->assertScript(closeTooltipOpen($sheet));
// A full-screen dialog opened from the keyboard moves the focus to its bar's ✕ in showModal().
$page = visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]])->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
$page->keys('#open-setback', 'Enter')
->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')")
@@ -1683,8 +1641,7 @@ it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but
->assertScript(closeTooltipOpen($dialog));
// A rich tooltip's trigger as the first control of a dialog opened from the keyboard.
$page = visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]])->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
$page->keys('#open-about', 'Enter')
->assertScript("document.activeElement.id === 'about-help'")
+2 -4
View File
@@ -76,8 +76,7 @@ function dataProbe()
</html>
BLADE));
return visit('/data-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/data-probe'));
}
it('sorts by a column and flips the direction on a second press', function () {
@@ -149,8 +148,7 @@ it('shows a page step\'s keyboard focus ring, from md-focus-ring', function () {
});
it('keeps a selected table row\'s fill under the hover and focus tint', function () {
$page = visit('/material/data')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/data'));
$selected = "document.querySelector('#data tr[data-md-list-row][aria-selected=\"true\"]')";
$plain = "document.querySelector('#data tr[data-md-list-row]:not([aria-selected=\"true\"])')";
+3 -6
View File
@@ -65,8 +65,7 @@ function dateProbe(string $locale = 'en')
BLADE);
});
return visit("/date-probe/{$locale}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit("/date-probe/{$locale}"));
}
class DateFormatProbe extends Component
@@ -121,8 +120,7 @@ function dateFormatProbe(string $locale = 'en')
BLADE);
});
return visit("/date-format-probe/{$locale}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit("/date-format-probe/{$locale}"));
}
class ScopedDateProbe extends Component
@@ -161,8 +159,7 @@ function scopedDateProbe()
</html>
BLADE));
return visit('/scoped-date-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/scoped-date-probe'));
}
/** A picker's day cell, by its ISO date. */
+7 -5
View File
@@ -111,11 +111,13 @@ it('draws the layout from its own inlined rules under a build that never imports
File::ensureDirectoryExists($public.'/build/assets');
// The foundation and the scheme, as an application's bundle would carry them; without the font
// face, whose relative url would 404 from here and is no part of what this test proves.
$css = Stylesheets::bundle([
realpath(__DIR__.'/../../resources/css/foundation.css'),
workbench_path('resources/css/material-scheme.css'),
]);
// face, whose relative url would 404 from here and is no part of what this test proves. The
// foundation's files hold either imports or rules (base.css's one import is the font face), so
// each file's rules in the order the imports reach them are the bundle.
$css = collect(Stylesheets::resolvedFiles([__DIR__.'/../../resources/css/foundation.css']))
->map(fn (string $file): string => (string) preg_replace('/^@import [^;]+;/m', '', File::get($file)))
->push(File::get(workbench_path('resources/css/material-scheme.css')))
->implode("\n");
File::put($public.'/build/assets/app-probe.css', (string) preg_replace('/@font-face\s*\{[^}]*\}/', '', $css));
File::put($public.'/build/manifest.json', json_encode([
'resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'resources/css/app.css', 'isEntry' => true],
+4 -8
View File
@@ -85,8 +85,7 @@ function fieldProbe()
</html>
BLADE));
return visit('/field-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/field-probe'));
}
it('floats the label while the field has focus or holds a value', function () {
@@ -231,8 +230,7 @@ it('ends a select\'s value before its arrow with an ellipsis, at every size and
</html>
BLADE));
$page = visit('/select-value-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/select-value-probe'));
foreach (['format-md', 'format-sm', 'format-xs', 'format-filled', 'format-icon'] as $id) {
expect($page->script(selectValue($id)))->toBe([true, 'ellipsis', 'nowrap', 'hidden', 'Day first, dots — 17.08.2026']);
@@ -359,8 +357,7 @@ function fieldStateProbe(): mixed
BLADE);
});
return visit('/field-state-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
return ready(visit('/field-state-probe'), livewire: false);
}
/** A field's edge, "width colour", once its colour transition has run: `Top` outlined, `Bottom` filled. */
@@ -558,8 +555,7 @@ it('keeps a switch\'s track and a checkbox\'s box whole beside long text in a ro
})()";
// A phone's window: the row is 329px across, less than the text beside each control would take.
$page = visit('/selection-row-probe')->resize(393, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete'");
$page = ready(visit('/selection-row-probe')->resize(393, 800), alpine: false, livewire: false);
expect($page->script($control('row-bare-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true])
->and($page->script($control('row-labelled-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true])
+1 -2
View File
@@ -36,8 +36,7 @@ function layoutPage(string $body, int $width, int $height = 900, string $dir = '
function layoutReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready($page);
}
/** A computed style of the element matching the selector. */
+1 -2
View File
@@ -9,8 +9,7 @@ const BOTTOM_BAR = "document.querySelector('[data-md-scaffold-bar]')";
function navigationReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready($page);
}
function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
+4 -18
View File
@@ -75,8 +75,7 @@ function pickProbe()
</html>
BLADE));
return visit('/pick-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/pick-probe'));
}
it('keeps the values of filter chips as the property\'s own type', function () {
@@ -161,8 +160,7 @@ it('hangs a searchable choice\'s list under its field and as wide, bounded at 40
return [Math.round(field.width), Math.round(list.width), Math.round(list.left - field.left)];
})()";
$page = visit('/wide-choices-probe')->resize(1280, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
$page = ready(visit('/wide-choices-probe')->resize(1280, 800), livewire: false);
$page->click('#bounded-zone')->assertScript("document.getElementById('bounded-zone-list').matches(':popover-open')");
@@ -177,7 +175,7 @@ it('hangs a searchable choice\'s list under its field and as wide, bounded at 40
it('opens the search view with the results Livewire renders for the query', function () {
$view = "document.querySelector('#find-view')";
// The combobox is the wrapper around the input, which carries `aria-expanded` (inputs.md IN-01).
// The combobox is the wrapper around the input, which carries `aria-expanded`.
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none'")
@@ -204,7 +202,7 @@ it('says so when nothing matches, and closes on a press outside or a chosen resu
->type('#find', 'zzz')
->assertSeeIn('#find-view', 'No files match.');
// The docked view opens over a scrim that covers the rest of the page (inputs.md IN-09), so a
// The docked view opens over a scrim that covers the rest of the page, so a
// press outside the view lands on the scrim.
$page->click('[data-md-search]:has(#find) [data-md-search-scrim]')
->assertScript("getComputedStyle({$view}).display === 'none'");
@@ -266,18 +264,6 @@ it('keeps the docked search above the page while its view closes', function () {
$page->assertScript("getComputedStyle({$view}).display === 'none' && getComputedStyle(document.querySelector('[data-md-search]:has(#find)')).zIndex !== '50'");
});
it('closes the docked search view back into its bar, rather than making it vanish', function () {
$view = "document.querySelector('#find-view')";
$page = pickProbe()
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'");
expect($page->script(searchCloseSample('[data-md-search-view]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
$page->assertScript("getComputedStyle({$view}).display === 'none'");
});
it('takes the whole screen on a compact window, with a back arrow', function () {
$page = pickProbe()->resize(400, 800);
+1 -2
View File
@@ -55,8 +55,7 @@ function progressShowcase(array $options = [])
{
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
// assertion retries until the page is complete and Alpine has started.
return visit('/material/progress', $options)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/material/progress', $options));
}
it('draws a linear indicator to its value', function () {
+4 -8
View File
@@ -7,8 +7,7 @@ it('opens the showcase in a real browser', function () {
});
it('moves between sections through the rail and the next-section link, keeping the rail', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material'));
$page->click('[data-md-navigation-rail-panel] a[href$="/material/buttons"]')
->assertSee('Variants')
@@ -21,8 +20,7 @@ it('moves between sections through the rail and the next-section link, keeping t
});
it('finds a component from anywhere and opens its section', function () {
$page = visit('/material/buttons')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material/buttons'));
$page->keys('#content', '/')
->assertScript("document.activeElement.id === 'showcase-search'");
@@ -36,8 +34,7 @@ it('finds a component from anywhere and opens its section', function () {
});
it('opens an example on another page at the example', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page = ready(visit('/material'));
$page->click('#showcase-search')
->type('#showcase-search', 'split button')
@@ -47,8 +44,7 @@ it('opens an example on another page at the example', function () {
});
it('says so when nothing matches', function () {
visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
ready(visit('/material'))
->click('#showcase-search')
->type('#showcase-search', 'zzzz')
->assertSeeIn('[data-md-search-view]', 'Nothing matches');
+2 -4
View File
@@ -53,14 +53,12 @@ function sliderProbe()
</html>
BLADE));
return visit('/slider-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/slider-probe'));
}
function sliderShowcase()
{
return visit('/material/sliders')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/material/sliders'));
}
/**
+2 -5
View File
@@ -26,9 +26,7 @@ it('follows the operating system while the visitor has not chosen', function ()
it('keeps the visitor\'s choice over the operating system', function () {
// The theme switch waits for Alpine (x-cloak); a press before then would find nothing to press.
$page = visit('/material')->inDarkMode()
->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
$page = ready(visit('/material')->inDarkMode())
->assertScript(theme('data-theme', 'dark'));
$page->click('label:has(input[name="material-theme"][value="light"])')
@@ -81,8 +79,7 @@ function pageSurfaceIs(string $hex): string
function themeReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready($page);
}
it('adds a theme-color meta in the painted surface, and follows the theme and the colour profile', function () {
+1 -2
View File
@@ -56,8 +56,7 @@ function timeProbe()
</html>
BLADE));
return visit('/time-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
return ready(visit('/time-probe'));
}
/** A JavaScript expression for an element inside the named picker's dialog. */
-3
View File
@@ -2,7 +2,6 @@
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
/**
@@ -23,8 +22,6 @@ const BOOST_TEXTS = [
*/
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
beforeEach(fn () => Stylesheets::resetCache());
/**
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
@@ -1,89 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The actions and communication components, rewritten without Tailwind (plan step 36): each view
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
* as px range queries, and is imported from the "Actions and communication" block of all.css.
* Every component stylesheet importing the stylesheets its view renders is
* tests/Feature/StylesheetsTest.php's now, folded into one dataset over every component view (step
* 42) rather than kept once per group.
*
* `icon` and `shape` are the two foundation components (`<x-icon>`, `<x-shape>`) — no group's view
* renders them exclusively, so they were in no group's dataset at all. They follow the same shape,
* token and view-class rules as every other component, so they run with this group's checks too;
* only "is imported from the actions and communication block" needs to read the two of them from
* all.css's first block ("Foundation components") instead, since that is where they sit.
*/
dataset('action components', [
'icon',
'shape',
'loading',
'tooltip',
'badge',
'button',
'group',
'button-group',
'split-button',
'fab',
'menu-separator',
'menu-group',
'menu-item',
'menu',
'fab-menu-item',
'fab-menu',
'rich-tooltip',
'toast',
'progress',
'alert',
'stat',
'empty-state',
]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('action components');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
})->with('action components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
$css = ComponentStylesheet::read($name);
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
// Colours are roles, shadows are elevation levels, type is a type style, motion is a spring.
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
foreach ($css->mediaQueries() as $query) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$name}.css: {$query}")
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$name}.css: {$query} is not at an M3 breakpoint");
}
}
})->with('action components');
it('is imported from the actions and communication block of all.css, or icon and shape from the foundation block before it', function (string $name) {
$block = allCssBlock(in_array($name, ['icon', 'shape'], true) ? 'Foundation components' : 'Actions and communication');
expect($block)->toContain("@import './components/{$name}.css';");
})->with('action components');
+5 -3
View File
@@ -12,9 +12,11 @@ it('states a notice in its state\'s container, with the state\'s icon', function
->toContain('Storage almost full')
->toContain('3.8 of 4 GB')
->toContain('<svg')
->and(ComponentStylesheet::read('alert')->declarations("[data-md-alert][data-md-color='warning']"))->toBe([
'background-color' => 'var(--md-sys-color-warning-container)',
'color' => 'var(--md-sys-color-on-warning-container)',
// Every hue but neutral comes from the shared colour-role table (color.css, ColorTest.php);
// the alert reads its container role straight off it, info by default.
->and(ComponentStylesheet::read('alert')->declarations('[data-md-alert]'))->toMatchArray([
'background-color' => 'var(--md-container, var(--md-sys-color-info-container))',
'color' => 'var(--md-on-container, var(--md-sys-color-on-info-container))',
]);
});
+14 -13
View File
@@ -179,11 +179,11 @@ it('centres a headline on a three-column row and curves the search container', f
$css = ComponentStylesheet::read('app-bar');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons.
->not->toContain('inset-inline: 56px;')
->and($css->declarations('[data-md-app-bar][data-md-variant=\'center\'] [data-md-app-bar-row]'))
->toBe(['display' => 'grid', 'grid-template-columns' => '1fr auto 1fr'])
// 312dp, then half of what is left (N-09).
// 312dp, then half of what is left.
->and($css->declarations('[data-md-app-bar-search]'))
->toHaveKey('max-width', 'calc(312px + (100% - 312px) / 2)');
});
@@ -204,20 +204,21 @@ it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = ComponentStylesheet::read('toolbar');
expect($css->declarations('[data-md-toolbar-place=\'bottom\']'))
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
// Never a sum: --material-bottom-bar already swallows the bottom safe area.
->toHaveKey('bottom', 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))')
->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-toolbar-place=\'bottom\']'))
->toHaveKey('bottom', 'var(--material-bottom-bar, 0px)')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16.
->and($css->declarations('[data-md-toolbar][data-md-vertical][data-md-toolbar-place=\'end\'], [data-md-toolbar-group][data-md-vertical][data-md-toolbar-place=\'end\']'))
->toBe(['inset-inline-end' => 'calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)))'])
// A standard toolbar's standard buttons are primary (N-13).
// A standard toolbar's standard buttons are primary.
->and($css->declarations('[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([data-md-variant=\'filled\'], [data-md-variant=\'tonal\'], [data-md-selected=\'true\'], [aria-pressed=\'true\'], :disabled, [aria-disabled=\'true\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
// N-13 names the *standard* button, so a button that fills its own container keeps the label
// colour that goes with that fill: a filled button painted primary put a primary icon on a
// primary fill, and the current page's button in a navigation toolbar came out a blank circle.
// The rule above names the *standard* button, so a button that fills its own container keeps
// the label colour that goes with that fill: a filled button painted primary put a primary
// icon on a primary fill, and the current page's button in a navigation toolbar came out a
// blank circle.
foreach (['[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]', '[data-md-toolbar][data-md-vibrant] [data-md-icon-button]'] as $scope) {
preg_match_all('/'.preg_quote($scope, '/').':not\([^{]*\{[^}]*\bcolor:/', $css->css, $rules);
@@ -256,7 +257,7 @@ it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-md-theme-toggle="contrast"')
->toContain('aria-label="Contrast"')
// A connected button group over native radios, not the deprecated segmented button (N-04).
// A connected button group over native radios, not the deprecated segmented button.
->toContain('data-md-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
@@ -281,7 +282,7 @@ it('switches the theme through the store in three shapes', function () {
->toContain('data-md-theme-toggle="toggle"')
->toContain('data-md-icon-button')
->toContain('aria-label="Dark theme"')
// 40px drawn, 48px reached: M3's minimum target (N-01).
// 40px drawn, 48px reached: M3's minimum target.
->toContain('md-touch-target')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
@@ -315,8 +316,8 @@ it('opens an account menu from the initials of a name', function () {
expect($html)
->toContain('aria-label="Account"')
->toContain('data-md-account-menu')
// The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01); the trigger
// has a hover and a pressed state like every other trigger in the library (N-15).
// The 40px avatar reaches 48px, and nothing clips the pseudo-target; the trigger
// has a hover and a pressed state like every other trigger in the library.
->toContain('md-state-layer md-touch-target md-focus-ring')
->not->toContain('overflow-hidden')
->toMatch('/>\s*AM\s*<\/button>/')
@@ -335,6 +336,6 @@ it('opens an account menu from the initials of a name', function () {
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
->and($css->declarations('[data-md-account-menu-avatar]'))
// Behind the state layer's ::before (z-index -1), so a hover or a press still washes over
// the picture instead of under it (N-15).
// the picture instead of under it.
->toHaveKey('z-index', '-2');
});
+12 -21
View File
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
*/
function badgeAttributes(string $blade): array
{
preg_match('/<span\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:-]+)="([^"]*)"/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2]])->all();
return layoutRoot((string) test()->blade($blade), 'span');
}
it('is M3\'s small badge without a value, hidden from screen readers', function () {
@@ -107,29 +104,23 @@ it('renders its slot as HTML, and is a dot while the slot holds nothing but comm
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
});
it('paints each colour from its pair of roles, and neutral without a hue', function (string $color, array $roles) {
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='{$color}']"))->toBe([
'--md-badge-color' => "var(--md-sys-color-{$roles[0]})",
'--md-badge-on-color' => "var(--md-sys-color-{$roles[1]})",
'--md-badge-container' => "var(--md-sys-color-{$roles[2]})",
'--md-badge-on-container' => "var(--md-sys-color-{$roles[3]})",
it('paints neutral from its own pair of roles, the one hue the shared table has none for', function () {
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='neutral']"))->toBe([
'--md-badge-color' => 'var(--md-sys-color-on-surface-variant)',
'--md-badge-on-color' => 'var(--md-sys-color-surface)',
'--md-badge-container' => 'var(--md-sys-color-surface-container-high)',
'--md-badge-on-container' => 'var(--md-sys-color-on-surface-variant)',
]);
})->with([
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
'neutral' => ['neutral', ['on-surface-variant', 'surface', 'surface-container-high', 'on-surface-variant']],
]);
});
it('is error by default, and falls back to error on a colour it does not know', function () {
$css = ComponentStylesheet::read('badge');
// Every other hue comes from the shared colour-role table (color.css, ColorTest.php): badge.css
// only has to give its own prefixed variables a fallback for when `data-md-color` is absent.
expect($css->declarations('[data-md-badge]'))->toMatchArray([
'--md-badge-color' => 'var(--md-sys-color-error)',
'--md-badge-on-color' => 'var(--md-sys-color-on-error)',
'--md-badge-color' => 'var(--md-color, var(--md-sys-color-error))',
'--md-badge-on-color' => 'var(--md-on-color, var(--md-sys-color-on-error))',
])
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])"))->toBe([
'background-color' => 'var(--md-badge-color)',
+1 -1
View File
@@ -31,7 +31,7 @@ it('connects buttons 2px apart, with M3\'s inner corners', function () {
->and($css->declarations("[data-md-button-group='connected']"))->toBe(['gap' => 'var(--md-sys-measurement-space25)'])
->and($css->declarations("[data-md-button-group='connected'] > *"))->toMatchArray([
'--md-button-group-corner' => 'var(--md-button-group-inner)',
'border-start-start-radius' => 'var(--md-button-group-corner)',
'border-radius' => 'var(--md-button-group-corner)',
])
->and($css->declarations("[data-md-button-group='connected'] > :active"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-inner-pressed)'])
->and($css->declarations("[data-md-button-group='connected'] > :first-child"))->toBe([
+14 -14
View File
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
*/
function buttonAttributes(string $blade): array
{
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
return layoutRoot((string) test()->blade($blade), 'button|a');
}
function buttonCss(): ComponentStylesheet
@@ -33,7 +30,7 @@ it('is a text button in the primary colour by default, drawn by its stylesheet',
->and(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
'--md-button-container' => 'transparent',
'--md-button-label' => 'var(--md-button-color)',
'--md-button-color' => 'var(--md-sys-color-primary)',
'--md-button-color' => 'var(--md-color, var(--md-sys-color-primary))',
'background-color' => 'var(--md-button-container)',
'color' => 'var(--md-button-label)',
]);
@@ -51,11 +48,17 @@ it('draws each M3 variant', function (string $variant, array $declarations) {
it('draws primary\'s tonal button in secondary-container and its quiet ink in on-surface-variant', function () {
expect(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
'--md-button-tone' => 'var(--md-container, var(--md-sys-color-secondary-container))',
'--md-button-on-tone' => 'var(--md-on-container, var(--md-sys-color-on-secondary-container))',
'--md-button-tone-selected' => 'var(--md-sys-color-secondary)',
'--md-button-quiet' => 'var(--md-sys-color-on-surface-variant)',
])
// The shared colour-role table's own `primary` entry is plain primary-container
// (color.css, ColorTest.php), so an explicit `data-md-color="primary"` keeps this override.
->and(buttonCss()->declarations("[data-md-button][data-md-color='primary']"))->toBe([
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
])
->and(buttonCss()->declarations("[data-md-button]:not([data-md-color='primary'])"))->toBe([
'--md-button-tone-selected' => 'var(--md-button-color)',
'--md-button-on-tone-selected' => 'var(--md-button-on-color)',
@@ -63,13 +66,10 @@ it('draws primary\'s tonal button in secondary-container and its quiet ink in on
]);
});
it('draws a variant in another colour role', function (string $color) {
expect(buttonCss()->declarations("[data-md-button][data-md-color='{$color}']"))->toBe([
'--md-button-color' => "var(--md-sys-color-{$color})",
'--md-button-on-color' => "var(--md-sys-color-on-{$color})",
'--md-button-tone' => "var(--md-sys-color-{$color}-container)",
'--md-button-on-tone' => "var(--md-sys-color-on-{$color}-container)",
]);
it('draws a variant in another colour role, straight off the shared colour-role table', function (string $color) {
// color.css (ColorTest.php) maps every one of these to --md-color/--md-on-color/--md-container/
// --md-on-container; button.css only has to read them (`draws each M3 variant`, above).
expect((string) $this->blade("<x-button label=\"Go\" color=\"{$color}\" />"))->toContain("data-md-color=\"{$color}\"");
})->with(['secondary', 'tertiary', 'error', 'success', 'warning', 'info']);
it('keeps the maryUI and ReStride shorthands', function () {
+2 -2
View File
@@ -135,7 +135,7 @@ it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down
->toContain('data-md-carousel="multi-browse"');
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no corner, no mask,
// and never wider than the medium window it is meant for (C-11).
// and never wider than the medium window it is meant for.
expect($carousel->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller]"))
->toBe([
'margin-inline' => 'auto',
@@ -210,7 +210,7 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu
$item = ComponentStylesheet::read('carousel-item');
// The scrim under the label and the item's literal white ink over it are deliberate (C-25).
// The scrim under the label and the item's literal white ink over it are deliberate.
expect($item->declarations('[data-md-carousel-surface] > [data-md-carousel-label]'))
->toHaveKey('background', 'linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent)')
->and($item->declarations('[data-md-carousel-label] > [data-md-carousel-label-text]'))
+12 -9
View File
@@ -17,7 +17,7 @@ it('is an outlined assist chip, a button, by default', function () {
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="app-chip-gap" />', 'button'))
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="app-chip-gap">')
->toBe('<button class="md-state-layer md-touch-target app-chip-gap" data-md-chip="assist" data-md-icon-start="" type="button">')
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('data-md-icon-end=""')
->not->toContain('data-md-icon-start')
@@ -25,7 +25,7 @@ it('is an outlined assist chip, a button, by default', function () {
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
->toContain('<span data-md-chip-label>Add to calendar</span>')
->not->toContain('class=');
->toContain('class="md-state-layer md-touch-target"');
});
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
@@ -36,8 +36,7 @@ it('draws a chip at Compose\'s size, corner, type and padding from its styleshee
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
// 16px beside a label and 8px beside an icon, each 1px short for the border.
->toContain('padding-inline: 15px;')
->toContain('padding-inline-start: 7px;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/chip.css';");
->toContain('padding-inline-start: 7px;');
});
it('pads a chip without icons by 16px on both sides', function () {
@@ -96,7 +95,7 @@ it('attaches a plain tooltip', function () {
expect($html)
->toContain('popover="manual"')
->toContain('Share this file')
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10};"/');
});
it('makes a filter chip without a model a toggle button the caller owns', function () {
@@ -118,7 +117,7 @@ it('makes a bound filter chip a native checkbox under the chip', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />');
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />', 'label'))
->toBe('<label data-md-chip="filter" class="app-filter-gap" wire:key="photos">')
->toBe('<label class="md-state-layer md-touch-target app-filter-gap" data-md-chip="filter" wire:key="photos">')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" />', 'input'))
->toContain('type="checkbox"')
->toContain('value="photos"')
@@ -177,7 +176,12 @@ it('makes an input chip a button when it has its own action, and selects it', fu
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
->toContain('data-md-actionable=""')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
->not->toContain('data-md-actionable');
->not->toContain('data-md-actionable')
// An input chip draws its state layer on its action, never a second one on the container.
->not->toContain('md-state-layer')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'button'))
->not->toContain('md-state-layer')
->not->toContain('md-touch-target');
});
it('gives a removable input chip a named remove button that calls wire:remove', function () {
@@ -244,8 +248,7 @@ it('draws the set from its stylesheet: 8px between chips, wrapping unless it scr
expect($css)->toContain("@import './chip.css';")
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/chip-set.css';");
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {');
});
it('scrolls a chip set on one line with fading edges', function () {
+1 -2
View File
@@ -105,6 +105,5 @@ it('brings the stylesheets of what it renders, and draws its empty row', functio
expect($css)->toContain("@import './field.css';")
->toContain("@import './chip-set.css';")
->toContain("@import './chip.css';")
->toContain('[data-md-choices-empty] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/choices.css';");
->toContain('[data-md-choices-empty] {');
});
+45
View File
@@ -0,0 +1,45 @@
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The shared colour-role table badge.css, slider.css, button.css, alert.css and progress.css read
* instead of each carrying its own copy: `data-md-color` picks primary, secondary, tertiary,
* error, success, warning or info, and each maps to the same four generic variables.
*/
it('maps every hue to its own role and its tonal container', function (string $color, array $roles) {
expect(ComponentStylesheet::read('color')->declarations("[data-md-color='{$color}']"))->toBe([
'--md-color' => "var(--md-sys-color-{$roles[0]})",
'--md-on-color' => "var(--md-sys-color-{$roles[1]})",
'--md-container' => "var(--md-sys-color-{$roles[2]})",
'--md-on-container' => "var(--md-sys-color-{$roles[3]})",
]);
})->with([
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
'error' => ['error', ['error', 'on-error', 'error-container', 'on-error-container']],
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
]);
it('keeps the generic variables on the element that names the colour', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/color.css');
foreach (['--md-color', '--md-on-color', '--md-container', '--md-on-container'] as $property) {
expect($css)->toMatch('/@property '.preg_quote($property, '/')."\\s*\\{\\s*syntax: '\\*';\\s*inherits: false;\\s*\\}/");
}
});
it('imports the table from every stylesheet that reads it', function () {
$readers = collect(glob(__DIR__.'/../../../resources/css/components/*.css'))
->reject(fn (string $file): bool => basename($file) === 'color.css')
->filter(fn (string $file): bool => preg_match('/var\(--md-(?:on-)?(?:color|container)\b/', (string) file_get_contents($file)) === 1);
expect($readers)->not->toBeEmpty();
foreach ($readers as $file) {
expect(str_contains((string) file_get_contents($file), "@import './color.css';"))->toBeTrue(basename($file).' reads the colour table without importing it');
}
});
@@ -0,0 +1,209 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* Every component and layout-shared stylesheet, rewritten without Tailwind (plan step 36): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as
* px range queries, and is imported from its own block of all.css. This used to be four near-copies
* of the same checks, one per group (Action/Containment/Input/Navigation), plus a fifth in
* ErrorPagesTest.php for the error layout — folded into this one file (plan cleanup) because the
* checks never differed in substance, only in which names they ran over and, for the token check,
* which box-shadow shape a group's own values may take (see $boxShadow below). `icon` and `shape`
* (no group's view renders them exclusively) and `color` (its own ColorTest.php already covers it,
* the shared colour-role table every hue-taking component imports) keep their separate treatment.
*
* Four names in the dataset are not a component view of their own: `selection` (what checkbox,
* radio and toggle share), `navigation-item` (what the bar and the rail's items share), `pagination`
* (four views under resources/views/pagination/, not resources/views/components/) and `error-page`
* (the framework's error layout, not a component at all). The stylesheet-only checks below (shape,
* tokens, block import) still run on all four; the view-scoped ones (class list, element-wide
* selector scoping) run only where the original group already ran them — pagination's own views get
* their own small dataset further down, and error-page's view-scoped checks stay in
* ErrorPagesTest.php, which reads a view outside resources/views/components/.
*/
function componentStylesheets(): array
{
$entries = [];
foreach (['icon', 'shape'] as $name) {
$entries[$name] = ['Foundation components', 'loose'];
}
foreach ([
'loading', 'tooltip', 'badge', 'button', 'group', 'button-group', 'split-button', 'fab',
'menu-separator', 'menu-group', 'menu-item', 'menu', 'fab-menu-item', 'fab-menu',
'rich-tooltip', 'toast', 'progress', 'alert', 'stat', 'empty-state',
] as $name) {
$entries[$name] = ['Actions and communication', 'loose'];
}
foreach ([
'divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet',
'carousel-item', 'carousel',
] as $name) {
$entries[$name] = ['Containment', 'ring'];
}
$entries['error-page'] = ['Containment', 'none'];
foreach ([
'form', 'field', 'input', 'password', 'textarea', 'select', 'file', 'checkbox', 'radio',
'toggle', 'chip', 'chip-set', 'choices', 'slider', 'search', 'table', 'sort-header',
'datepicker', 'timepicker', 'selection', 'pagination',
] as $name) {
$entries[$name] = ['Inputs, selection and data', 'ring'];
}
foreach ([
'app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail',
'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu',
'theme-toggle', 'scheme-picker', 'navigation-item',
] as $name) {
$entries[$name] = ['Navigation', 'ring'];
}
return $entries;
}
dataset('component stylesheets', function (): Generator {
foreach (componentStylesheets() as $name => [$block, $boxShadow]) {
yield $name => [$name, $block, $boxShadow];
}
});
// The names with exactly one view of their own at resources/views/components/<name>.blade.php —
// every dataset entry above but the four stylesheet-only names (see the file header) — for the two
// checks that read that view. `tabs` renders two (tabs.blade.php and tab.blade.php).
dataset('component views', array_values(array_diff(array_keys(componentStylesheets()), ['selection', 'navigation-item', 'pagination', 'error-page'])));
/**
* The names whose stylesheet travels in every page's bundle (all.css), so a bare `html`, `body`,
* `dialog` or `:root` rule would restyle an application's own pages — containment and navigation,
* the two groups whose original tests carried this check. `[data-md-` is the exception in both: a
* rule that only applies while a package component is on the page at all. `:has([data-md-` needs
* no `>` combinator only in navigation's own stylesheets (toolbar.css's
* `:root:has([data-md-toolbar-place=…])` has none) — containment keeps the strict, `>`-only form
* its own original test held it to.
*/
dataset('scoped selectors', function (): Generator {
$names = [
...array_fill_keys(['divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet', 'carousel-item', 'carousel'], true),
...array_fill_keys(['app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail', 'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu', 'theme-toggle', 'scheme-picker', 'navigation-item'], false),
];
foreach ($names as $name => $strict) {
yield $name => [$name, $strict];
}
});
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
// The header, the layer statement, the plain imports and freedom from Tailwind are
// StylesheetsTest's, checked over every file all.css reaches; only the block layer and the
// imports' existence are this dataset's own to check.
expect($css->blocks())->each->toBe('@layer material.components');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('component stylesheets');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
// `tabs` renders two views of its own, tabs.blade.php and tab.blade.php; every other name here
// has exactly one.
foreach (($name === 'tabs' ? ['tabs', 'tab'] : [$name]) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('component views');
/**
* `$boxShadow` is the shape a stylesheet's own box-shadow values may take: 'loose' for actions and
* communication (a component paints its own elevation through a local custom property, e.g.
* button.css's `--md-button-elevation`, not `--md-sys-elevation-N` directly, so any `var(--md-*)`
* passes); 'ring' for containment, inputs/selection/data and navigation (`none`, an elevation level,
* or a literal ring — `[inset] 0 0 0 <n>px` in a colour role, a focus or a selected day); 'none' for
* the error layout, which draws no box-shadow of its own.
*
* Breakpoints (px, at one of M3's four) are StylesheetsTest's, checked over every file all.css
* reaches. field.css's autofill workaround (`transition: background-color 604800s, color
* 604800s`) delays Chrome's autofill background for a week — not a spring, and not meant to be
* one — so it is stripped before the transition check.
*/
it('takes its values from the tokens', function (string $name, string $block, string $boxShadow) {
$label = "{$name}.css";
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
if ($label === 'field.css') {
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
}
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
if ($boxShadow === 'loose') {
expect($source)->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/');
} elseif ($boxShadow === 'ring') {
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
}
})->with('component stylesheets');
it('is imported from its own block of all.css', function (string $name, string $block) {
expect(allCssBlock($block))->toContain("@import './components/{$name}.css';");
})->with('component stylesheets');
it('scopes every element-wide selector to a data-md hook', function (string $name, bool $strict) {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
$has = $strict ? ':has\(> \[data-md-' : ':has\((?:> )?\[data-md-';
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|'.$has.')/m');
})->with('scoped selectors');
// ---- pagination: its views live outside resources/views/components/ --------------------------
dataset('pagination views', [
'pagination/laravel/tailwind',
'pagination/laravel/simple-tailwind',
'pagination/livewire/tailwind',
'pagination/livewire/simple-tailwind',
]);
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
})->with('pagination views');
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('pagination views');
// ---- partials: fragments a component view @includes, not a component view of their own --------
dataset('partial views', array_map(
fn (string $file): string => 'partials/'.basename($file, '.blade.php'),
glob(__DIR__.'/../../../resources/views/partials/*.blade.php'),
));
it('writes no class list into a partial but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('partial views');
@@ -1,109 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The containment components, rewritten without Tailwind (plan step 36): each view renders
* `data-md-*` attributes and no class list of its own beyond the interaction and text classes
* (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as px
* range queries, and is imported from the "Containment" block of all.css. Every component
* stylesheet importing the stylesheets its view renders is tests/Feature/StylesheetsTest.php's
* now, folded into one dataset over every component view (step 42) rather than kept once per
* group.
*
* The dataset grew by one name per component commit, the same rule InputStylesheetsTest.php and
* ActionStylesheetsTest.php follow. The error layout, whose view is not a component, has the same
* checks in tests/Feature/ErrorPagesTest.php.
*/
dataset('containment components', [
'divider',
'collapse',
'card',
'list',
'list-item',
'modal',
'drawer',
'bottom-sheet',
'carousel-item',
'carousel',
]);
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
*/
function assertContainmentBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
}
}
}
}
/**
* Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints
* are px, at one of M3's four.
*/
function assertContainmentTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
assertContainmentBreakpointsInPx($css, $label);
}
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('containment components');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
})->with('containment components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertContainmentTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('containment components');
it('is imported from the containment block of all.css', function (string $name) {
expect(allCssBlock('Containment'))->toContain("@import './components/{$name}.css';");
})->with('containment components');
it('scopes every element-wide selector to a data-md hook', function (string $name) {
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
// `dialog` or `:root` rule would restyle the application's own pages.
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m');
})->with('containment components');
+3 -6
View File
@@ -25,8 +25,7 @@ it('draws 52px rows, 36px dense ones, from its stylesheet in the components laye
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/table.css';");
->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))');
});
it('sorts by its column, ascending first and then flipping', function () {
@@ -110,14 +109,12 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
->toContain('@layer material.components')
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/sort-header.css';");
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/');
});
it('draws the paginators from their stylesheet, trading the numbers for "Page n of m" below 600px', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/pagination.css'))
->toContain('@layer material.components')
->toMatch('/@media \\(width < 600px\\) \\{\\s*\\[data-md-pagination-page\\],\\s*\\[data-md-pagination-range\\] \\{\\s*display: none;/')
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-pagination-compact\\] \\{\\s*display: none;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/pagination.css';");
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-pagination-compact\\] \\{\\s*display: none;/');
});
+1 -4
View File
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
*/
function fabAttributes(string $blade): array
{
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
return layoutRoot((string) test()->blade($blade), 'button|a');
}
it('draws a FAB in its container colour, named by its tooltip', function () {
+3 -20
View File
@@ -16,27 +16,10 @@ it('takes a minimum item width as a length or a number of px', function () {
expect(layoutRoot((string) $this->blade('<x-feed min-item="a third" />'))['style'])->toBe('--md-min-item: 240px;');
});
it('replaces the spacer with a spacing token, and with no gap for an unknown one', function () {
expect(layoutRoot((string) $this->blade('<x-feed gap="space400" />'))['data-md-gap'])->toBe('space400')
->and(layoutRoot((string) $this->blade('<x-feed gap="wide" />'))['data-md-gap'])->toBe('none');
});
it('takes the element, the visibility props, and the caller\'s class and style after its own', function () {
expect(layoutRoot((string) $this->blade('<x-feed as="ul" hide-below="medium" class="photos" style="padding-block: 1rem" />')))
->toMatchArray([
'<' => 'ul',
'data-md-hide-below' => 'medium',
'class' => 'photos',
'style' => '--md-min-item: 240px; padding-block: 1rem;',
]);
});
it('keeps one column on a compact window and fills columns from medium, in the layout layer', function () {
it('keeps one column on a compact window and fills columns from medium', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/feed.css');
expect($css)->toContain('@layer material.layout')
->toContain("[data-md-feed] {\n display: grid;\n grid-template-columns: minmax(0, 1fr);")
expect($css)->toContain("[data-md-feed] {\n display: grid;\n grid-template-columns: minmax(0, 1fr);")
->toContain("@media (width >= 600px) {\n grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--md-min-item, 240px)), 1fr));")
->not->toContain('grid-auto-flow')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/feed.css';");
->not->toContain('grid-auto-flow');
});
+7 -14
View File
@@ -248,8 +248,7 @@ it('draws a form\'s column and its actions from its stylesheet', function () {
expect($css)->toContain('@layer material.components')
->toContain('grid-template-columns: minmax(0, 1fr);')
->toContain('gap: var(--md-sys-measurement-space200);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/form.css';");
->toContain('gap: var(--md-sys-measurement-space200);');
});
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
@@ -258,8 +257,7 @@ it('keeps the field\'s chrome in its own stylesheet in the components layer', fu
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toContain('[data-md-field-box]')
->not->toMatch('/(?<![\\w-])\\.field\\b/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/field.css';");
->not->toMatch('/(?<![\\w-])\\.field\\b/');
});
it('takes a custom control marked as the field\'s control, and a hint class', function () {
@@ -280,15 +278,13 @@ it('gives the one-line field a stylesheet that brings the field and its icons',
expect($css)->toStartWith('/*')
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/input.css';");
->toContain("@import './icon.css';");
});
it('gives the password field a stylesheet that brings the field and its icons', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/password.css';");
->toContain("@import './icon.css';");
});
it('draws what a textarea changes in the field from its own stylesheet', function () {
@@ -298,8 +294,7 @@ it('draws what a textarea changes in the field from its own stylesheet', functio
->toContain('@layer material.components')
->toContain('textarea[data-md-field-control][data-md-autogrow]')
->toContain('field-sizing: content;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/textarea.css';");
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[');
});
it('draws what a select changes in the field from its own stylesheet', function () {
@@ -308,8 +303,7 @@ it('draws what a select changes in the field from its own stylesheet', function
expect($css)->toContain("@import './field.css';")
->toContain('@supports (appearance: base-select)')
->toContain('select[data-md-field-control]:open')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/select.css';");
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[');
});
it('draws the file picker\'s button from its own stylesheet', function () {
@@ -318,8 +312,7 @@ it('draws the file picker\'s button from its own stylesheet', function () {
expect($css)->toContain("@import './field.css';")
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
->toContain('background-color: var(--md-sys-color-secondary-container);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/file.css';");
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']");
});
it('keeps a button written among a form\'s fields at its label\'s width', function () {
+2 -17
View File
@@ -34,11 +34,6 @@ it('fills each breakpoint left out from the nearest smaller one', function () {
expect(Layout::columns(['extra-large' => 6]))->toBe(['compact' => 1, 'medium' => 1, 'expanded' => 1, 'large' => 1, 'extra-large' => 6]);
});
it('spaces the columns with a spacing token only, and with none for anything else', function () {
expect(layoutRoot((string) $this->blade('<x-grid gap="space300" />'))['data-md-gap'])->toBe('space300')
->and(layoutRoot((string) $this->blade('<x-grid gap="1.5rem" />'))['data-md-gap'])->toBe('none');
});
it('fills a row with columns of a minimum width, capped by the counts when both are given', function () {
$fill = layoutRoot((string) $this->blade('<x-grid min-item="280px" />'));
$capped = layoutRoot((string) $this->blade('<x-grid min-item="18rem" :columns="[\'medium\' => 2, \'expanded\' => 3]" />'));
@@ -58,22 +53,12 @@ it('fills a row with columns of a minimum width, capped by the counts when both
}
});
it('takes the element, the visibility props, and the caller\'s class and style after its own', function () {
$root = layoutRoot((string) $this->blade('<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />'));
expect($root)->toMatchArray(['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery'])
->and($root['style'])->toEndWith('--md-columns-extra-large: 1; align-items: start;');
});
it('reads its own column count at each breakpoint in the layout layer', function () {
it('reads its own column count at each breakpoint', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/grid.css');
expect($css)->toContain('@layer material.layout')
->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));');
expect($css)->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));');
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
expect($css)->toContain("@media (width >= {$width}px) {\n --md-columns: var(--md-columns-{$breakpoint}, 1);");
}
expect((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/grid.css';");
});
+2
View File
@@ -18,6 +18,8 @@ it('draws a choice as connected radios', function () {
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
->toMatch('/<label data-md-group-segment class="md-state-layer\s+md-touch-target\s*">/')
->toContain('type="radio"')
// The native control stays in the form and the accessibility tree, out of the drawing.
->toMatch('/<input[^>]*class="md-visually-hidden"/')
->toContain('name="theme"')
->toContain('x-model="theme"')
->toContain('value="light"')
+1 -2
View File
@@ -72,8 +72,7 @@ it('draws its size from a stylesheet in the components layer', function () {
expect($css)->toContain('@layer material.components')
->toContain('inline-size: var(--md-icon-size, 24px)')
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/icon.css';");
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)");
});
it('names the icon for a screen reader when it carries the meaning alone', function () {
@@ -1,195 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The inputs, selection and data components, rewritten without Tailwind (plan step 36): each view
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
* as px range queries, and is imported from the "Inputs, selection and data" block of all.css.
* Every component stylesheet importing the stylesheets its view renders is
* tests/Feature/StylesheetsTest.php's now, folded into one dataset over every component view (step
* 42) rather than kept once per group.
*
* `pagination` is not in this dataset: its four views live in `resources/views/pagination/**`, not
* `resources/views/components/`, so the check that reads a single `resources/views/components/
* {name}.blade.php` file does not apply to it. It gets the two checks that do not depend on that
* path below, and its own small version of the third.
*/
function inputComponents(): array
{
return [
'form',
'field',
'input',
'password',
'textarea',
'select',
'file',
'checkbox',
'radio',
'toggle',
'chip',
'chip-set',
'choices',
'slider',
'search',
'table',
'sort-header',
'datepicker',
'timepicker',
];
}
dataset('input components', inputComponents());
/**
* The checks that read a stylesheet alone also run on the one no view renders by itself:
* selection.css, what the checkbox, radio and switch share (its own file header names them), which
* each of their stylesheets imports too. Nothing renders it directly, so without this its shape
* and token rules would go unchecked, the way navigation-item.css did before
* NavigationStylesheetsTest.php picked it up.
*/
dataset('input stylesheets', [...inputComponents(), 'selection']);
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. The one exception is a viewport *height* in a query keyed on `orientation`: the time
* picker lies its dial down by device orientation and height, which M3 explicitly does not make a
* breakpoint (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour),
* so that height may be any px. A width, in any query, is still a breakpoint. `rem` and `em` would
* grow with the reader's text size, which neither should.
*/
function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
$orientation = preg_match('/\(\s*orientation\s*:/', $query) === 1;
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
$height = preg_match('/(?:^|[\s:<>=])(?:min-|max-)?height\b/', $feature) === 1 && ! str_contains($feature, 'width');
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
if (! ($orientation && $height)) {
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
}
}
}
}
}
/**
* Colours are roles, shadows are elevation levels, type is a type style, motion is a spring and
* breakpoints are px, at one of M3's four (an orientation query's height aside; see
* assertBreakpointsInPx()).
*
* One named exception, in field.css alone: its autofill workaround (`transition: background-color
* 604800s, color 604800s`) delays Chrome's autofill background for a week, which is not a spring and
* is not meant to be one a `var(--md-sys-motion-*)` duration there would visibly repaint on load.
*/
function assertTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
if ($label === 'field.css') {
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
}
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour
// role (or a role mixed toward transparent, a disabled ring) — a day's or a year's "current"
// outline, a focused time field's edge. Never a hand-made shadow.
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
assertBreakpointsInPx($css, $label);
}
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('input stylesheets');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
})->with('input components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('input stylesheets');
it('is imported from the inputs, selection and data block of all.css', function (string $name) {
expect(allCssBlock('Inputs, selection and data'))->toContain("@import './components/{$name}.css';");
})->with('input components');
// ---- pagination: its views live outside resources/views/components/ --------------------------
it('draws pagination from a stylesheet shaped like every package stylesheet', function () {
$css = ComponentStylesheet::read('pagination');
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path('pagination')).'/'.$import))->toBeTrue("pagination.css imports {$import}, which does not exist");
}
});
it('takes pagination\'s values from the tokens and its breakpoints in px', function () {
assertTokensAndPxBreakpoints(ComponentStylesheet::read('pagination'), 'pagination.css');
});
it('is imported from the inputs, selection and data block of all.css for pagination', function () {
expect(allCssBlock('Inputs, selection and data'))->toContain("@import './components/pagination.css';");
});
dataset('pagination views', [
'pagination/laravel/tailwind',
'pagination/laravel/simple-tailwind',
'pagination/livewire/tailwind',
'pagination/livewire/simple-tailwind',
]);
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
$rendered = collect($tags[1])->unique()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
})->with('pagination views');
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
})->with('pagination views');
@@ -0,0 +1,68 @@
<?php
/**
* Two checks every layout component repeated once per file: the root passes through `as`, a
* visibility prop and the caller's own `class`/`style` (after its own style, where it writes one)
* and any other attribute; the four with a `gap` prop replace it with a spacing token or drop to
* `data-md-gap="none"` for anything else. Folded into one dataset each here, keyed by tag, rather
* than kept once per component's own test file — <x-grid>'s style differs (a long per-breakpoint
* column list before the caller's own, checked with `$styleEndsWith` instead of the exact string
* the others match) and <x-stack>'s gap check has an extra invalid case (`space1000`, a token name
* past the scale); both are still one dataset row, not a reason to keep the test itself apart.
*/
dataset('layout component root attributes', [
'feed' => [
'<x-feed as="ul" hide-below="medium" class="photos" style="padding-block: 1rem" />',
['<' => 'ul', 'data-md-hide-below' => 'medium', 'class' => 'photos', 'style' => '--md-min-item: 240px; padding-block: 1rem;'],
],
'grid' => [
'<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />',
['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery'],
'--md-columns-extra-large: 1; align-items: start;',
],
'row' => [
'<x-row as="nav" hide-from="expanded" class="toolbar-row" style="min-height: 3rem" aria-label="Filters" />',
['<' => 'nav', 'data-md-hide-from' => 'expanded', 'class' => 'toolbar-row', 'style' => 'min-height: 3rem;', 'aria-label' => 'Filters'],
],
'stack' => [
'<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />',
['<' => 'ul', 'data-md-hide-below' => 'medium', 'data-md-hide-from' => 'large', 'class' => 'steps', 'style' => 'max-width: 40rem;'],
],
'pane' => [
'<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />',
['<' => 'section', 'data-md-hide-below' => 'expanded', 'class' => 'checkout', 'style' => 'max-width: 50rem;', 'aria-labelledby' => 'checkout-title'],
],
'list-detail' => [
'<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />',
['<' => 'section', 'data-md-hide-below' => 'medium', 'class' => 'inbox', 'style' => 'min-height: 30rem;', 'x-model' => 'chosen', 'x-modelable' => 'selected'],
],
'supporting-pane' => [
'<x-supporting-pane as="main" hide-from="extra-large" class="editor" style="min-height: 20rem" />',
['<' => 'main', 'data-md-hide-from' => 'extra-large', 'class' => 'editor', 'style' => 'min-height: 20rem;'],
],
]);
it('takes the element, the visibility props, and the caller\'s class and style after its own', function (string $blade, array $expected, ?string $styleEndsWith = null) {
$root = layoutRoot((string) $this->blade($blade));
expect($root)->toMatchArray($expected);
if ($styleEndsWith !== null) {
expect($root['style'])->toEndWith($styleEndsWith);
}
})->with('layout component root attributes');
dataset('layout component gaps', [
'feed' => ['feed', 'space400', ['wide']],
'grid' => ['grid', 'space300', ['1.5rem']],
'row' => ['row', 'space100', ['tight']],
'stack' => ['stack', 'space200', ['16px', 'space1000']],
]);
it('replaces the gap prop with a spacing token, and with none for anything else', function (string $tag, string $token, array $invalid) {
expect(layoutRoot((string) $this->blade("<x-{$tag} gap=\"{$token}\" />"))['data-md-gap'])->toBe($token);
foreach ($invalid as $bad) {
expect(layoutRoot((string) $this->blade("<x-{$tag} gap=\"{$bad}\" />"))['data-md-gap'])->toBe('none');
}
})->with('layout component gaps');
+1 -15
View File
@@ -68,26 +68,12 @@ it('draws its own back row above the detail, unless the detail brings one', func
->and($pane)->not->toContain('data-md-list-detail-back-row');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />')))
->toMatchArray([
'<' => 'section',
'data-md-hide-below' => 'medium',
'class' => 'inbox',
'style' => 'min-height: 30rem;',
'x-model' => 'chosen',
'x-modelable' => 'selected',
]);
});
it('shows one pane below expanded and both from it, at M3\'s fixed pane widths', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/list-detail.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
expect($css)->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
->toContain("@media (width >= 1200px) {\n grid-template-columns: 412px minmax(0, 1fr);")
->toContain("@media (width < 840px) {\n [data-md-list-detail]:not([data-md-selected]) > [data-md-list-detail-pane='detail'],\n [data-md-list-detail][data-md-selected] > [data-md-list-detail-pane='list'] {\n display: none;")
->toContain("[data-md-list-detail-back] [data-md-icon]:is([dir='rtl'], [dir='rtl'] *)")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/list-detail.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/js/material.js'))->toContain("import './layout.js'");
});
+4 -2
View File
@@ -30,9 +30,11 @@ it('opens a popover menu from its trigger', function () {
});
it('scrolls a menu too long for the window instead of running off it', function () {
// The size limit is the popover chrome a submenu shares (menu-item.css imports this file for
// it), so it sits under the selector list both are drawn from.
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-md-menu-popover')
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover]'))->toMatchArray([
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover], [data-md-submenu]'))->toMatchArray([
'max-block-size' => 'min(288px, calc(100dvh - 32px))',
'overflow-y' => 'auto',
]);
@@ -297,7 +299,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
// A modal bottom sheet, named by the menu's label. Its id is the same in every render, so
// a Livewire morph patches it rather than swapping it (menu.js makes it unique and keeps
// this one as the key); the lists carry keys of their own, as the popover does.
->toContain('...materialBottomSheet(false, JSON.parse(')
->toContain('...materialBottomSheet(JSON.parse(')
->toContain('id="material-menu-sheet"')
->not->toContain("material-menu-{$key[1]}-sheet\"")
// A key given to the Blade component would become the key of the loop around the menu.
@@ -53,10 +53,10 @@ it('keeps the bar item on the label and state-layer colours M3 tokens', function
$bar = ComponentStylesheet::read('navigation-bar-item');
$shared = ComponentStylesheet::read('navigation-item');
// NavigationBarTokens names one label font for both icon positions: label-medium (N-08).
// NavigationBarTokens names one label font for both icon positions: label-medium.
expect($bar->declarations('[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill]', ['@container (width >= 600px)']))
->not->toHaveKey('font')
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19), from
// The indicator and the pill wash in on-secondary-container, as the rail's do, from
// the shared file both the bar's and the rail's items import.
->and($shared->declarations('[data-md-navigation-bar-item] [data-md-navigation-indicator]::before, [data-md-navigation-bar-item] [data-md-navigation-pill]::before, [data-md-navigation-rail-item]::before, [data-md-navigation-rail-item] [data-md-navigation-indicator]::before'))
->toHaveKey('background-color', 'var(--md-sys-color-on-secondary-container)');
@@ -103,7 +103,7 @@ it('hides on a scroll down and springs back on a scroll up', function () {
->toBe(['translate' => '0 100%'])
// Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
// `material.layout`, which this rule's own `material.components` always outranks, so both
// sit in a layer now (N-02's era of an unlayered Tailwind utility is gone).
// sit in a layer now (the era of an unlayered Tailwind utility is gone).
->and($css->declarations('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden])'))
->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
+65 -32
View File
@@ -24,7 +24,7 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
->toContain('data-md-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"')
// The 40px menu button reaches M3's 48px target through the shared classes (N-01).
// The 40px menu button reaches M3's 48px target through the shared classes.
->toContain('class="md-state-layer md-focus-ring md-touch-target"')
->not->toContain('data-md-navigation-rail-scrim')
->not->toContain('x-trap')
@@ -63,13 +63,13 @@ it('takes M3\'s optional divider and turns the container fill off', function ()
$css = ComponentStylesheet::read('navigation-rail');
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22), nor one
// Neither reaches a rail open over a scrim, which is a surface over the page, nor one
// sliding off the window as it closes.
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['border-inline-end' => '1px solid var(--md-sys-color-outline-variant)'])
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['background-color' => 'transparent'])
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead.
->and($css->declarations('[data-md-navigation-rail=\'collapsible\']', ['@media (width < 600px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
});
@@ -97,7 +97,7 @@ it('takes M3\'s narrow collapsed width, icons alone but still named', function (
->and($css->declarations('[data-md-navigation-rail]'))
->toHaveKey('--navigation-rail-collapsed-width', '96px')
// Out of the drawing, not out of the page: clipped only while collapsed.
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]", ['@container style(--md-navigation-rail-value: collapsed)']))
->toHaveKey('clip-path', 'inset(50%)');
});
@@ -206,18 +206,18 @@ it('reproduces every branch of the old rail-collapsed variant for the rail\'s ow
it('flattens a FAB nested in the rail header and morphs its label', function () {
$css = ComponentStylesheet::read('navigation-rail');
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03). Now layered, like
// A nested FAB rests at elevation 0, not the 3 a standalone one has. Now layered, like
// toolbar.css's own docked-FAB override: fab.css's `[data-md-fab]` is one attribute, so the
// doubled selector here (two) always wins without needing to sit outside the layer.
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-fab], [data-md-navigation-rail-header] [data-md-fab][data-md-fab]:hover'))
->toBe(['box-shadow' => 'none'])
// One FAB whose label springs shut, not two swapped by display (N-23).
// One FAB whose label springs shut, not two swapped by display.
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span'))
->toHaveKey('max-width', '256px');
expect($css->declarations("[data-md-navigation-rail-header] [data-md-fab] > span:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span', ['@container style(--md-navigation-rail-value: collapsed)']))
->toBe(['max-width' => '0', 'opacity' => '0'])
->and($css->declarations("[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]', ['@container style(--md-navigation-rail-value: collapsed)']))
->toBe(['min-inline-size' => '0', 'aspect-ratio' => '1', 'gap' => '0']);
});
@@ -260,21 +260,13 @@ it('draws the item\'s own state layer split between the item and its indicator',
});
/**
* Plain CSS has no variant to name "drawn collapsed" once, so every rule with a collapsed shape
* writes the conditions out: navigation-rail.css, navigation-rail-item.css,
* navigation-rail-section.css, and layout/scaffold.css for the actions row inside the rail's footer.
* This walks every copy in those files not one pinned rule and fails if any drifts from the
* seven conditions the Tailwind-era `rail-collapsed:` variant had, or if a rule has its collapsed
* shape in some window bands but not in all five.
* navigation-rail.css alone still writes "drawn collapsed" branch for branch: the two rules that
* set the rail's own width and publish `--md-navigation-rail-value`, which the rail cannot query
* on itself (a `@container style()` reads an ancestor, never the element that publishes the
* property). This walks every copy of that seven-condition set and fails if any drifts from it, or
* has its collapsed shape in some window bands but not in all five.
*/
dataset('stylesheets that draw a collapsed rail', [
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'../layout/scaffold',
]);
it('writes "collapsed" with the same seven conditions in every copy, in every band', function (string $name) {
it('writes the rail\'s own "collapsed" condition with the same seven conditions, in every band', function () {
$closed = ':not([data-md-open])';
$bands = [
'always' => ["[data-md-navigation-rail='collapsed']", "[data-rail='collapsed'] [data-md-navigation-rail='collapsible']{$closed}", "[data-md-navigation-rail='modal']{$closed}"],
@@ -284,14 +276,12 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
'@media (width >= 1200px)' => ["[data-rail='collapsed'] [data-md-navigation-rail='adaptive']{$closed}"],
];
// A condition matches the rail itself and everything in it; the scaffold's actions row can only
// ever be inside a rail, so its copy keeps the descendant half alone.
// A condition matches the rail itself and everything in it.
$both = fn (array $conditions): array => array_merge(...array_map(fn (string $condition): array => [$condition, "{$condition} *"], $conditions));
$inside = fn (array $conditions): array => array_map(fn (string $condition): string => "{$condition} *", $conditions);
$groups = [];
foreach (ComponentStylesheet::read($name)->rules() as $rule) {
foreach (ComponentStylesheet::read('navigation-rail')->rules() as $rule) {
preg_match_all('/:where\(((?:[^()]++|\((?1)\))*)\)/', $rule['selector'], $wheres, PREG_SET_ORDER);
$wheres = array_filter($wheres, fn (array $where): bool => str_contains($where[1], 'data-md-navigation-rail='));
@@ -302,17 +292,15 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
$media = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@media')));
$band = match ($media) {
[] => 'always',
['@media (width >= 840px)', '@media (width < 1200px)'] => '@media (840px <= width < 1200px)',
default => implode(' ', $media),
};
expect(array_key_exists($band, $bands))->toBeTrue("{$name}.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
expect(array_key_exists($band, $bands))->toBeTrue("navigation-rail.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
foreach ($wheres as [$where, $conditions]) {
$written = array_map(trim(...), preg_split('/,(?![^()]*\))/', $conditions));
expect(in_array($written, [$both($bands[$band]), $inside($bands[$band])], true))
->toBeTrue("{$name}.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
expect($written)->toBe($both($bands[$band]), "navigation-rail.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
}
$key = str_replace(array_column($wheres, 0), '', $rule['selector']).' '.json_encode($rule['declarations']);
@@ -322,6 +310,51 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
expect($groups)->not->toBeEmpty();
foreach ($groups as $key => $found) {
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "{$name}.css: {$key} is drawn collapsed in some bands only");
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "navigation-rail.css: {$key} is drawn collapsed in some bands only");
}
})->with('stylesheets that draw a collapsed rail');
});
/**
* Everything that takes a collapsed shape and is not the rail element itself reads the value the
* rules above publish, one `@container style()` per rule, instead of a copy of the rail's own
* seven conditions the reason `--md-navigation-rail-value` is published at all (UPGRADE.md,
* "From 2.0.0 to 2.1.0"). This walks every copy across navigation-rail.css,
* navigation-rail-item.css, navigation-rail-section.css and layout/scaffold.css, fails if any of
* them spells the query differently or wraps no declarations, and outside navigation-rail.css,
* where the two rules above are the only rules allowed to fails if the old `:where()` copy of
* the rail's conditions comes back.
*/
dataset('stylesheets with a rail descendant that draws a collapsed shape', [
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'../layout/scaffold',
]);
it('reads the rail\'s published value with the same style container query in every copy', function (string $name) {
$css = ComponentStylesheet::read($name);
$rules = $css->rules();
$matches = array_values(array_filter(
$rules,
fn (array $rule): bool => array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')) !== [],
));
expect($matches)->not->toBeEmpty();
foreach ($matches as $rule) {
$containers = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')));
expect($containers)->toBe(['@container style(--md-navigation-rail-value: collapsed)'], "{$name}.css: `{$rule['selector']}` wraps a different container query")
->and($rule['declarations'])->not->toBeEmpty("{$name}.css: `{$rule['selector']}` wraps an empty container query");
}
if ($name === 'navigation-rail') {
return;
}
foreach ($rules as $rule) {
expect(str_contains($rule['selector'], "data-md-navigation-rail='collapsed'"))
->toBeFalse("{$name}.css: `{$rule['selector']}` copies the rail's conditions instead of querying its value");
}
})->with('stylesheets with a rail descendant that draws a collapsed shape');
@@ -1,143 +0,0 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
/**
* The navigation components, rewritten without Tailwind (plan step 36, the last audit group): each
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
* as px range queries, and is imported from the "Navigation" block of all.css. Every component
* stylesheet importing the stylesheets its view renders is tests/Feature/StylesheetsTest.php's
* now, folded into one dataset over every component view (step 42, `tabs`/`tab`'s N-16 pairing
* included) rather than kept once per group.
*
* The dataset grows by one name per component commit, the same rule ContainmentStylesheetsTest.php
* and the earlier groups follow. `navigation-bar` rejoined the dataset with the scaffold's own
* rewrite: its one rule reading `--material-bottom-bar` stayed unlayered only while `<x-scaffold>`
* published that variable through a Tailwind utility, which no layered rule could outrank; now
* scaffold.css sets it in `material.layout`, which this file's `material.components` always beats,
* so the whole stylesheet fits one `@layer material.components` block like every other entry here.
* `section-nav` renders `<x-tabs>`'s hooks rather than `<x-tabs>` itself (it reuses its stylesheet
* whole, tabs.css, N-16), so `navigationViews()` need not special-case it the way it does 'tabs'.
*/
function navigationComponents(): array
{
return [
'app-bar',
'toolbar',
'tabs',
'navigation-bar',
'navigation-bar-item',
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'section-nav',
'account-menu',
'theme-toggle',
'scheme-picker',
];
}
dataset('navigation components', navigationComponents());
/**
* The checks that read a stylesheet alone also run on the one no view renders by itself:
* navigation-item.css, the indicator fill and state layer the bar's and the rail's items share and
* both import. Nothing else reaches it the layout stylesheet tests skip component files they
* import so without this its layer, tokens and scoping would go unchecked.
*/
dataset('navigation stylesheets', [...navigationComponents(), 'navigation-item']);
/**
* The blade view(s) a dataset name's checks read. One file for every name but 'tabs', whose panel
* lives in a second view, tab.blade.php, sharing tabs.css with tabs.blade.php.
*
* @return list<string>
*/
function navigationViews(string $name): array
{
return $name === 'tabs' ? ['tabs', 'tab'] : [$name];
}
/**
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
*/
function assertNavigationBreakpointsInPx(ComponentStylesheet $css, string $label): void
{
foreach ($css->mediaQueries() as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', "{$label}: {$query}");
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
}
}
}
}
/**
* Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints
* are px, at one of M3's four.
*/
function assertNavigationTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
{
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
foreach ($shadows as [, $value]) {
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
}
assertNavigationBreakpointsInPx($css, $label);
}
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
}
})->with('navigation stylesheets');
it('writes no class list into the view but the interaction and text classes', function (string $name) {
foreach (navigationViews($name) as $view) {
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
}
})->with('navigation components');
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
assertNavigationTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
})->with('navigation stylesheets');
it('is imported from the navigation block of all.css', function (string $name) {
expect(allCssBlock('Navigation'))->toContain("@import './components/{$name}.css';");
})->with('navigation stylesheets');
it('scopes every element-wide selector to a data-md hook', function (string $name) {
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
// `dialog` or `:root` rule would restyle the application's own pages. One that applies only
// while a package component is on the page (`:root:has([data-md-toolbar-place…])`) is scoped.
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\((?:> )?\[data-md-)/m');
})->with('navigation stylesheets');
+7 -12
View File
@@ -57,7 +57,7 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Only a subtitle<\/p>/')
->not->toContain('data-md-modal-title');
// The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which
// The markup is the same at every width; modal.css (ComponentStylesheetsTest) decides which
// of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle.
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle]))')
@@ -100,7 +100,6 @@ it('divides a scrolling body from its header and actions only while content is h
->toContain('position: absolute;')
// A token that reduced motion sets to zero, so there the rule simply appears.
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/modal.css';")
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
->toContain("directive('dialog-dividers'")
@@ -209,7 +208,7 @@ it('slides a side sheet in from either edge', function () {
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
expect((string) $this->blade('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(false, JSON.parse(')
->toContain('...materialBottomSheet(JSON.parse(')
->toContain('data-md-bottom-sheet-scrim')
->toContain('x-trap.inert.noscroll="open"')
->toContain('x-layer="open" x-on:material-escape="close()"')
@@ -220,7 +219,7 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
->toContain('md-focus-ring md-touch-target')
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true, JSON.parse(')
->toContain('...materialBottomSheet(JSON.parse(')
->not->toContain('data-md-bottom-sheet-scrim')
->not->toContain('aria-modal')
->toContain('data-md-standard')
@@ -289,7 +288,7 @@ it('slides a side sheet out as it slid in, from its own edge in either direction
->toMatch('/<aside\s+x-cloak\s+x-show="open"\s+x-transition:enter="md-transition"\s+x-transition:leave="md-transition"/');
});
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
it('draws the drawer with the pane prop gone', function () {
$view = File::get(__DIR__.'/../../../resources/views/components/drawer.blade.php');
expect($view)
@@ -298,9 +297,7 @@ it('draws the drawer from a stylesheet imported from the containment block, with
->not->toContain('paneCloseOnEscape')
->not->toContain('pane-width')
->not->toContain('pane-close-on-escape')
->not->toContain('data-pane')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))
->toContain("@import './components/drawer.css';");
->not->toContain('data-pane');
});
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
@@ -330,10 +327,8 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun
->toContain('aria-label="Close"');
});
it('draws the bottom sheet from a stylesheet imported from the containment block and by the menu', function () {
expect(File::get(__DIR__.'/../../../resources/css/all.css'))
->toContain("@import './components/bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
it('draws the bottom sheet from a stylesheet imported by the menu', function () {
expect(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
->toContain("@import './bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/js/bottom-sheet.js'))
->toContain('data-md-bottom-sheet-handle')
+3 -16
View File
@@ -68,24 +68,11 @@ it('caps its width with M3\'s narrow measure or the wider steps', function () {
expect(layoutRoot((string) $this->blade('<x-pane width="40rem" />')))->not->toHaveKey('data-md-width');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />')))
->toMatchArray([
'<' => 'section',
'data-md-hide-below' => 'expanded',
'class' => 'checkout',
'style' => 'max-width: 50rem;',
'aria-labelledby' => 'checkout-title',
]);
});
it('keeps M3\'s margin on the body, once, in the layout layer', function () {
it('keeps M3\'s margin on the body, once', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/pane.css');
expect($css)->toContain('@layer material.layout')
->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
expect($css)->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
->toContain("@media (width >= 600px) {\n padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space300));")
->toContain("[data-md-pane-body] > * {\n --md-layout-margin: 0px;")
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/pane.css';");
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;");
});
+12 -11
View File
@@ -56,24 +56,25 @@ it('draws the first frame on the server, at the value', function () {
->toContain('<path d="M36.579 27.01A18 18 0 1 1 12.99 3.421" />');
});
it('draws the active indicator in the colour and the track in its container', function (string $color, array $declarations) {
$css = ComponentStylesheet::read('progress');
it('draws the active indicator in the colour and the track in its container', function (string $color) {
// Every hue but primary comes straight off the shared colour-role table (color.css,
// ColorTest.php): `stroke` reads `--md-container`, which is the hue's own container for
// every one of them.
expect((string) $this->blade('<x-progress value="50" :color="$color" />', ['color' => $color]))
->toContain("data-md-color=\"{$color}\"")
->and($css->declarations("[data-md-progress][data-md-color='{$color}']"))->toBe($declarations);
})->with([
'secondary' => ['secondary', ['color' => 'var(--md-sys-color-secondary)', 'stroke' => 'var(--md-sys-color-secondary-container)']],
'tertiary' => ['tertiary', ['color' => 'var(--md-sys-color-tertiary)', 'stroke' => 'var(--md-sys-color-tertiary-container)']],
'error' => ['error', ['color' => 'var(--md-sys-color-error)', 'stroke' => 'var(--md-sys-color-error-container)']],
]);
->toContain("data-md-color=\"{$color}\"");
})->with(['secondary', 'tertiary', 'error']);
it('falls back to primary for an unknown colour, secondary-container track included', function () {
$css = ComponentStylesheet::read('progress');
expect((string) $this->blade('<x-progress value="50" color="purple" />'))->toContain('data-md-color="primary"')
->and($css->declarations('[data-md-progress]'))->toMatchArray([
'color' => 'var(--md-sys-color-primary)',
'color' => 'var(--md-color, var(--md-sys-color-primary))',
'stroke' => 'var(--md-container, var(--md-sys-color-secondary-container))',
])
// Unlike every other hue, primary keeps a track of its own: the shared table's `primary`
// entry is plain primary-container, which progress does not want.
->and($css->declarations("[data-md-progress][data-md-color='primary']"))->toBe([
'stroke' => 'var(--md-sys-color-secondary-container)',
]);
});
+3 -21
View File
@@ -7,11 +7,6 @@ it('lays its children side by side in a div, with nothing set but itself', funct
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-justify', 'data-md-wrap', 'data-md-stack-below']);
});
it('spaces its children with a spacing token only, and with none for anything else', function () {
expect(layoutRoot((string) $this->blade('<x-row gap="space100" />'))['data-md-gap'])->toBe('space100')
->and(layoutRoot((string) $this->blade('<x-row gap="tight" />'))['data-md-gap'])->toBe('none');
});
it('aligns its children across it and places them along it', function () {
foreach (['center', 'start', 'end', 'stretch', 'baseline'] as $align) {
expect(layoutRoot((string) $this->blade('<x-row :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
@@ -39,22 +34,9 @@ it('stacks below one of M3\'s breakpoints, and never below compact', function ()
}
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-row as="nav" hide-from="expanded" class="toolbar-row" style="min-height: 3rem" aria-label="Filters" />')))
->toMatchArray([
'<' => 'nav',
'data-md-hide-from' => 'expanded',
'class' => 'toolbar-row',
'style' => 'min-height: 3rem;',
'aria-label' => 'Filters',
]);
});
it('draws the row from a stylesheet in the layout layer, stacking at the breakpoint in px', function () {
it('draws the row from a stylesheet, stacking at the breakpoint in px', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/row.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;")
->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/row.css';");
expect($css)->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;")
->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;");
});
+6 -12
View File
@@ -197,15 +197,11 @@ it('stacks the actions row into a column through every branch a collapsed rail c
$css = scaffoldCss();
// The three width-independent branches share one :where() group; the four width-gated ones
// each keep their own @media block, since CSS cannot merge different queries — the same shape
// navigation-rail.css's own rewrite gave its internal parts.
// The actions row is always inside a rail, never the rail element itself, so it reads the
// rail's published value with one style container query instead of copying every branch a
// collapsed rail can take.
expect($css)
->toContain("&:where(\n [data-md-navigation-rail='collapsed'] *,\n [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,\n [data-md-navigation-rail='modal']:not([data-md-open]) *\n ) {\n flex-direction: column;\n }")
->toContain("@media (width < 600px) {\n &:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
->toContain("@media (width < 840px) {\n &:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
->toContain("@media (width >= 840px) {\n @media (width < 1200px) {\n &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }\n }")
->toContain("@media (width >= 1200px) {\n &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }");
->toContain("@container style(--md-navigation-rail-value: collapsed) {\n flex-direction: column;\n }");
});
it('places each slot once', function () {
@@ -264,8 +260,7 @@ it('marks the scaffold, and draws the FAB after the page\'s bar and before the p
it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar, in the layout layer', function () {
$css = scaffoldCss();
expect($css)->toContain('@layer material.layout')
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
expect($css)->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
->toContain("@media (width >= 600px) {\n --md-scaffold-fab-margin: var(--md-sys-measurement-space300);")
->toContain('max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)))')
->toContain('+ var(--material-snackbar-height, 0px)')
@@ -273,8 +268,7 @@ it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar,
// The content region already has the window margin, so a pane inside draws none.
// Only the region's own <main>: one an application nests in the page takes none of this.
->toContain("[data-md-scaffold-content] > main {\n --md-layout-margin: 0px;")
->not->toContain('[data-md-scaffold] main')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/scaffold.css';");
->not->toContain('[data-md-scaffold] main');
});
it('imports the stylesheets of the navigation rail, the navigation bar and the snackbar host', function () {
@@ -27,7 +27,8 @@ it('draws a radio per generated profile, bound, named and previewing on change',
->toContain('Applies after saving')
->toContain('data-md-scheme-picker-option="indigo"')
->toContain('data-md-scheme-picker-option="teal"')
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
->toContain('class="md-visually-hidden"')
->toMatch('/<input\s+class="md-visually-hidden"\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
->toContain('>Teal</span>')
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
+1 -2
View File
@@ -117,6 +117,5 @@ it('draws the bar and the view from its stylesheet, at M3\'s widths', function (
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {")
// Leaving full screen, the header bar fades with the view instead of going on the first frame.
->toContain('[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {')
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';");
->not->toMatch('/\\d(?:\\.\\d+)?rem/');
});
+2 -5
View File
@@ -150,8 +150,7 @@ it('draws the checkbox from its stylesheet, on the row the selection controls sh
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
->toContain('[data-md-selection-row]')
->toContain('@layer material.components')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/checkbox.css';");
->toContain('@layer material.components');
});
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
@@ -159,7 +158,6 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
expect($css)->toContain("@import './selection.css';")
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/radio.css';")
->and((string) $this->blade('<x-radio name="size" class="app-field-spacing" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
->toMatch('/data-md-radio\\s+class="app-field-spacing" style="order: 1"/');
});
@@ -167,6 +165,5 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
it('draws the switch from its stylesheet', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/toggle.css'))
->toContain("@import './selection.css';")
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/toggle.css';");
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/');
});
+26 -17
View File
@@ -4,6 +4,7 @@ use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
use Livewire\Component;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('is a labelled native range input under a drawing only the script touches', function () {
$html = (string) $this->blade('<x-slider label="Volume" name="volume" value="40" :min="0" :max="200" :step="5" id="volume" />');
@@ -46,8 +47,7 @@ it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track',
->and($css)
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/slider.css';");
->toContain("@import './icon.css';");
});
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
@@ -249,23 +249,32 @@ it('keeps a range slider horizontal, as M3 asks', function () {
->not->toContain('aria-orientation');
});
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
it('draws in the colour and its container, and greys out when disabled', function (string $color) {
// Every hue but primary comes straight off the shared colour-role table (color.css,
// ColorTest.php): the active pair is `--md-color`/`--md-on-color`, the inactive one is the
// hue's own `--md-container`/`--md-on-container`, which for every one of them is its container.
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
->toMatch('/<input[^>]*\sdisabled[\s>]/');
})->with(['primary', 'tertiary', 'error', 'unknown' => 'pink']);
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
it('reads the shared colour-role table, keeping its own secondary-container for primary', function () {
$css = ComponentStylesheet::read('slider');
if ($color === 'primary' || $color === 'pink') {
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
} else {
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
}
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
})->with([
'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'error', 'error-container'],
'unknown' => ['pink', 'primary', 'secondary-container'],
]);
expect($css->declarations('[data-md-slider]'))->toMatchArray([
'--slider-active' => 'var(--md-color, var(--md-sys-color-primary))',
'--slider-inactive' => 'var(--md-container, var(--md-sys-color-secondary-container))',
'--slider-on-active' => 'var(--md-on-color, var(--md-sys-color-on-primary))',
'--slider-on-inactive' => 'var(--md-on-container, var(--md-sys-color-on-secondary-container))',
])
// Unlike every other hue, primary keeps its inactive track of its own: the shared table's
// `primary` entry is plain primary-container, which the slider does not want.
->and($css->declarations("[data-md-slider][data-md-color='primary']"))->toBe([
'--slider-inactive' => 'var(--md-sys-color-secondary-container)',
'--slider-on-inactive' => 'var(--md-sys-color-on-secondary-container)',
])
->and($css->declarations('[data-md-slider]:has([data-md-slider-input]:disabled)'))->toMatchArray([
'--slider-active' => 'var(--slider-disabled-active)',
'--slider-inactive' => 'var(--slider-disabled-inactive)',
]);
});
+1 -4
View File
@@ -23,10 +23,7 @@ it('leaves the corners to the pair, growing the inner ones under the finger', fu
expect($css->declarations('[data-md-split-button] [data-md-split]'))->toBe([
'--md-split-button-corner' => 'var(--md-split-button-inner)',
'border-start-start-radius' => 'var(--md-split-button-corner)',
'border-end-start-radius' => 'var(--md-split-button-corner)',
'border-start-end-radius' => 'var(--md-split-button-corner)',
'border-end-end-radius' => 'var(--md-split-button-corner)',
'border-radius' => 'var(--md-split-button-corner)',
])
->and($css->declarations('[data-md-split-button] [data-md-split]:active'))->toBe(['--md-split-button-corner' => 'var(--md-split-button-inner-pressed)'])
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true']"))->toBe(['--md-split-button-corner' => 'var(--md-split-button-full)'])
+3 -22
View File
@@ -7,12 +7,6 @@ it('stacks its children in a div with no gap and no alignment of its own', funct
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-hide-below', 'data-md-hide-from']);
});
it('spaces its children with a spacing token only, and with none for anything else', function () {
expect(layoutRoot((string) $this->blade('<x-stack gap="space200" />'))['data-md-gap'])->toBe('space200')
->and(layoutRoot((string) $this->blade('<x-stack gap="16px" />'))['data-md-gap'])->toBe('none')
->and(layoutRoot((string) $this->blade('<x-stack gap="space1000" />'))['data-md-gap'])->toBe('none');
});
it('aligns its children across it', function () {
foreach (['stretch', 'start', 'center', 'end'] as $align) {
expect(layoutRoot((string) $this->blade('<x-stack :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
@@ -21,25 +15,12 @@ it('aligns its children across it', function () {
expect(layoutRoot((string) $this->blade('<x-stack align="baseline" />')))->not->toHaveKey('data-md-align');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />')))
->toMatchArray([
'<' => 'ul',
'data-md-hide-below' => 'medium',
'data-md-hide-from' => 'large',
'class' => 'steps',
'style' => 'max-width: 40rem;',
]);
});
it('draws the stack from a stylesheet in the layout layer, imported by all.css', function () {
it('draws the stack from a stylesheet in the layout layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/stack.css');
expect($css)->toContain('@layer material.layout')
->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
expect($css)->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
// A stack inside another never takes its parent's gap.
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/stack.css';");
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;");
});
it('keeps a button at its label\'s width while the stack stretches the rest', function () {
@@ -50,23 +50,11 @@ it('docks the supporting pane as a bottom sheet below expanded, opened by its ha
->and(layoutRoot((string) $this->blade('<x-supporting-pane compact="drawer" />'))['data-md-compact'])->toBe('below');
});
it('takes the element, the visibility props and the caller\'s class and style', function () {
expect(layoutRoot((string) $this->blade('<x-supporting-pane as="main" hide-from="extra-large" class="editor" style="min-height: 20rem" />')))
->toMatchArray([
'<' => 'main',
'data-md-hide-from' => 'extra-large',
'class' => 'editor',
'style' => 'min-height: 20rem;',
]);
});
it('places the supporting pane at M3\'s widths from expanded, in the layout layer', function () {
it('places the supporting pane at M3\'s widths from expanded', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/supporting-pane.css');
expect($css)->toContain('@layer material.layout')
->toContain("@media (width >= 840px) {\n grid-template-columns: minmax(0, 1fr) 360px;")
expect($css)->toContain("@media (width >= 840px) {\n grid-template-columns: minmax(0, 1fr) 360px;")
->toContain("@media (width >= 1200px) {\n grid-template-columns: minmax(0, 1fr) 412px;")
->toContain("[data-md-supporting-pane][data-md-width='split'] {\n grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);")
->toContain('inset-block-end: max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)));')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/supporting-pane.css';");
->toContain('inset-block-end: max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)));');
});
+2 -4
View File
@@ -61,13 +61,11 @@ it('puts the caller\'s class and style on the surface untouched', function () {
it('draws the surface from a stylesheet in the layout layer, imported by all.css', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/surface.css');
expect($css)->toContain('@layer material.layout')
->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
expect($css)->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
->toContain('background-color: var(--md-sys-color-surface-container-high);')
->toContain('border: 1px solid var(--md-sys-color-outline-variant);')
->toContain("@import './spacing.css';")
->toContain("@import './visibility.css';")
// A pane on a surface keeps its content off the surface's edge again.
->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/surface.css';");
->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;");
});
+8 -8
View File
@@ -22,7 +22,7 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
->toContain('data-md-tab-indicator')
->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"')
// The panel's id is the server's, so aria-controls never dangles (N-05).
// The panel's id is the server's, so aria-controls never dangles.
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
->toContain('>3</span>');
});
@@ -69,7 +69,7 @@ it('entangles the chosen tab with Livewire and renders the one the property name
->toContain(".entangle('tab').live")
->not->toContain('x-modelable')
->toMatch('/data-md-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
// The panel the property names is the one drawn, before Alpine entangles anything.
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
});
@@ -108,17 +108,17 @@ it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', functio
$css = ComponentStylesheet::read('tabs');
expect($css->declarations('[data-md-tabs-bar][data-md-scrollable]'))
// 52dp before the first of a scrollable set (N-10).
// 52dp before the first of a scrollable set.
->toHaveKey('padding-inline-start', '52px')
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
// A primary indicator is inset 2dp each side; a secondary one spans the tab.
->and($css->declarations('[data-md-tab-indicator]'))
->toHaveKey('inset-inline', '2px')
->and($css->declarations('[data-md-tabs-bar][data-md-variant=\'secondary\'] [data-md-tab-indicator]'))
->toHaveKey('inset-inline', '0')
// The ring is 2px outside, as everywhere else in the package (N-20).
// The ring is 2px outside, as everywhere else in the package.
->and($css->declarations('[data-md-tab]:focus-visible'))
->toBe(['color' => 'var(--md-sys-color-on-surface)', 'outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
// A link marked as the page is the chosen tab too (N-21).
// A link marked as the page is the chosen tab too.
->and($css->declarations('[data-md-tab]:is([aria-selected=\'true\'], [aria-current=\'page\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
});
@@ -200,7 +200,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
->toMatch('/data-md-section-nav-picker.*Security.*>\s*1\s*<.*<nav/s');
// The picker shows only below medium, the tab bar only from it (N-16's compact fallback).
// The picker shows only below medium, the tab bar only from it (the compact fallback).
$css = ComponentStylesheet::read('section-nav');
expect($css->declarations('[data-md-section-nav-picker]', ['@media (width >= 600px)']))
@@ -218,7 +218,7 @@ it('marks the section whose url is the request\'s, and scrolls many sections', f
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows.
->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/')
->not->toContain('grid-cols-')
// Its label is `<x-tabs>`'s anatomy, drawn by tabs.css, not a one-line `md-truncate`.
File diff suppressed because it is too large Load Diff
+13 -72
View File
@@ -288,24 +288,12 @@ it('sends an expired form back to its page to refresh', function () {
/**
* Plan step 36: `errors::minimal`'s class lists moved into
* resources/css/components/error-page.css, keyed on `data-md-error-*`. Its view lives outside
* resources/views/components/, so it is not in ContainmentStylesheetsTest's dataset (whose
* "imports what its view renders" check reads a fixed resources/views/components/<name>.blade.php
* path); this is its own small stylesheet-shape test instead, checking the same things.
* resources/views/components/, so it is not in ComponentStylesheetsTest's view-scoped datasets
* (whose "imports what its view renders" and "no class list" checks read a fixed
* resources/views/components/<name>.blade.php path) those two stay here, reading its real view
* path, while the stylesheet-only checks (shape, tokens, block import) run there instead, on the
* `error-page` entry of its dataset.
*/
it('draws the error layout from a stylesheet shaped like every package stylesheet', function () {
$css = ComponentStylesheet::read('error-page');
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path('error-page')).'/'.$import))->toBeTrue("error-page.css imports {$import}, which does not exist");
}
});
it('imports the stylesheet of every component the error layout renders', function () {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php'), $tags);
@@ -322,33 +310,6 @@ it('writes no class list into the error layout but the interaction and text clas
expect(ViewClasses::violations(File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php')))->toBe([]);
});
it('takes its values from the tokens and its breakpoints in px', function () {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
preg_match_all('/@media\s*([^{]+)\{/', $source, $queries);
foreach ($queries[1] as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $query);
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
}
}
}
});
it('is imported from the containment block of all.css', function () {
expect(allCssBlock('Containment'))->toContain("@import './components/error-page.css';");
});
it('styles the body only when it holds the error layout, and turns the shape on both paths', function () {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
@@ -361,7 +322,7 @@ it('styles the body only when it holds the error layout, and turns the shape on
});
/**
* Plan step 40: the fallback is `Stylesheets::bundle()` of the foundation and the error layout
* Plan step 40: the fallback is the prebuilt bundle of the foundation and the error layout
* (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries
* their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the
* bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face`
@@ -382,13 +343,15 @@ it('bundles the foundation and the error layout into the fallback, without an im
->toContain('[data-md-error-shape]')
->toContain('[data-md-button]')
->toContain('[data-md-shape]')
// The foundation's reset and tokens travelled in too, not only the error layout.
->toContain('[data-md-icon]')
// The foundation's reset, tokens and page travelled in too, not only the error layout.
->toContain('box-sizing: border-box')
->toContain('--md-sys-color-surface');
->toContain('--md-sys-color-surface')
->toMatch('/html \{\s*background-color/');
});
/**
* Plan step 46: what the page inlines beside a build is `Stylesheets::bundle()` of the error layout
* Plan step 46: what the page inlines beside a build is the prebuilt bundle of the error layout
* alone. It reaches no `@font-face` the package's only one is tokens/font.css's, which only the
* foundation imports, and the application's build serves it and no `url()` a request from the
* page would have to resolve, so nothing needs dropping from it the way the fallback drops the face.
@@ -398,33 +361,11 @@ it('inlines beside a build the error layout\'s bundle, which holds no font face,
$css = (string) ErrorPage::layoutStyles();
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
expect($css)->toBe(Stylesheets::bundle([$layout]))
expect($css)->toBe(File::get(__DIR__.'/../../resources/dist/error-page.css'))
->and(array_map(basename(...), Stylesheets::resolvedFiles([$layout])))
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css'])
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css', 'color.css'])
->and($plain)->not->toContain('@font-face')
->not->toContain('@import')
->not->toMatch('/\burl\(/i')
->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;');
});
/**
* The bundle's comments name `@font-face` too (foundation/base.css's and error-page.css's headers).
* Read as the rule, each mention cut from inside its comment to the end of the next balanced block
* and left the comment open, so base.css's `html` rule and icon.css's rules went missing from the
* fallback. Only the real block may leave: what remains is the bundle, rule for rule, without it.
*/
it('drops only the real font face from the fallback, not the rules after a comment that names one', function () {
$files = [
realpath(__DIR__.'/../../resources/css/foundation.css'),
realpath(__DIR__.'/../../resources/css/components/error-page.css'),
];
$withoutComments = fn (string $css): string => (string) preg_replace('~/\*.*?\*/~s', '', $css);
$bundle = Stylesheets::bundle($files);
expect(substr_count($withoutComments($bundle), '@font-face'))->toBe(1)
->and(substr_count($bundle, '@font-face'))->toBeGreaterThan(1)
->and($withoutComments((string) ErrorPage::fallbackStyles()))
->toStartWith($withoutComments((string) preg_replace('/@font-face\s*\{[^{}]*\}/', '', $bundle)))
->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);/')
->toContain('[data-md-icon] {');
});
+10 -14
View File
@@ -4,17 +4,19 @@ use Illuminate\Support\Facades\Process;
/**
* PHP cannot open a woff2 without the Brotli extension, so the axes are read by Node:
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node the test is skipped, the
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node every test here is skipped, the
* way `material:scheme` reports the same missing binary instead of guessing.
*/
it('keeps the variable axes the typescale depends on in the packaged font', function () {
$node = config('livewire-material.node', 'node');
beforeEach(function () {
$this->node = config('livewire-material.node', 'node');
if (Process::run([$node, '--version'])->failed()) {
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
if (Process::run([$this->node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/fontkit')) {
$this->markTestSkipped("Node ({$this->node}) or fontkit is missing; bin/check-font.mjs reads the woff2's axes.");
}
});
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs']);
it('keeps the variable axes the typescale depends on in the packaged font', function () {
$result = Process::run([$this->node, __DIR__.'/../../bin/check-font.mjs']);
expect($result->successful())->toBeTrue($result->errorOutput());
@@ -27,14 +29,8 @@ it('keeps the variable axes the typescale depends on in the packaged font', func
->and($font['axes']['ROND'])->toMatchArray(['min' => 0, 'max' => 100]);
});
it('fails loudly when the font has no variable axes at all', function () {
$node = config('livewire-material.node', 'node');
if (Process::run([$node, '--version'])->failed()) {
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
}
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
it('fails loudly when the font file cannot be opened', function () {
$result = Process::run([$this->node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
expect($result->failed())->toBeTrue()
->and($result->errorOutput())->toContain('no-such-font.woff2');
-6
View File
@@ -33,12 +33,6 @@ it('compiles an unprefixed component to the same view on every machine', functio
->not->toContain(hash('xxh128', LivewireMaterialServiceProvider::componentPath()));
});
it('still renders a view compiled when the namespace came from the component path', function () {
$legacy = hash('xxh128', LivewireMaterialServiceProvider::componentPath());
expect(view()->exists($legacy.'::input'))->toBeTrue();
});
it('registers the components under a configured prefix', function () {
config(['livewire-material.prefix' => 'm']);
+13 -17
View File
@@ -3,17 +3,14 @@
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
/**
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, bundled by
* `Stylesheets::bundle()` and served without the application's Vite build, and the two package
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, prebuilt into
* resources/dist/showcase.css and served without the application's Vite build, and the two package
* folders its relative `url()`s point into.
*/
afterEach(function () {
Stylesheets::resetCache();
});
it('serves the bundle as long-cached CSS at the hash of its own content', function () {
$response = $this->get(ShowcaseAssetController::url())
->assertOk()
@@ -35,11 +32,14 @@ it('redirects a hash that does not match the current bundle to the current one',
it('serves the font and svg files the bundle references, mime-typed by extension', function () {
$css = $this->get(ShowcaseAssetController::url())->getContent();
// The stylesheet sits in assets/css/, so its relative `../fonts/…` and `../svg/…` land on the asset route.
expect(dirname(parse_url(ShowcaseAssetController::url(), PHP_URL_PATH)))->toBe(route('livewire-material.asset', ['path' => 'css'], false))
->and($css)->toContain("url('../fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')")
->toContain('url("../svg/symbols/outlined/check.svg")');
$fontUrl = route('livewire-material.asset', ['path' => 'fonts/google-sans-flex/GoogleSansFlex-Latin.woff2'], false);
$svgUrl = route('livewire-material.asset', ['path' => 'svg/symbols/outlined/check.svg'], false);
expect($css)->toContain($fontUrl)->toContain($svgUrl);
$this->get($fontUrl)
->assertOk()
->assertHeader('Content-Type', 'font/woff2')
@@ -93,7 +93,6 @@ it('bundles the application generated scheme, unlayered, when one sits beside it
file_put_contents($dir.'/material-scheme.css', ":root { --md-showcase-test-marker: #123456; }\n");
config(['livewire-material.scheme' => $dir.'/material-scheme.json']);
Stylesheets::resetCache();
try {
$this->get(ShowcaseAssetController::url())
@@ -105,15 +104,12 @@ it('bundles the application generated scheme, unlayered, when one sits beside it
}
});
it('falls back to Scheme::load() in the default shape when no generated stylesheet is found', function () {
it('falls back to the scheme JSON in the generated shape when no generated stylesheet is found', function () {
config(['livewire-material.scheme' => sys_get_temp_dir().'/livewire-material-showcase-missing-'.Str::random(8).'.json']);
Stylesheets::resetCache();
$css = $this->get(ShowcaseAssetController::url())->assertOk()->getContent();
// The fallback's own one-line selector list, distinct from tokens/scheme.css's multi-line one
// (already in all.css), so its presence here can only come from the appended fallback block.
expect($css)->toContain("[data-contrast='medium'], [data-contrast='medium'][data-theme='light'], [data-contrast='medium'] [data-theme='light']");
expect($css)->toEndWith(SchemeStylesheet::withProfiles(...Scheme::forStylesheet()))
->toContain("[data-contrast='medium'][data-theme='dark']");
});
it('mounts none of the showcase asset routes when the showcase is off', function () {
@@ -122,7 +118,7 @@ it('mounts none of the showcase asset routes when the showcase is off', function
$this->refreshApplication();
try {
$this->get('/material/showcase.0000000000000000.css')->assertNotFound();
$this->get('/material/assets/css/showcase.0000000000000000.css')->assertNotFound();
$this->get('/material/assets/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')->assertNotFound();
} finally {
putenv('MATERIAL_SHOWCASE=true');
+36 -272
View File
@@ -4,84 +4,25 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* `Stylesheets::bundle()` (plan step 37) is what the showcase and the error page's fallback serve
* without the application's own Vite build, so it has to do in PHP what Vite's bundled
* postcss-import already does for a normal build: inline every `@import` once, first occurrence
* kept, and leave a `url()` reachable from wherever the bundle ends up.
* The stylesheets the package serves on its own the showcase's and the error page's, outside an
* application's Vite build are prebuilt into resources/dist/ by `npm run build:stylesheets`
* (bin/stylesheets.mjs), with Vite's own postcss-import: every `@import` inlined once, first
* occurrence kept. This file pins that the committed files are current, the Vite deduplication they
* rely on, and `Stylesheets::resolvedFiles()`, the import graph `DesignGuard` reads.
*
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
* file's own header, layer statement and imports) a concern that holds whether or not anything
* ever bundles them. This file is about the bundler itself: what `bundle()` produces, and, at the
* end, a real Vite build to pin the claim it is modelled on.
*
* Every helper here is named `stylesheetsBundle*` rather than reusing StylesheetsTest.php's
* `stylesheet*` names the two files may run in the same Pest process, and a duplicate top-level
* function name is a fatal error, the same reason the four component-group stylesheet test files
* each name their own `assert…TokensAndPxBreakpoints()` uniquely.
* file's own header, layer statement and imports). `stylesheetsBundlePath()` is named apart from
* StylesheetsTest.php's `stylesheetPath()` for the same reason: the two files may run in the same
* Pest process, and a duplicate top-level function name is a fatal error.
*/
function stylesheetsBundlePath(string $path = ''): string
{
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
return (string) realpath(ComponentStylesheet::cssPath($path));
}
/**
* `$css` with every comment and quoted string blanked out the same idea StylesheetsTest.php's
* `stylesheetWithoutComments()` uses, kept local here for the reason above.
*/
function stylesheetsBundleWithoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The first line of a stylesheet's header comment, the sentence naming what it draws unique per
* file (checked below), untouched by `bundle()` since it only ever rewrites an `@import` or a
* `url()`, never a comment. A reliable per-file fingerprint in `bundle()`'s output, which is not
* minified.
*/
function stylesheetsBundleMarker(string $file): string
{
$lines = explode("\n", File::get($file));
return trim((string) preg_replace('/^\s*\*\s?/', '', $lines[1] ?? ''));
}
/**
* @return list<string>
*/
function stylesheetsBundleComponentAndLayoutFiles(): array
{
return collect(File::files(stylesheetsBundlePath('components')))
->merge(File::files(stylesheetsBundlePath('layout')))
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
->values()
->all();
}
it('bundles all.css with every import resolved, foundation first, every component and layout file once', function () {
$bundled = Stylesheets::bundle([stylesheetsBundlePath('all.css')]);
expect(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
$files = stylesheetsBundleComponentAndLayoutFiles();
$markers = collect($files)->mapWithKeys(fn (string $file): array => [$file => stylesheetsBundleMarker($file)]);
expect($markers)->toHaveCount(count($files))
->and($markers->unique()->count())->toBe($markers->count(), 'two files share a header marker');
foreach ($markers as $file => $marker) {
expect(substr_count($bundled, $marker))->toBe(1, basename($file).' should appear exactly once in the bundle');
}
$foundationPosition = strpos($bundled, stylesheetsBundleMarker(stylesheetsBundlePath('foundation.css')));
$earliestOtherPosition = $markers->map(fn (string $marker): int => (int) strpos($bundled, $marker))->min();
expect($foundationPosition)->toBeInt()
->and($foundationPosition)->toBeLessThan($earliestOtherPosition);
});
it('resolves the files an entry imports, transitively, without bundling any content', function () {
$files = Stylesheets::resolvedFiles([stylesheetsBundlePath('components/split-button.css')]);
@@ -93,7 +34,7 @@ it('resolves the files an entry imports, transitively, without bundling any cont
);
});
it('resolves past what an application entry imports and bundle() refuses, without throwing', function () {
it('resolves past what an application entry imports and only a Vite build resolves, without throwing', function () {
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
File::makeDirectory($dir);
File::put($dir.'/theme.css', '.theme {}');
@@ -114,131 +55,6 @@ it('resolves past what an application entry imports and bundle() refuses, withou
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.css']);
});
it('keeps a file two others import once, at its first position', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/shared.css", "@layer material.reset;\n.shared { color: green; }\n");
File::put("{$dir}/a.css", "@import './shared.css';\n.a { color: red; }\n");
File::put("{$dir}/b.css", "@import './shared.css';\n.b { color: blue; }\n");
try {
$bundled = Stylesheets::bundle(["{$dir}/a.css", "{$dir}/b.css"]);
expect(substr_count($bundled, '.shared { color: green; }'))->toBe(1)
->and(strpos($bundled, '.shared'))->toBeLessThan(strpos($bundled, '.a { color: red; }'))
->and($bundled)->toContain('.b { color: blue; }')
->and(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
} finally {
File::deleteDirectory($dir);
}
});
it('inlines every plain import form once, and nothing written in a comment or a string', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory("{$dir}/components", recursive: true);
foreach (['double', 'quoted-url', 'bare-url'] as $name) {
// A leading byte-order mark and @charset mean nothing past a stylesheet's first byte.
File::put("{$dir}/components/{$name}.css", "\u{FEFF}@charset \"UTF-8\";\n.{$name} {}\n");
}
File::put("{$dir}/entry.css", <<<'CSS'
/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */
@layer a, b;
@import "./components/double.css";
@import url('./components/quoted-url.css');
@IMPORT url( ./components/bare-url.css );
@import './components/../components/double.css';
.entry::before { content: "@import './components/never.css'; url(never.png)"; }
CSS);
try {
$bundled = Stylesheets::bundle(["{$dir}/entry.css"]);
expect(substr_count($bundled, '.double {}'))->toBe(1)
->and($bundled)->toContain('.quoted-url {}')
->toContain('.bare-url {}')
->toContain("/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */")
->toContain(".entry::before { content: \"@import './components/never.css'; url(never.png)\"; }")
->not->toContain('@charset')
->not->toContain("\u{FEFF}")
// The two left are the comment's and the string's.
->and(substr_count(strtolower($bundled), '@import'))->toBe(2);
} finally {
File::deleteDirectory($dir);
}
});
it('throws, naming the file, for an import the bundle could not inline without changing its meaning', function (string $css, string $reason) {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/x.css", '.x {}');
File::put("{$dir}/entry.css", $css);
try {
Stylesheets::bundle(["{$dir}/entry.css"]);
$this->fail("Stylesheets::bundle() should have thrown for {$css}");
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('entry.css')->toContain($reason);
} finally {
File::deleteDirectory($dir);
}
})->with([
'a layer() condition' => ["@import './x.css' layer(x);", 'without layer()'],
'a supports() condition' => ["@import './x.css' supports(display: grid);", 'without layer()'],
'a media condition' => ['@import url(./x.css) screen;', 'without layer()'],
'an absolute URL' => ["@import 'https://example.com/x.css';", 'absolute URL'],
'an absolute path' => ["@import '/x.css';", 'absolute URL'],
'a package specifier' => ["@import 'tailwindcss';", '"tailwindcss", which does not exist'],
'an import after a rule' => [".a {}\n@import './x.css';", 'after a rule'],
'an import inside a block' => ["@layer b { @import './x.css'; }", 'inside a block'],
]);
it('rewrites a relative url() against the entry file\'s directory, or against $base when given', function () {
// Mirrors resources/css/'s own shape: an entry (all.css) sitting where the bundle is "from",
// a component one level under it (components/menu.css), and an asset one level above that
// again (resources/fonts/, resources/svg/, siblings of resources/css/) — so a url() two
// directories away from the file that wrote it lands one directory away from the entry.
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory("{$dir}/css/components", recursive: true);
File::makeDirectory("{$dir}/fonts");
File::put("{$dir}/css/entry.css", "@import './components/widget.css';\n");
File::put("{$dir}/css/components/widget.css", <<<'CSS'
@font-face { src: url('../../fonts/widget.woff2') format('woff2'); }
.widget { background: url("icon.svg"), url(https://example.com/a.png), url(#gradient), url(data:image/png;base64,AA==); }
CSS);
try {
$bundled = Stylesheets::bundle(["{$dir}/css/entry.css"]);
// Resolved against the entry's own directory (css/) when no $base is given: the font
// climbs out of css/ to the sibling fonts/ directory, the same shape resources/css/ has
// relative to resources/fonts/; the icon, sitting beside widget.css, stays inside components/.
expect($bundled)->toContain("url('../fonts/widget.woff2')")
->toContain('url("components/icon.svg")')
// An absolute URL, a # fragment and a data: URI are left exactly as written.
->toContain('url(https://example.com/a.png)')
->toContain('url(#gradient)')
->toContain('url(data:image/png;base64,AA==)');
// $base stands for wherever the entry itself (the thing "resources/css/" is here) is
// served from: the icon, which never left that directory, lands right under $base, while
// the font's one directory climbed above the entry the same way it did with no $base at
// all, so it climbs one directory above $base too — off the end of "assets", not inside it.
Stylesheets::resetCache();
$withBase = Stylesheets::bundle(["{$dir}/css/entry.css"], 'https://cdn.example.com/assets');
expect($withBase)->toContain("url('https://cdn.example.com/fonts/widget.woff2')")
->toContain('url("https://cdn.example.com/assets/components/icon.svg")')
->toContain('url(https://example.com/a.png)');
} finally {
File::deleteDirectory($dir);
}
});
/**
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
@@ -252,7 +68,7 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
$walk = function (string $file, array $stack) use (&$walk, &$edges): void {
expect(in_array($file, $stack, true))->toBeFalse('import cycle: '.implode(' → ', array_map(basename(...), [...$stack, $file])));
preg_match_all("/@import\\s+'([^']+)'/", stylesheetsBundleWithoutComments(File::get($file)), $matches);
preg_match_all("/@import\\s+'([^']+)'/", ComponentStylesheet::withoutComments(File::get($file)), $matches);
foreach ($matches[1] as $import) {
$edges++;
@@ -265,84 +81,32 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
expect($edges)->toBeGreaterThan(100);
});
it('does not loop on a cycle, and keeps both files\' rules', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
/**
* resources/dist/ is committed so applications need no Node; a stylesheet change without
* `npm run build:stylesheets` would leave the showcase and the error pages serving the old rules.
*/
it('keeps the prebuilt stylesheets in resources/dist/ in step with resources/css/', function () {
$node = config('livewire-material.node', 'node');
File::put("{$dir}/a.css", "@import './b.css';\n.a {}\n");
File::put("{$dir}/b.css", "@import './a.css';\n.b {}\n");
try {
$bundled = Stylesheets::bundle(["{$dir}/a.css"]);
expect($bundled)->toContain('.a {}')->toContain('.b {}');
} finally {
File::deleteDirectory($dir);
if (Process::run([$node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/vite')) {
$this->markTestSkipped("Node ({$node}) or Vite is missing; `npm run build:stylesheets` builds resources/dist/.");
}
});
it('throws naming the importer when an import does not exist', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/broken.css", "@import './missing.css';\n");
$outDir = sys_get_temp_dir().'/livewire-material-dist-'.Str::random(8);
try {
Stylesheets::bundle(["{$dir}/broken.css"]);
$result = Process::run([$node, __DIR__.'/../../bin/stylesheets.mjs', $outDir]);
$this->fail('Stylesheets::bundle() should have thrown.');
} catch (RuntimeException $exception) {
expect($exception->getMessage())->toContain('broken.css')->toContain('missing.css');
expect($result->successful())->toBeTrue($result->errorOutput());
foreach (File::files($outDir) as $built) {
$committed = __DIR__.'/../../resources/dist/'.$built->getFilename();
expect(is_file($committed) ? File::get($committed) : null)
->toBe($built->getContents(), "resources/dist/{$built->getFilename()} is stale: run `npm run build:stylesheets`");
}
} finally {
File::deleteDirectory($dir);
}
});
it('caches per file list and mtime, and resetCache() forces a fresh read', function () {
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
File::makeDirectory($dir);
File::put("{$dir}/cached.css", '.before {}');
touch("{$dir}/cached.css", 1_700_000_000);
try {
expect(Stylesheets::bundle(["{$dir}/cached.css"]))->toBe('.before {}');
// The mtime is unchanged, so the cache serves the old content even though the file itself
// now holds something else.
File::put("{$dir}/cached.css", '.changed-but-same-mtime {}');
touch("{$dir}/cached.css", 1_700_000_000);
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.before {}', 'an unchanged mtime should still be served from the cache');
Stylesheets::resetCache();
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.changed-but-same-mtime {}', 'resetCache() should force a fresh read');
// A later mtime invalidates the cache on its own, with no resetCache() needed.
File::put("{$dir}/cached.css", '.after {}');
touch("{$dir}/cached.css", 1_700_000_100);
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
->toBe('.after {}', 'a changed mtime should invalidate the cache on its own');
// So does a later mtime on a file the bundle reached only through an @import — a changed
// button.css must not leave a cached all.css bundle standing.
File::put("{$dir}/entry.css", "@import './cached.css';\n");
touch("{$dir}/entry.css", 1_700_000_000);
expect(Stylesheets::bundle(["{$dir}/entry.css"]))->toContain('.after {}');
File::put("{$dir}/cached.css", '.nested-change {}');
touch("{$dir}/cached.css", 1_700_000_200);
expect(Stylesheets::bundle(["{$dir}/entry.css"]))
->toContain('.nested-change {}')
->and(Stylesheets::bundle(["{$dir}/entry.css"], ''))->toContain('.nested-change {}');
} finally {
Stylesheets::resetCache();
File::deleteDirectory($dir);
File::deleteDirectory($outDir);
}
});
@@ -398,11 +162,11 @@ function stylesheetsBundleLeafRules(string $css): array
}
/**
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler
* (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph): `all.css`, and
* tests/Fixtures/dedup-app.css, an entry shaped like an application's outside the package, the
* foundation, then split button, app bar, modal and pagination, which each import button.css, and
* button.css once more.
* The Vite deduplication the prebuilt stylesheets and every application's build rely on, pinned
* against the real bundler (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph):
* `all.css`, and tests/Fixtures/dedup-app.css, an entry shaped like an application's outside
* the package, the foundation, then split button, app bar, modal and pagination, which each
* import button.css, and button.css once more.
*
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
+85 -158
View File
@@ -1,10 +1,8 @@
<?php
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Support\Layout;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
@@ -18,87 +16,14 @@ use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
* below run over the whole tree `all.css` reaches (foundation, layout and components together)
* rather than the foundation alone, and one file it does not reach `showcase.css`, checked on
* its own further down. A component's own values (colours as roles, shadows as elevation levels,
* and so on) are each group's own concern, in tests/Feature/Components/*StylesheetsTest.php;
* `bundle()`, the PHP bundler `all.css` is built for, is tests/Feature/StylesheetsBundleTest.php's.
* and so on) are ComponentStylesheetsTest's own concern.
* The prebuilt bundles in resources/dist/ are tests/Feature/StylesheetsBundleTest.php's.
*/
const MATERIAL_LAYER_STATEMENT = '@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;';
function stylesheetPath(string $path = ''): string
{
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
}
/**
* A stylesheet without its comments; a quoted string keeps whatever it holds.
*/
function stylesheetWithoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The top-level statements (`@layer …;`, `@import …;`) and blocks of a stylesheet, in order, with
* whitespace collapsed in statements and preludes.
*
* @return list<array{statement: string}|array{prelude: string, body: string}>
*/
function stylesheetItems(string $css): array
{
$css = stylesheetWithoutComments($css);
$items = [];
$start = 0;
$depth = 0;
$quote = null;
$opening = 0;
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
$char = $css[$i];
if ($quote !== null) {
if ($char === '\\') {
$i++;
} elseif ($char === $quote) {
$quote = null;
}
continue;
}
if ($char === '"' || $char === "'") {
$quote = $char;
} elseif ($char === '{') {
if ($depth++ === 0) {
$opening = $i;
}
} elseif ($char === '}' && --$depth === 0) {
$items[] = [
'prelude' => (string) preg_replace('/\s+/', ' ', trim(substr($css, $start, $opening - $start))),
'body' => substr($css, $opening + 1, $i - $opening - 1),
];
$start = $i + 1;
} elseif ($char === ';' && $depth === 0) {
$items[] = ['statement' => preg_replace('/\s+/', ' ', trim(substr($css, $start, $i - $start))).';'];
$start = $i + 1;
}
}
expect(trim(substr($css, $start)))->toBe('', 'The stylesheet ends inside an unclosed rule.');
return $items;
}
/**
* The declarations of a flat block body, by property.
*
* @return array<string, string>
*/
function stylesheetDeclarations(string $body): array
{
return collect(explode(';', $body))
->map(fn (string $declaration): string => trim($declaration))
->filter()
->mapWithKeys(fn (string $declaration): array => [trim(strstr($declaration, ':', true)) => trim(substr(strstr($declaration, ':'), 1))])
->all();
return (string) realpath(ComponentStylesheet::cssPath($path));
}
/**
@@ -106,13 +31,13 @@ function stylesheetDeclarations(string $body): array
*/
function stylesheetBlock(string $css, string $prelude): string
{
foreach (stylesheetItems($css) as $item) {
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
if (($item['prelude'] ?? null) === $prelude) {
return $item['body'];
}
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
foreach (stylesheetItems($item['body']) as $inner) {
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body'])) as $inner) {
if (($inner['prelude'] ?? null) === $prelude) {
return $inner['body'];
}
@@ -130,8 +55,8 @@ function stylesheetBlock(string $css, string $prelude): string
*/
function stylesheetImports(string $file): array
{
return collect(stylesheetItems(File::get($file)))
->map(fn (array $item): ?string => preg_match('/^@import\s+([\'"])(.+?)\1/', $item['statement'] ?? '', $match) === 1 ? $match[2] : null)
return collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))))
->map(fn (array $item): ?string => ComponentStylesheet::importPath($item['statement'] ?? ''))
->filter()
->values()
->all();
@@ -205,8 +130,10 @@ it('brings every foundation file and text.css into the foundation, and nothing e
expect($parts->diff($foundation)->values()->all())->toBe([])
->and(array_map(fn (string $import): string => basename($import), stylesheetImports(stylesheetPath('foundation.css'))))
// In the order of their layers, the rules outside every layer beside the reset.
->toBe(['reset.css', 'hidden.css', 'tokens.css', 'base.css', 'interaction.css', 'text.css'])
// In the order of their layers, the rules outside every layer beside the reset — the seven
// token files imported directly (plan step 33: foundation/tokens.css held only these
// imports, and only foundation.css reached it, so it was folded in here).
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.css', 'base.css', 'interaction.css', 'text.css'])
->and($foundation)
->toContain('tokens/scheme.css', 'tokens/shape.css', 'tokens/elevation.css', 'tokens/motion.css', 'tokens/type.css', 'tokens/state.css', 'tokens/spacing.css', 'tokens/font.css')
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
@@ -253,7 +180,7 @@ it('leaves no stylesheet under resources/css/ outside all.css or showcase.css',
it('opens every stylesheet all.css reaches with a header, the layer statement and plain imports', function () {
foreach (allStylesheets() as $file) {
$name = stylesheetName($file);
$items = stylesheetItems(File::get($file));
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file)));
expect(File::get($file))->toStartWith('/*', "{$name} has no header comment")
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, "{$name} does not open with the layer statement");
@@ -275,12 +202,11 @@ it('opens every stylesheet all.css reaches with a header, the layer statement an
});
it('keeps every foundation rule inside its material layer, but the two that hide', function () {
// Which layer each file writes into; tokens.css only imports.
// Which layer each file writes into.
$layers = [
'foundation.css' => null,
'foundation/reset.css' => 'material.reset',
'foundation/hidden.css' => null,
'foundation/tokens.css' => null,
'foundation/base.css' => 'material.base',
'foundation/interaction.css' => 'material.base',
'text.css' => 'material.text',
@@ -293,15 +219,15 @@ it('keeps every foundation rule inside its material layer, but the two that hide
foreach (foundationStylesheets() as $file) {
$name = stylesheetName($file);
$layer = array_key_exists($name, $layers) ? $layers[$name] : 'material.tokens';
$important += substr_count(stylesheetWithoutComments(File::get($file)), '!important');
$important += substr_count(ComponentStylesheet::withoutComments(File::get($file)), '!important');
foreach (stylesheetItems(File::get($file)) as $item) {
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))) as $item) {
if (! isset($item['prelude'])) {
continue;
}
if (! str_starts_with($item['prelude'], '@layer ')) {
$unlayered["{$name}: {$item['prelude']}"] = stylesheetDeclarations($item['body']);
$unlayered["{$name}: {$item['prelude']}"] = ComponentStylesheet::flatDeclarations($item['body']);
continue;
}
@@ -321,7 +247,7 @@ it('keeps every foundation rule inside its material layer, but the two that hide
it('defines x-cloak once, reached from both the foundation and all.css', function () {
$definitions = collect(File::allFiles(stylesheetPath()))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
->filter(fn (SplFileInfo $file): bool => str_contains(stylesheetWithoutComments($file->getContents()), '[x-cloak]'))
->filter(fn (SplFileInfo $file): bool => str_contains(ComponentStylesheet::withoutComments($file->getContents()), '[x-cloak]'))
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
->values()
->all();
@@ -333,37 +259,21 @@ it('defines x-cloak once, reached from both the foundation and all.css', functio
it('writes no Tailwind directive anywhere all.css reaches', function () {
foreach (allStylesheets() as $file) {
expect(stylesheetWithoutComments(File::get($file)))
expect(ComponentStylesheet::withoutComments(File::get($file)))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', stylesheetName($file))
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', stylesheetName($file));
}
});
it('writes no Tailwind utility class anywhere under resources/ or tests/', function () {
// DesignGuard's own family table (check (i)) is the standing definition of a Tailwind
// utility or variant — this test is that guard turned on the package's own source, so the
// two never drift apart. tests/Feature/DesignGuardTest.php and tests/Fixtures/design-guard/
// carry Tailwind on purpose (they are the guard's own fixtures) and are left out.
$root = __DIR__.'/../..';
$designGuardTest = (string) realpath($root.'/tests/Feature/DesignGuardTest.php');
$paths = collect(['resources/views', 'resources/js', 'src', 'tests/Browser', 'tests/Feature'])
->flatMap(fn (string $directory): Collection => collect(File::allFiles($root.'/'.$directory)))
->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'js', 'ts'], true))
->reject(fn (SplFileInfo $file): bool => (string) $file->getRealPath() === $designGuardTest)
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
->values()
->all();
expect($paths)->not->toBeEmpty();
expect(DesignGuard::scan($paths)->violations())->toBe([]);
});
// No Tailwind-shaped class anywhere the package ships or tests itself is
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
// either.
it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, in px', function () {
$queries = collect(allStylesheets())
->flatMap(function (string $file): array {
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments(File::get($file)), $matches);
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments(File::get($file)), $matches);
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
});
@@ -376,9 +286,9 @@ it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, i
$feature = substr($query, strpos($query, ': ') + 2);
// The one named exception (docs/reference/m3/components-navigation-selection-inputs.md §
// Time pickers/Behaviour, and tests/Feature/Components/InputStylesheetsTest.php's
// assertBreakpointsInPx()): a viewport *height* in an `orientation` query is not a
// breakpoint, since M3 does not make one of it. A width, in any query, still is.
// Time pickers/Behaviour, and tests/Feature/Components/ComponentStylesheetsTest.php's
// token-values test): a viewport *height* in an `orientation` query is not a breakpoint,
// since M3 does not make one of it. A width, in any query, still is.
$orientationHeight = preg_match('/\(\s*orientation\s*:/', $feature) === 1
&& preg_match('/(?:^|[\s:<>=(])(?:min-|max-)?height\b/', $feature) === 1
&& ! str_contains($feature, 'width');
@@ -408,7 +318,7 @@ it('declares M3\'s spacing scale as the reference gives it', function () {
);
$reference = collect($rows)->mapWithKeys(fn (array $row): array => ["--md-sys-measurement-space{$row[1]}" => "{$row[3]}px"])->all();
$tokens = stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
$tokens = ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
expect($reference)->toHaveCount(13)
->and($tokens)->toBe($reference);
@@ -495,11 +405,11 @@ it('gives every component view a stylesheet, or lists it on a short, documented
});
/**
* Every component view the four group stylesheet tests (Action/Input/Containment/Navigation) used
* to check one at a time, in one dataset instead: every `resources/views/components/*.blade.php`
* backed by a `components/*.css` file of its own, `tabs` and `tab` folded into the one entry
* `imports…()` below already special-cases (they share tabs.css, N-16), `theme-script` (no
* stylesheet at all) and every layout component (the test above covers those) left out.
* Every component view ComponentStylesheetsTest used to check one at a time, in one dataset
* instead: every `resources/views/components/*.blade.php` backed by a `components/*.css` file of
* its own, `tabs` and `tab` folded into the one entry `imports…()` below already special-cases
* (they share tabs.css), `theme-script` (no stylesheet at all) and every layout component (the
* test above covers those) left out.
*
* @return list<string>
*/
@@ -517,17 +427,34 @@ function componentStylesheetViews(): array
dataset('component stylesheet views', componentStylesheetViews());
/**
* The `<x-livewire-material::…>` tags a view renders, including any inside a
* `@include('livewire-material::partials.<name>', …)` it pulls in markup a view moved out to
* resources/views/partials/<name>.blade.php (menu's filter field, the timepicker's period picker)
* still renders the same tags, just not inline where a plain regex over the view alone would see
* them.
*
* @return list<string>
*/
function renderedComponentTags(string $view): array
{
$content = File::get($view);
preg_match_all('/<x-livewire-material::([a-z-]+)/', $content, $tags);
preg_match_all('/@include\(\'livewire-material::partials\.([\w.-]+)\'/', $content, $partials);
return collect($tags[1])
->merge(collect($partials[1])->flatMap(fn (string $partial): array => renderedComponentTags(__DIR__."/../../resources/views/partials/{$partial}.blade.php")))
->all();
}
it('imports, from every component stylesheet, the stylesheet of each component its view renders', function (string $name) {
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php (N-16); every other
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php; every other
// component stylesheet in this dataset has exactly one view of its own name.
$views = $name === 'tabs' ? ['tabs', 'tab'] : [$name];
$rendered = collect($views)
->flatMap(function (string $view): array {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../resources/views/components/{$view}.blade.php"), $tags);
return $tags[1];
})
->flatMap(fn (string $view): array => renderedComponentTags(__DIR__."/../../resources/views/components/{$view}.blade.php"))
->unique()
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
// <x-pane>) has no import of its own to check here.
@@ -607,7 +534,7 @@ it('imports, from every layout stylesheet, the stylesheet of each component its
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
// file draws on (pane.css: app-bar.css and button.css, for its own top app bar and back
// button) — each already checked, in material.components, by its own group's stylesheet test.
// button) — each already checked, in material.components, by ComponentStylesheetsTest.
foreach (layoutStylesheets() as $file) {
$name = stylesheetName($file);
@@ -621,9 +548,9 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
default => 'material.layout',
};
expect(stylesheetWithoutComments($css))->not->toContain('!important', $name);
expect(ComponentStylesheet::withoutComments($css))->not->toContain('!important', $name);
foreach (stylesheetItems($css) as $item) {
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
if (! isset($item['prelude'])) {
continue;
}
@@ -637,21 +564,21 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
it('gives every spacing token a gap and a padding, and the layout components read only those', function () {
$tokens = array_map(
fn (string $property): string => substr($property, strlen('--md-sys-measurement-')),
array_keys(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
array_keys(ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
);
expect(Layout::SPACING)->toBe($tokens);
$spacing = File::get(stylesheetPath('layout/spacing.css'));
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
foreach ($tokens as $token) {
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
->and(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
}
expect(collect(stylesheetItems(stylesheetBlock($spacing, '@layer material.layout')))->pluck('prelude')->all())
expect(collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($spacing, '@layer material.layout'))))->pluck('prelude')->all())
->toHaveCount(1 + 2 * count($tokens));
});
@@ -668,10 +595,10 @@ it('hides an element below and from every breakpoint but compact, and nothing el
$expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']";
}
$actual = collect(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
$rule = stylesheetItems($item['body'])[0];
$actual = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments($visibility)))->mapWithKeys(function (array $item): array {
$rule = ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body']))[0];
expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']);
expect(ComponentStylesheet::flatDeclarations($rule['body']))->toBe(['display' => 'none']);
return [$item['prelude'] => $rule['prelude']];
})->all();
@@ -686,7 +613,7 @@ it('gives every md-type class exactly font, letter-spacing and font-variation-se
// asserted directly rather than cross-checked against a second copy.
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
expect($classes)->toHaveCount(30);
@@ -730,18 +657,18 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
],
];
$classes = collect(stylesheetItems(stylesheetBlock($text, '@layer material.text')))
$classes = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($text, '@layer material.text'))))
->map(fn (array $item): string => $item['prelude'] ?? $item['statement'])
->all();
expect($classes)->toBe($styles->merge(array_keys($inks))->merge(array_keys($rest))->map(fn (string $class): string => ".{$class}")->all());
foreach ($inks as $class => $role) {
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
}
foreach ($rest as $class => $declarations) {
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
}
});
@@ -754,7 +681,7 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
// file keeps to.
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
$app = ComponentStylesheet::withoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// Opens by ordering the layers, so the order holds whichever the page links first — an
// application with Tailwind of its own still opens its own entry the same way (its header).
@@ -775,11 +702,11 @@ it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left
it('shapes showcase.css like a package stylesheet, with its documented unlayered exceptions', function () {
$path = __DIR__.'/../../resources/css/showcase.css';
$css = File::get($path);
$items = stylesheetItems($css);
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($css));
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement')
->and(stylesheetWithoutComments($css))
->and(ComponentStylesheet::withoutComments($css))
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
@@ -814,7 +741,7 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
// Bundled with all.css on every showcase page, so it styles its own hooks and classes only,
// and each of those is still drawn by some showcase view.
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', stylesheetWithoutComments($css)), $preludes);
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', ComponentStylesheet::withoutComments($css)), $preludes);
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
foreach ($preludes[1] as $prelude) {
@@ -823,13 +750,13 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
}
}
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', stylesheetWithoutComments($css), $hooks);
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', ComponentStylesheet::withoutComments($css), $hooks);
foreach (array_unique($hooks[0]) as $hook) {
expect(str_contains($views, ltrim($hook, '.')))->toBeTrue("showcase.css draws `{$hook}`, which no showcase view renders");
}
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments($css), $matches);
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments($css), $matches);
foreach ($matches[1] as $query) {
$feature = trim($query);
@@ -845,17 +772,17 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
}
});
it('keeps all.css under a gzip size budget, measured through Stylesheets::bundle()', function () {
// 106,838 bytes gzipped today (2026-09-15), rounded up by about 10% to a number CI can hold
// to without Node: PHP's own gzencode() on the same bytes Stylesheets::bundle() serves the
// showcase and the error page's fallback. A little organic growth should not retrigger this
// on every commit; a real jump means look at what grew since the budget was last raised.
$budget = 120_000;
it('keeps the showcase bundle, all.css with showcase.css, under a gzip size budget', function () {
// 40,167 bytes gzipped today (2026-09-17), measured with PHP's own gzencode() on
// resources/dist/showcase.css, the prebuilt bundle the showcase serves, comments stripped. A
// little organic growth should not retrigger this on every commit; a real jump means look at
// what grew since the budget was last raised.
$budget = 44_000;
$gzipped = strlen((string) gzencode(Stylesheets::bundle([stylesheetPath('all.css')]), 9));
$gzipped = strlen((string) gzencode(File::get(__DIR__.'/../../resources/dist/showcase.css'), 9));
expect($gzipped)->toBeLessThanOrEqual($budget, sprintf(
'all.css now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
'The showcase bundle now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
number_format($gzipped),
number_format($budget),
));
+2 -1
View File
@@ -2,10 +2,11 @@
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
function packageCss(string $path = ''): string
{
return __DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path);
return ComponentStylesheet::cssPath($path);
}
/**
@@ -0,0 +1 @@
<span class="duration-(--md-sys-motion-spatial-fast-duration)"></span>
@@ -1,2 +1 @@
<p class="text-meta text-quiet border-divider"></p>
<span class="text-on-primary-container bg-background text-current md-ink-variant"></span>
@@ -1,3 +0,0 @@
<div class="type-body-md type-emphasized-title-lg focus-ring state-layer link">
<span class="rounded-corner-lg rounded-t-corner-xl-increased shadow-elevation-2 ease-spatial-fast ease-emphasized-decelerate duration-(--md-sys-motion-spatial-fast-duration)"></span>
</div>
+40 -7
View File
@@ -6,18 +6,51 @@ use NoNameWeb\LivewireMaterial\Tests\TestCase;
pest()->extend(TestCase::class)->in('Feature', 'Browser');
/**
* The element name (under `<`) and the attributes of the first tag in a rendered component, by
* name; a boolean attribute Blade renders as `name="name"`. For the layout components' render
* tests, which assert what their root carries.
* The attributes of a rendered component's tag, by name; a boolean attribute Blade renders as
* `name="name"`. Without `$tag`, it is the first tag anywhere in the rendered Blade (anchored to
* the start), and the element name comes back too, under `<` the layout components' render
* tests, which assert what their root carries, read it this way. With `$tag` (`|`-separated for
* more than one, e.g. `'button|a'`), it is the first `<$tag>` anywhere in the rendered Blade
* instead, with no `<` key for a component whose root is not the tag Blade renders first (a
* button or link inside a wrapper, a span), where the anchored match would read the wrong element.
*
* @return array<string, string>
*/
function layoutRoot(string $html): array
function layoutRoot(string $html, ?string $tag = null): array
{
preg_match('/^\s*<([a-z]+)\b([^>]*)>/s', $html, $tag);
preg_match_all('/([\w:.@-]+)(?:="([^"]*)")?/', $tag[2] ?? '', $pairs, PREG_SET_ORDER);
if ($tag === null) {
preg_match('/^\s*<([a-z]+)\b([^>]*)>/s', $html, $match);
$root = ['<' => $match[1] ?? ''];
$attributes = $match[2] ?? '';
} else {
preg_match('/<(?:'.$tag.')\b([^>]*)>/', $html, $match);
$root = [];
$attributes = $match[1] ?? '';
}
return ['<' => $tag[1] ?? ''] + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
preg_match_all('/([\w:.@-]+)(?:="([^"]*)")?/', $attributes, $pairs, PREG_SET_ORDER);
return $root + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
}
/**
* The page is done loading, and Alpine and/or Livewire (whichever the page renders) have booted
* chained after visit(), before a Browser test probe reads anything either one wires up. Most
* pages need both; one that renders no Livewire component needs Alpine alone, and vice versa.
*/
function ready(mixed $page, bool $alpine = true, bool $livewire = true): mixed
{
$checks = ["document.readyState === 'complete'"];
if ($alpine) {
$checks[] = "typeof window.Alpine !== 'undefined'";
}
if ($livewire) {
$checks[] = "typeof window.Livewire !== 'undefined'";
}
return $page->waitForEvent('networkidle')->assertScript(implode(' && ', $checks));
}
// A shared CI runner is slower than a workstation: an animation or a smooth scroll can take longer
+46 -8
View File
@@ -53,7 +53,16 @@ final class ComponentStylesheet
public static function path(string $name): string
{
return dirname(__DIR__, 2)."/resources/css/components/{$name}.css";
return self::cssPath("components/{$name}.css");
}
/**
* resources/css itself, or a path under it the root every stylesheet test resolves paths
* from.
*/
public static function cssPath(string $relative = ''): string
{
return dirname(__DIR__, 2).'/resources/css'.($relative === '' ? '' : "/{$relative}");
}
/**
@@ -73,10 +82,33 @@ final class ComponentStylesheet
*/
public function imports(): array
{
return array_values(array_filter(array_map(
fn (string $statement): ?string => preg_match('/^@import\s+([\'"])(.+?)\1;$/', $statement, $match) === 1 ? $match[2] : null,
$this->statements,
)));
return array_values(array_filter(array_map(self::importPath(...), $this->statements)));
}
/**
* The file a plain `@import '<path>';` statement names, or null for anything else (a
* statement with `layer()`/`supports()`/media after the path, or not an import at all).
*/
public static function importPath(string $statement): ?string
{
return preg_match('/^@import\s+([\'"])(.+?)\1;$/', $statement, $match) === 1 ? $match[2] : null;
}
/**
* The declarations of a flat block body no nested rule inside it, just `property: value;`
* pairs (a `:root` block, a media query's own single nested rule already unwrapped by the
* caller). declarations() above is for a selector inside the full, nested stylesheet; this is
* for a body a caller already has in hand.
*
* @return array<string, string>
*/
public static function flatDeclarations(string $body): array
{
return collect(explode(';', $body))
->map(fn (string $declaration): string => trim($declaration))
->filter()
->mapWithKeys(fn (string $declaration): array => [trim(strstr($declaration, ':', true)) => trim(substr(strstr($declaration, ':'), 1))])
->all();
}
/**
@@ -240,18 +272,24 @@ final class ComponentStylesheet
return trim((string) preg_replace('/\s+/', ' ', $text));
}
private static function withoutComments(string $css): string
/**
* `$css` with every comment and quoted string blanked out (the quoted string kept, the comment
* dropped) shared by every test that has to tell a `/* … *\/` in a stylesheet from real CSS.
*/
public static function withoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The statements and blocks at the top of a piece of CSS. Inside a rule a declaration may end
* at the closing brace without a semicolon.
* at the closing brace without a semicolon. `$css` must already be comment-free
* (withoutComments()) this does not strip them itself, so a caller walking nested bodies
* (already substrings of a stripped source) does not pay for it twice.
*
* @return list<array{statement: string}|array{prelude: string, body: string}>
*/
private static function items(string $css, bool $declarations = false): array
public static function items(string $css, bool $declarations = false): array
{
$items = [];
$start = 0;