Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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>')");
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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\"])')";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
/**
|
||||
@@ -23,8 +22,6 @@ const BOOST_TEXTS = [
|
||||
*/
|
||||
const BOOST_SECTIONS_NAMING_WHAT_FAILS = ['Testing the design'];
|
||||
|
||||
beforeEach(fn () => Stylesheets::resetCache());
|
||||
|
||||
/**
|
||||
* A text's code, line for line, as the design guard reads an application: fenced Blade, HTML, PHP
|
||||
* and JS into a view, fenced CSS into a stylesheet, and every inline span that reads as a class
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
|
||||
/**
|
||||
* The actions and communication components, rewritten without Tailwind (plan step 36): each view
|
||||
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
|
||||
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
|
||||
* as px range queries, and is imported from the "Actions and communication" block of all.css.
|
||||
* Every component stylesheet importing the stylesheets its view renders is
|
||||
* tests/Feature/StylesheetsTest.php's now, folded into one dataset over every component view (step
|
||||
* 42) rather than kept once per group.
|
||||
*
|
||||
* `icon` and `shape` are the two foundation components (`<x-icon>`, `<x-shape>`) — no group's view
|
||||
* renders them exclusively, so they were in no group's dataset at all. They follow the same shape,
|
||||
* token and view-class rules as every other component, so they run with this group's checks too;
|
||||
* only "is imported from the actions and communication block" needs to read the two of them from
|
||||
* all.css's first block ("Foundation components") instead, since that is where they sit.
|
||||
*/
|
||||
dataset('action components', [
|
||||
'icon',
|
||||
'shape',
|
||||
'loading',
|
||||
'tooltip',
|
||||
'badge',
|
||||
'button',
|
||||
'group',
|
||||
'button-group',
|
||||
'split-button',
|
||||
'fab',
|
||||
'menu-separator',
|
||||
'menu-group',
|
||||
'menu-item',
|
||||
'menu',
|
||||
'fab-menu-item',
|
||||
'fab-menu',
|
||||
'rich-tooltip',
|
||||
'toast',
|
||||
'progress',
|
||||
'alert',
|
||||
'stat',
|
||||
'empty-state',
|
||||
]);
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
|
||||
}
|
||||
})->with('action components');
|
||||
|
||||
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
|
||||
})->with('action components');
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||
|
||||
// Colours are roles, shadows are elevation levels, type is a type style, motion is a spring.
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
foreach ($css->mediaQueries() as $query) {
|
||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER);
|
||||
|
||||
foreach ($lengths as [, $number, $unit]) {
|
||||
expect($unit)->toBe('px', "{$name}.css: {$query}")
|
||||
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$name}.css: {$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
})->with('action components');
|
||||
|
||||
it('is imported from the actions and communication block of all.css, or icon and shape from the foundation block before it', function (string $name) {
|
||||
$block = allCssBlock(in_array($name, ['icon', 'shape'], true) ? 'Foundation components' : 'Actions and communication');
|
||||
|
||||
expect($block)->toContain("@import './components/{$name}.css';");
|
||||
})->with('action components');
|
||||
@@ -12,9 +12,11 @@ it('states a notice in its state\'s container, with the state\'s icon', function
|
||||
->toContain('Storage almost full')
|
||||
->toContain('3.8 of 4 GB')
|
||||
->toContain('<svg')
|
||||
->and(ComponentStylesheet::read('alert')->declarations("[data-md-alert][data-md-color='warning']"))->toBe([
|
||||
'background-color' => 'var(--md-sys-color-warning-container)',
|
||||
'color' => 'var(--md-sys-color-on-warning-container)',
|
||||
// Every hue but neutral comes from the shared colour-role table (color.css, ColorTest.php);
|
||||
// the alert reads its container role straight off it, info by default.
|
||||
->and(ComponentStylesheet::read('alert')->declarations('[data-md-alert]'))->toMatchArray([
|
||||
'background-color' => 'var(--md-container, var(--md-sys-color-info-container))',
|
||||
'color' => 'var(--md-on-container, var(--md-sys-color-on-info-container))',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -179,11 +179,11 @@ it('centres a headline on a three-column row and curves the search container', f
|
||||
$css = ComponentStylesheet::read('app-bar');
|
||||
|
||||
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
|
||||
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
|
||||
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons.
|
||||
->not->toContain('inset-inline: 56px;')
|
||||
->and($css->declarations('[data-md-app-bar][data-md-variant=\'center\'] [data-md-app-bar-row]'))
|
||||
->toBe(['display' => 'grid', 'grid-template-columns' => '1fr auto 1fr'])
|
||||
// 312dp, then half of what is left (N-09).
|
||||
// 312dp, then half of what is left.
|
||||
->and($css->declarations('[data-md-app-bar-search]'))
|
||||
->toHaveKey('max-width', 'calc(312px + (100% - 312px) / 2)');
|
||||
});
|
||||
@@ -204,20 +204,21 @@ it('keeps a toolbar at the bottom clear of the navigation bar', function () {
|
||||
$css = ComponentStylesheet::read('toolbar');
|
||||
|
||||
expect($css->declarations('[data-md-toolbar-place=\'bottom\']'))
|
||||
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
|
||||
// Never a sum: --material-bottom-bar already swallows the bottom safe area.
|
||||
->toHaveKey('bottom', 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))')
|
||||
->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-toolbar-place=\'bottom\']'))
|
||||
->toHaveKey('bottom', 'var(--material-bottom-bar, 0px)')
|
||||
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
|
||||
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16.
|
||||
->and($css->declarations('[data-md-toolbar][data-md-vertical][data-md-toolbar-place=\'end\'], [data-md-toolbar-group][data-md-vertical][data-md-toolbar-place=\'end\']'))
|
||||
->toBe(['inset-inline-end' => 'calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)))'])
|
||||
// A standard toolbar's standard buttons are primary (N-13).
|
||||
// A standard toolbar's standard buttons are primary.
|
||||
->and($css->declarations('[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([data-md-variant=\'filled\'], [data-md-variant=\'tonal\'], [data-md-selected=\'true\'], [aria-pressed=\'true\'], :disabled, [aria-disabled=\'true\'])'))
|
||||
->toBe(['color' => 'var(--md-sys-color-primary)']);
|
||||
|
||||
// N-13 names the *standard* button, so a button that fills its own container keeps the label
|
||||
// colour that goes with that fill: a filled button painted primary put a primary icon on a
|
||||
// primary fill, and the current page's button in a navigation toolbar came out a blank circle.
|
||||
// The rule above names the *standard* button, so a button that fills its own container keeps
|
||||
// the label colour that goes with that fill: a filled button painted primary put a primary
|
||||
// icon on a primary fill, and the current page's button in a navigation toolbar came out a
|
||||
// blank circle.
|
||||
foreach (['[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]', '[data-md-toolbar][data-md-vibrant] [data-md-icon-button]'] as $scope) {
|
||||
preg_match_all('/'.preg_quote($scope, '/').':not\([^{]*\{[^}]*\bcolor:/', $css->css, $rules);
|
||||
|
||||
@@ -256,7 +257,7 @@ it('offers M3\'s three contrast levels, marking the one in force', function () {
|
||||
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
|
||||
->toContain('data-md-theme-toggle="contrast"')
|
||||
->toContain('aria-label="Contrast"')
|
||||
// A connected button group over native radios, not the deprecated segmented button (N-04).
|
||||
// A connected button group over native radios, not the deprecated segmented button.
|
||||
->toContain('data-md-button-group="connected"')
|
||||
->toContain('name="material-contrast"')
|
||||
->toContain('value="standard"')
|
||||
@@ -281,7 +282,7 @@ it('switches the theme through the store in three shapes', function () {
|
||||
->toContain('data-md-theme-toggle="toggle"')
|
||||
->toContain('data-md-icon-button')
|
||||
->toContain('aria-label="Dark theme"')
|
||||
// 40px drawn, 48px reached: M3's minimum target (N-01).
|
||||
// 40px drawn, 48px reached: M3's minimum target.
|
||||
->toContain('md-touch-target')
|
||||
->toContain('$store.theme.toggle()')
|
||||
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
|
||||
@@ -315,8 +316,8 @@ it('opens an account menu from the initials of a name', function () {
|
||||
expect($html)
|
||||
->toContain('aria-label="Account"')
|
||||
->toContain('data-md-account-menu')
|
||||
// The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01); the trigger
|
||||
// has a hover and a pressed state like every other trigger in the library (N-15).
|
||||
// The 40px avatar reaches 48px, and nothing clips the pseudo-target; the trigger
|
||||
// has a hover and a pressed state like every other trigger in the library.
|
||||
->toContain('md-state-layer md-touch-target md-focus-ring')
|
||||
->not->toContain('overflow-hidden')
|
||||
->toMatch('/>\s*AM\s*<\/button>/')
|
||||
@@ -335,6 +336,6 @@ it('opens an account menu from the initials of a name', function () {
|
||||
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
|
||||
->and($css->declarations('[data-md-account-menu-avatar]'))
|
||||
// Behind the state layer's ::before (z-index -1), so a hover or a press still washes over
|
||||
// the picture instead of under it (N-15).
|
||||
// the picture instead of under it.
|
||||
->toHaveKey('z-index', '-2');
|
||||
});
|
||||
|
||||
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
*/
|
||||
function badgeAttributes(string $blade): array
|
||||
{
|
||||
preg_match('/<span\b([^>]*)>/', (string) test()->blade($blade), $tag);
|
||||
preg_match_all('/([\w:-]+)="([^"]*)"/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
|
||||
|
||||
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2]])->all();
|
||||
return layoutRoot((string) test()->blade($blade), 'span');
|
||||
}
|
||||
|
||||
it('is M3\'s small badge without a value, hidden from screen readers', function () {
|
||||
@@ -107,29 +104,23 @@ it('renders its slot as HTML, and is a dot while the slot holds nothing but comm
|
||||
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
|
||||
});
|
||||
|
||||
it('paints each colour from its pair of roles, and neutral without a hue', function (string $color, array $roles) {
|
||||
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='{$color}']"))->toBe([
|
||||
'--md-badge-color' => "var(--md-sys-color-{$roles[0]})",
|
||||
'--md-badge-on-color' => "var(--md-sys-color-{$roles[1]})",
|
||||
'--md-badge-container' => "var(--md-sys-color-{$roles[2]})",
|
||||
'--md-badge-on-container' => "var(--md-sys-color-{$roles[3]})",
|
||||
it('paints neutral from its own pair of roles, the one hue the shared table has none for', function () {
|
||||
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='neutral']"))->toBe([
|
||||
'--md-badge-color' => 'var(--md-sys-color-on-surface-variant)',
|
||||
'--md-badge-on-color' => 'var(--md-sys-color-surface)',
|
||||
'--md-badge-container' => 'var(--md-sys-color-surface-container-high)',
|
||||
'--md-badge-on-container' => 'var(--md-sys-color-on-surface-variant)',
|
||||
]);
|
||||
})->with([
|
||||
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
|
||||
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
|
||||
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
|
||||
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
|
||||
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
|
||||
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
|
||||
'neutral' => ['neutral', ['on-surface-variant', 'surface', 'surface-container-high', 'on-surface-variant']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is error by default, and falls back to error on a colour it does not know', function () {
|
||||
$css = ComponentStylesheet::read('badge');
|
||||
|
||||
// Every other hue comes from the shared colour-role table (color.css, ColorTest.php): badge.css
|
||||
// only has to give its own prefixed variables a fallback for when `data-md-color` is absent.
|
||||
expect($css->declarations('[data-md-badge]'))->toMatchArray([
|
||||
'--md-badge-color' => 'var(--md-sys-color-error)',
|
||||
'--md-badge-on-color' => 'var(--md-sys-color-on-error)',
|
||||
'--md-badge-color' => 'var(--md-color, var(--md-sys-color-error))',
|
||||
'--md-badge-on-color' => 'var(--md-on-color, var(--md-sys-color-on-error))',
|
||||
])
|
||||
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])"))->toBe([
|
||||
'background-color' => 'var(--md-badge-color)',
|
||||
|
||||
@@ -31,7 +31,7 @@ it('connects buttons 2px apart, with M3\'s inner corners', function () {
|
||||
->and($css->declarations("[data-md-button-group='connected']"))->toBe(['gap' => 'var(--md-sys-measurement-space25)'])
|
||||
->and($css->declarations("[data-md-button-group='connected'] > *"))->toMatchArray([
|
||||
'--md-button-group-corner' => 'var(--md-button-group-inner)',
|
||||
'border-start-start-radius' => 'var(--md-button-group-corner)',
|
||||
'border-radius' => 'var(--md-button-group-corner)',
|
||||
])
|
||||
->and($css->declarations("[data-md-button-group='connected'] > :active"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-inner-pressed)'])
|
||||
->and($css->declarations("[data-md-button-group='connected'] > :first-child"))->toBe([
|
||||
|
||||
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
*/
|
||||
function buttonAttributes(string $blade): array
|
||||
{
|
||||
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
|
||||
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
|
||||
|
||||
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
|
||||
return layoutRoot((string) test()->blade($blade), 'button|a');
|
||||
}
|
||||
|
||||
function buttonCss(): ComponentStylesheet
|
||||
@@ -33,7 +30,7 @@ it('is a text button in the primary colour by default, drawn by its stylesheet',
|
||||
->and(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
|
||||
'--md-button-container' => 'transparent',
|
||||
'--md-button-label' => 'var(--md-button-color)',
|
||||
'--md-button-color' => 'var(--md-sys-color-primary)',
|
||||
'--md-button-color' => 'var(--md-color, var(--md-sys-color-primary))',
|
||||
'background-color' => 'var(--md-button-container)',
|
||||
'color' => 'var(--md-button-label)',
|
||||
]);
|
||||
@@ -51,11 +48,17 @@ it('draws each M3 variant', function (string $variant, array $declarations) {
|
||||
|
||||
it('draws primary\'s tonal button in secondary-container and its quiet ink in on-surface-variant', function () {
|
||||
expect(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
|
||||
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
|
||||
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
|
||||
'--md-button-tone' => 'var(--md-container, var(--md-sys-color-secondary-container))',
|
||||
'--md-button-on-tone' => 'var(--md-on-container, var(--md-sys-color-on-secondary-container))',
|
||||
'--md-button-tone-selected' => 'var(--md-sys-color-secondary)',
|
||||
'--md-button-quiet' => 'var(--md-sys-color-on-surface-variant)',
|
||||
])
|
||||
// The shared colour-role table's own `primary` entry is plain primary-container
|
||||
// (color.css, ColorTest.php), so an explicit `data-md-color="primary"` keeps this override.
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-color='primary']"))->toBe([
|
||||
'--md-button-tone' => 'var(--md-sys-color-secondary-container)',
|
||||
'--md-button-on-tone' => 'var(--md-sys-color-on-secondary-container)',
|
||||
])
|
||||
->and(buttonCss()->declarations("[data-md-button]:not([data-md-color='primary'])"))->toBe([
|
||||
'--md-button-tone-selected' => 'var(--md-button-color)',
|
||||
'--md-button-on-tone-selected' => 'var(--md-button-on-color)',
|
||||
@@ -63,13 +66,10 @@ it('draws primary\'s tonal button in secondary-container and its quiet ink in on
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws a variant in another colour role', function (string $color) {
|
||||
expect(buttonCss()->declarations("[data-md-button][data-md-color='{$color}']"))->toBe([
|
||||
'--md-button-color' => "var(--md-sys-color-{$color})",
|
||||
'--md-button-on-color' => "var(--md-sys-color-on-{$color})",
|
||||
'--md-button-tone' => "var(--md-sys-color-{$color}-container)",
|
||||
'--md-button-on-tone' => "var(--md-sys-color-on-{$color}-container)",
|
||||
]);
|
||||
it('draws a variant in another colour role, straight off the shared colour-role table', function (string $color) {
|
||||
// color.css (ColorTest.php) maps every one of these to --md-color/--md-on-color/--md-container/
|
||||
// --md-on-container; button.css only has to read them (`draws each M3 variant`, above).
|
||||
expect((string) $this->blade("<x-button label=\"Go\" color=\"{$color}\" />"))->toContain("data-md-color=\"{$color}\"");
|
||||
})->with(['secondary', 'tertiary', 'error', 'success', 'warning', 'info']);
|
||||
|
||||
it('keeps the maryUI and ReStride shorthands', function () {
|
||||
|
||||
@@ -135,7 +135,7 @@ it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down
|
||||
->toContain('data-md-carousel="multi-browse"');
|
||||
|
||||
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no corner, no mask,
|
||||
// and never wider than the medium window it is meant for (C-11).
|
||||
// and never wider than the medium window it is meant for.
|
||||
expect($carousel->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller]"))
|
||||
->toBe([
|
||||
'margin-inline' => 'auto',
|
||||
@@ -210,7 +210,7 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu
|
||||
|
||||
$item = ComponentStylesheet::read('carousel-item');
|
||||
|
||||
// The scrim under the label and the item's literal white ink over it are deliberate (C-25).
|
||||
// The scrim under the label and the item's literal white ink over it are deliberate.
|
||||
expect($item->declarations('[data-md-carousel-surface] > [data-md-carousel-label]'))
|
||||
->toHaveKey('background', 'linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent)')
|
||||
->and($item->declarations('[data-md-carousel-label] > [data-md-carousel-label-text]'))
|
||||
|
||||
@@ -17,7 +17,7 @@ it('is an outlined assist chip, a button, by default', function () {
|
||||
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
|
||||
|
||||
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="app-chip-gap" />', 'button'))
|
||||
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="app-chip-gap">')
|
||||
->toBe('<button class="md-state-layer md-touch-target app-chip-gap" data-md-chip="assist" data-md-icon-start="" type="button">')
|
||||
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
|
||||
->toContain('data-md-icon-end=""')
|
||||
->not->toContain('data-md-icon-start')
|
||||
@@ -25,7 +25,7 @@ it('is an outlined assist chip, a button, by default', function () {
|
||||
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
|
||||
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
|
||||
->toContain('<span data-md-chip-label>Add to calendar</span>')
|
||||
->not->toContain('class=');
|
||||
->toContain('class="md-state-layer md-touch-target"');
|
||||
});
|
||||
|
||||
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
|
||||
@@ -36,8 +36,7 @@ it('draws a chip at Compose\'s size, corner, type and padding from its styleshee
|
||||
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
|
||||
// 16px beside a label and 8px beside an icon, each 1px short for the border.
|
||||
->toContain('padding-inline: 15px;')
|
||||
->toContain('padding-inline-start: 7px;')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/chip.css';");
|
||||
->toContain('padding-inline-start: 7px;');
|
||||
});
|
||||
|
||||
it('pads a chip without icons by 16px on both sides', function () {
|
||||
@@ -96,7 +95,7 @@ it('attaches a plain tooltip', function () {
|
||||
expect($html)
|
||||
->toContain('popover="manual"')
|
||||
->toContain('Share this file')
|
||||
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
|
||||
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10};"/');
|
||||
});
|
||||
|
||||
it('makes a filter chip without a model a toggle button the caller owns', function () {
|
||||
@@ -118,7 +117,7 @@ it('makes a bound filter chip a native checkbox under the chip', function () {
|
||||
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />');
|
||||
|
||||
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />', 'label'))
|
||||
->toBe('<label data-md-chip="filter" class="app-filter-gap" wire:key="photos">')
|
||||
->toBe('<label class="md-state-layer md-touch-target app-filter-gap" data-md-chip="filter" wire:key="photos">')
|
||||
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" />', 'input'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('value="photos"')
|
||||
@@ -177,7 +176,12 @@ it('makes an input chip a button when it has its own action, and selects it', fu
|
||||
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
|
||||
->toContain('data-md-actionable=""')
|
||||
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
|
||||
->not->toContain('data-md-actionable');
|
||||
->not->toContain('data-md-actionable')
|
||||
// An input chip draws its state layer on its action, never a second one on the container.
|
||||
->not->toContain('md-state-layer')
|
||||
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'button'))
|
||||
->not->toContain('md-state-layer')
|
||||
->not->toContain('md-touch-target');
|
||||
});
|
||||
|
||||
it('gives a removable input chip a named remove button that calls wire:remove', function () {
|
||||
@@ -244,8 +248,7 @@ it('draws the set from its stylesheet: 8px between chips, wrapping unless it scr
|
||||
|
||||
expect($css)->toContain("@import './chip.css';")
|
||||
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
|
||||
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/chip-set.css';");
|
||||
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {');
|
||||
});
|
||||
|
||||
it('scrolls a chip set on one line with fading edges', function () {
|
||||
|
||||
@@ -105,6 +105,5 @@ it('brings the stylesheets of what it renders, and draws its empty row', functio
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain("@import './chip-set.css';")
|
||||
->toContain("@import './chip.css';")
|
||||
->toContain('[data-md-choices-empty] {')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/choices.css';");
|
||||
->toContain('[data-md-choices-empty] {');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The shared colour-role table badge.css, slider.css, button.css, alert.css and progress.css read
|
||||
* instead of each carrying its own copy: `data-md-color` picks primary, secondary, tertiary,
|
||||
* error, success, warning or info, and each maps to the same four generic variables.
|
||||
*/
|
||||
it('maps every hue to its own role and its tonal container', function (string $color, array $roles) {
|
||||
expect(ComponentStylesheet::read('color')->declarations("[data-md-color='{$color}']"))->toBe([
|
||||
'--md-color' => "var(--md-sys-color-{$roles[0]})",
|
||||
'--md-on-color' => "var(--md-sys-color-{$roles[1]})",
|
||||
'--md-container' => "var(--md-sys-color-{$roles[2]})",
|
||||
'--md-on-container' => "var(--md-sys-color-{$roles[3]})",
|
||||
]);
|
||||
})->with([
|
||||
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
|
||||
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
|
||||
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
|
||||
'error' => ['error', ['error', 'on-error', 'error-container', 'on-error-container']],
|
||||
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
|
||||
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
|
||||
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
|
||||
]);
|
||||
|
||||
it('keeps the generic variables on the element that names the colour', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/color.css');
|
||||
|
||||
foreach (['--md-color', '--md-on-color', '--md-container', '--md-on-container'] as $property) {
|
||||
expect($css)->toMatch('/@property '.preg_quote($property, '/')."\\s*\\{\\s*syntax: '\\*';\\s*inherits: false;\\s*\\}/");
|
||||
}
|
||||
});
|
||||
|
||||
it('imports the table from every stylesheet that reads it', function () {
|
||||
$readers = collect(glob(__DIR__.'/../../../resources/css/components/*.css'))
|
||||
->reject(fn (string $file): bool => basename($file) === 'color.css')
|
||||
->filter(fn (string $file): bool => preg_match('/var\(--md-(?:on-)?(?:color|container)\b/', (string) file_get_contents($file)) === 1);
|
||||
|
||||
expect($readers)->not->toBeEmpty();
|
||||
|
||||
foreach ($readers as $file) {
|
||||
expect(str_contains((string) file_get_contents($file), "@import './color.css';"))->toBeTrue(basename($file).' reads the colour table without importing it');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
|
||||
/**
|
||||
* Every component and layout-shared stylesheet, rewritten without Tailwind (plan step 36): each
|
||||
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
|
||||
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as
|
||||
* px range queries, and is imported from its own block of all.css. This used to be four near-copies
|
||||
* of the same checks, one per group (Action/Containment/Input/Navigation), plus a fifth in
|
||||
* ErrorPagesTest.php for the error layout — folded into this one file (plan cleanup) because the
|
||||
* checks never differed in substance, only in which names they ran over and, for the token check,
|
||||
* which box-shadow shape a group's own values may take (see $boxShadow below). `icon` and `shape`
|
||||
* (no group's view renders them exclusively) and `color` (its own ColorTest.php already covers it,
|
||||
* the shared colour-role table every hue-taking component imports) keep their separate treatment.
|
||||
*
|
||||
* Four names in the dataset are not a component view of their own: `selection` (what checkbox,
|
||||
* radio and toggle share), `navigation-item` (what the bar and the rail's items share), `pagination`
|
||||
* (four views under resources/views/pagination/, not resources/views/components/) and `error-page`
|
||||
* (the framework's error layout, not a component at all). The stylesheet-only checks below (shape,
|
||||
* tokens, block import) still run on all four; the view-scoped ones (class list, element-wide
|
||||
* selector scoping) run only where the original group already ran them — pagination's own views get
|
||||
* their own small dataset further down, and error-page's view-scoped checks stay in
|
||||
* ErrorPagesTest.php, which reads a view outside resources/views/components/.
|
||||
*/
|
||||
function componentStylesheets(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach (['icon', 'shape'] as $name) {
|
||||
$entries[$name] = ['Foundation components', 'loose'];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'loading', 'tooltip', 'badge', 'button', 'group', 'button-group', 'split-button', 'fab',
|
||||
'menu-separator', 'menu-group', 'menu-item', 'menu', 'fab-menu-item', 'fab-menu',
|
||||
'rich-tooltip', 'toast', 'progress', 'alert', 'stat', 'empty-state',
|
||||
] as $name) {
|
||||
$entries[$name] = ['Actions and communication', 'loose'];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet',
|
||||
'carousel-item', 'carousel',
|
||||
] as $name) {
|
||||
$entries[$name] = ['Containment', 'ring'];
|
||||
}
|
||||
|
||||
$entries['error-page'] = ['Containment', 'none'];
|
||||
|
||||
foreach ([
|
||||
'form', 'field', 'input', 'password', 'textarea', 'select', 'file', 'checkbox', 'radio',
|
||||
'toggle', 'chip', 'chip-set', 'choices', 'slider', 'search', 'table', 'sort-header',
|
||||
'datepicker', 'timepicker', 'selection', 'pagination',
|
||||
] as $name) {
|
||||
$entries[$name] = ['Inputs, selection and data', 'ring'];
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail',
|
||||
'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu',
|
||||
'theme-toggle', 'scheme-picker', 'navigation-item',
|
||||
] as $name) {
|
||||
$entries[$name] = ['Navigation', 'ring'];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
dataset('component stylesheets', function (): Generator {
|
||||
foreach (componentStylesheets() as $name => [$block, $boxShadow]) {
|
||||
yield $name => [$name, $block, $boxShadow];
|
||||
}
|
||||
});
|
||||
|
||||
// The names with exactly one view of their own at resources/views/components/<name>.blade.php —
|
||||
// every dataset entry above but the four stylesheet-only names (see the file header) — for the two
|
||||
// checks that read that view. `tabs` renders two (tabs.blade.php and tab.blade.php).
|
||||
dataset('component views', array_values(array_diff(array_keys(componentStylesheets()), ['selection', 'navigation-item', 'pagination', 'error-page'])));
|
||||
|
||||
/**
|
||||
* The names whose stylesheet travels in every page's bundle (all.css), so a bare `html`, `body`,
|
||||
* `dialog` or `:root` rule would restyle an application's own pages — containment and navigation,
|
||||
* the two groups whose original tests carried this check. `[data-md-` is the exception in both: a
|
||||
* rule that only applies while a package component is on the page at all. `:has([data-md-` needs
|
||||
* no `>` combinator only in navigation's own stylesheets (toolbar.css's
|
||||
* `:root:has([data-md-toolbar-place=…])` has none) — containment keeps the strict, `>`-only form
|
||||
* its own original test held it to.
|
||||
*/
|
||||
dataset('scoped selectors', function (): Generator {
|
||||
$names = [
|
||||
...array_fill_keys(['divider', 'collapse', 'card', 'list', 'list-item', 'modal', 'drawer', 'bottom-sheet', 'carousel-item', 'carousel'], true),
|
||||
...array_fill_keys(['app-bar', 'toolbar', 'tabs', 'navigation-bar', 'navigation-bar-item', 'navigation-rail', 'navigation-rail-item', 'navigation-rail-section', 'section-nav', 'account-menu', 'theme-toggle', 'scheme-picker', 'navigation-item'], false),
|
||||
];
|
||||
|
||||
foreach ($names as $name => $strict) {
|
||||
yield $name => [$name, $strict];
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
|
||||
// The header, the layer statement, the plain imports and freedom from Tailwind are
|
||||
// StylesheetsTest's, checked over every file all.css reaches; only the block layer and the
|
||||
// imports' existence are this dataset's own to check.
|
||||
expect($css->blocks())->each->toBe('@layer material.components');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
|
||||
}
|
||||
})->with('component stylesheets');
|
||||
|
||||
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||
// `tabs` renders two views of its own, tabs.blade.php and tab.blade.php; every other name here
|
||||
// has exactly one.
|
||||
foreach (($name === 'tabs' ? ['tabs', 'tab'] : [$name]) as $view) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
|
||||
}
|
||||
})->with('component views');
|
||||
|
||||
/**
|
||||
* `$boxShadow` is the shape a stylesheet's own box-shadow values may take: 'loose' for actions and
|
||||
* communication (a component paints its own elevation through a local custom property, e.g.
|
||||
* button.css's `--md-button-elevation`, not `--md-sys-elevation-N` directly, so any `var(--md-*)`
|
||||
* passes); 'ring' for containment, inputs/selection/data and navigation (`none`, an elevation level,
|
||||
* or a literal ring — `[inset] 0 0 0 <n>px` in a colour role, a focus or a selected day); 'none' for
|
||||
* the error layout, which draws no box-shadow of its own.
|
||||
*
|
||||
* Breakpoints (px, at one of M3's four) are StylesheetsTest's, checked over every file all.css
|
||||
* reaches. field.css's autofill workaround (`transition: background-color 604800s, color
|
||||
* 604800s`) delays Chrome's autofill background for a week — not a spring, and not meant to be
|
||||
* one — so it is stripped before the transition check.
|
||||
*/
|
||||
it('takes its values from the tokens', function (string $name, string $block, string $boxShadow) {
|
||||
$label = "{$name}.css";
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
|
||||
|
||||
if ($label === 'field.css') {
|
||||
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
|
||||
}
|
||||
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
if ($boxShadow === 'loose') {
|
||||
expect($source)->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/');
|
||||
} elseif ($boxShadow === 'ring') {
|
||||
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
|
||||
|
||||
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
|
||||
|
||||
foreach ($shadows as [, $value]) {
|
||||
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
|
||||
}
|
||||
}
|
||||
})->with('component stylesheets');
|
||||
|
||||
it('is imported from its own block of all.css', function (string $name, string $block) {
|
||||
expect(allCssBlock($block))->toContain("@import './components/{$name}.css';");
|
||||
})->with('component stylesheets');
|
||||
|
||||
it('scopes every element-wide selector to a data-md hook', function (string $name, bool $strict) {
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
|
||||
$has = $strict ? ':has\(> \[data-md-' : ':has\((?:> )?\[data-md-';
|
||||
|
||||
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|'.$has.')/m');
|
||||
})->with('scoped selectors');
|
||||
|
||||
// ---- pagination: its views live outside resources/views/components/ --------------------------
|
||||
|
||||
dataset('pagination views', [
|
||||
'pagination/laravel/tailwind',
|
||||
'pagination/laravel/simple-tailwind',
|
||||
'pagination/livewire/tailwind',
|
||||
'pagination/livewire/simple-tailwind',
|
||||
]);
|
||||
|
||||
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
|
||||
|
||||
$rendered = collect($tags[1])->unique()
|
||||
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
|
||||
// <x-pane>) has no import of its own to check here.
|
||||
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
|
||||
->map(fn (string $tag): string => "./{$tag}.css")
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
|
||||
})->with('pagination views');
|
||||
|
||||
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
|
||||
})->with('pagination views');
|
||||
|
||||
// ---- partials: fragments a component view @includes, not a component view of their own --------
|
||||
|
||||
dataset('partial views', array_map(
|
||||
fn (string $file): string => 'partials/'.basename($file, '.blade.php'),
|
||||
glob(__DIR__.'/../../../resources/views/partials/*.blade.php'),
|
||||
));
|
||||
|
||||
it('writes no class list into a partial but the interaction and text classes', function (string $view) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
|
||||
})->with('partial views');
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
|
||||
/**
|
||||
* The containment components, rewritten without Tailwind (plan step 36): each view renders
|
||||
* `data-md-*` attributes and no class list of its own beyond the interaction and text classes
|
||||
* (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints as px
|
||||
* range queries, and is imported from the "Containment" block of all.css. Every component
|
||||
* stylesheet importing the stylesheets its view renders is tests/Feature/StylesheetsTest.php's
|
||||
* now, folded into one dataset over every component view (step 42) rather than kept once per
|
||||
* group.
|
||||
*
|
||||
* The dataset grew by one name per component commit, the same rule InputStylesheetsTest.php and
|
||||
* ActionStylesheetsTest.php follow. The error layout, whose view is not a component, has the same
|
||||
* checks in tests/Feature/ErrorPagesTest.php.
|
||||
*/
|
||||
dataset('containment components', [
|
||||
'divider',
|
||||
'collapse',
|
||||
'card',
|
||||
'list',
|
||||
'list-item',
|
||||
'modal',
|
||||
'drawer',
|
||||
'bottom-sheet',
|
||||
'carousel-item',
|
||||
'carousel',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
|
||||
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
|
||||
*/
|
||||
function assertContainmentBreakpointsInPx(ComponentStylesheet $css, string $label): void
|
||||
{
|
||||
foreach ($css->mediaQueries() as $query) {
|
||||
preg_match_all('/\(([^()]*)\)/', $query, $features);
|
||||
|
||||
foreach ($features[1] as $feature) {
|
||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
|
||||
|
||||
foreach ($lengths as [, $number, $unit]) {
|
||||
expect($unit)->toBe('px', "{$label}: {$query}");
|
||||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints
|
||||
* are px, at one of M3's four.
|
||||
*/
|
||||
function assertContainmentTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
|
||||
{
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
|
||||
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
|
||||
|
||||
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
|
||||
|
||||
foreach ($shadows as [, $value]) {
|
||||
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
|
||||
}
|
||||
|
||||
assertContainmentBreakpointsInPx($css, $label);
|
||||
}
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
|
||||
}
|
||||
})->with('containment components');
|
||||
|
||||
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
|
||||
})->with('containment components');
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||
assertContainmentTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
|
||||
})->with('containment components');
|
||||
|
||||
it('is imported from the containment block of all.css', function (string $name) {
|
||||
expect(allCssBlock('Containment'))->toContain("@import './components/{$name}.css';");
|
||||
})->with('containment components');
|
||||
|
||||
it('scopes every element-wide selector to a data-md hook', function (string $name) {
|
||||
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
|
||||
// `dialog` or `:root` rule would restyle the application's own pages.
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
|
||||
|
||||
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m');
|
||||
})->with('containment components');
|
||||
@@ -25,8 +25,7 @@ it('draws 52px rows, 36px dense ones, from its stylesheet in the components laye
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
|
||||
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
|
||||
->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/table.css';");
|
||||
->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))');
|
||||
});
|
||||
|
||||
it('sorts by its column, ascending first and then flipping', function () {
|
||||
@@ -110,14 +109,12 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
|
||||
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
|
||||
->toContain('@layer material.components')
|
||||
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/sort-header.css';");
|
||||
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/');
|
||||
});
|
||||
|
||||
it('draws the paginators from their stylesheet, trading the numbers for "Page n of m" below 600px', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/pagination.css'))
|
||||
->toContain('@layer material.components')
|
||||
->toMatch('/@media \\(width < 600px\\) \\{\\s*\\[data-md-pagination-page\\],\\s*\\[data-md-pagination-range\\] \\{\\s*display: none;/')
|
||||
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-pagination-compact\\] \\{\\s*display: none;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/pagination.css';");
|
||||
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-pagination-compact\\] \\{\\s*display: none;/');
|
||||
});
|
||||
|
||||
@@ -9,10 +9,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
*/
|
||||
function fabAttributes(string $blade): array
|
||||
{
|
||||
preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag);
|
||||
preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
|
||||
|
||||
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
|
||||
return layoutRoot((string) test()->blade($blade), 'button|a');
|
||||
}
|
||||
|
||||
it('draws a FAB in its container colour, named by its tooltip', function () {
|
||||
|
||||
@@ -16,27 +16,10 @@ it('takes a minimum item width as a length or a number of px', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-feed min-item="a third" />'))['style'])->toBe('--md-min-item: 240px;');
|
||||
});
|
||||
|
||||
it('replaces the spacer with a spacing token, and with no gap for an unknown one', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-feed gap="space400" />'))['data-md-gap'])->toBe('space400')
|
||||
->and(layoutRoot((string) $this->blade('<x-feed gap="wide" />'))['data-md-gap'])->toBe('none');
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props, and the caller\'s class and style after its own', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-feed as="ul" hide-below="medium" class="photos" style="padding-block: 1rem" />')))
|
||||
->toMatchArray([
|
||||
'<' => 'ul',
|
||||
'data-md-hide-below' => 'medium',
|
||||
'class' => 'photos',
|
||||
'style' => '--md-min-item: 240px; padding-block: 1rem;',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one column on a compact window and fills columns from medium, in the layout layer', function () {
|
||||
it('keeps one column on a compact window and fills columns from medium', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/feed.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain("[data-md-feed] {\n display: grid;\n grid-template-columns: minmax(0, 1fr);")
|
||||
expect($css)->toContain("[data-md-feed] {\n display: grid;\n grid-template-columns: minmax(0, 1fr);")
|
||||
->toContain("@media (width >= 600px) {\n grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--md-min-item, 240px)), 1fr));")
|
||||
->not->toContain('grid-auto-flow')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/feed.css';");
|
||||
->not->toContain('grid-auto-flow');
|
||||
});
|
||||
|
||||
@@ -248,8 +248,7 @@ it('draws a form\'s column and its actions from its stylesheet', function () {
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toContain('grid-template-columns: minmax(0, 1fr);')
|
||||
->toContain('gap: var(--md-sys-measurement-space200);')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/form.css';");
|
||||
->toContain('gap: var(--md-sys-measurement-space200);');
|
||||
});
|
||||
|
||||
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
|
||||
@@ -258,8 +257,7 @@ it('keeps the field\'s chrome in its own stylesheet in the components layer', fu
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toContain("@import './icon.css';")
|
||||
->toContain('[data-md-field-box]')
|
||||
->not->toMatch('/(?<![\\w-])\\.field\\b/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/field.css';");
|
||||
->not->toMatch('/(?<![\\w-])\\.field\\b/');
|
||||
});
|
||||
|
||||
it('takes a custom control marked as the field\'s control, and a hint class', function () {
|
||||
@@ -280,15 +278,13 @@ it('gives the one-line field a stylesheet that brings the field and its icons',
|
||||
|
||||
expect($css)->toStartWith('/*')
|
||||
->toContain("@import './field.css';")
|
||||
->toContain("@import './icon.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/input.css';");
|
||||
->toContain("@import './icon.css';");
|
||||
});
|
||||
|
||||
it('gives the password field a stylesheet that brings the field and its icons', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
|
||||
->toContain("@import './field.css';")
|
||||
->toContain("@import './icon.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/password.css';");
|
||||
->toContain("@import './icon.css';");
|
||||
});
|
||||
|
||||
it('draws what a textarea changes in the field from its own stylesheet', function () {
|
||||
@@ -298,8 +294,7 @@ it('draws what a textarea changes in the field from its own stylesheet', functio
|
||||
->toContain('@layer material.components')
|
||||
->toContain('textarea[data-md-field-control][data-md-autogrow]')
|
||||
->toContain('field-sizing: content;')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/textarea.css';");
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[');
|
||||
});
|
||||
|
||||
it('draws what a select changes in the field from its own stylesheet', function () {
|
||||
@@ -308,8 +303,7 @@ it('draws what a select changes in the field from its own stylesheet', function
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain('@supports (appearance: base-select)')
|
||||
->toContain('select[data-md-field-control]:open')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/select.css';");
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[');
|
||||
});
|
||||
|
||||
it('draws the file picker\'s button from its own stylesheet', function () {
|
||||
@@ -318,8 +312,7 @@ it('draws the file picker\'s button from its own stylesheet', function () {
|
||||
expect($css)->toContain("@import './field.css';")
|
||||
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
|
||||
->toContain('background-color: var(--md-sys-color-secondary-container);')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/file.css';");
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']");
|
||||
});
|
||||
|
||||
it('keeps a button written among a form\'s fields at its label\'s width', function () {
|
||||
|
||||
@@ -34,11 +34,6 @@ it('fills each breakpoint left out from the nearest smaller one', function () {
|
||||
expect(Layout::columns(['extra-large' => 6]))->toBe(['compact' => 1, 'medium' => 1, 'expanded' => 1, 'large' => 1, 'extra-large' => 6]);
|
||||
});
|
||||
|
||||
it('spaces the columns with a spacing token only, and with none for anything else', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-grid gap="space300" />'))['data-md-gap'])->toBe('space300')
|
||||
->and(layoutRoot((string) $this->blade('<x-grid gap="1.5rem" />'))['data-md-gap'])->toBe('none');
|
||||
});
|
||||
|
||||
it('fills a row with columns of a minimum width, capped by the counts when both are given', function () {
|
||||
$fill = layoutRoot((string) $this->blade('<x-grid min-item="280px" />'));
|
||||
$capped = layoutRoot((string) $this->blade('<x-grid min-item="18rem" :columns="[\'medium\' => 2, \'expanded\' => 3]" />'));
|
||||
@@ -58,22 +53,12 @@ it('fills a row with columns of a minimum width, capped by the counts when both
|
||||
}
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props, and the caller\'s class and style after its own', function () {
|
||||
$root = layoutRoot((string) $this->blade('<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />'));
|
||||
|
||||
expect($root)->toMatchArray(['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery'])
|
||||
->and($root['style'])->toEndWith('--md-columns-extra-large: 1; align-items: start;');
|
||||
});
|
||||
|
||||
it('reads its own column count at each breakpoint in the layout layer', function () {
|
||||
it('reads its own column count at each breakpoint', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/grid.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));');
|
||||
expect($css)->toContain('grid-template-columns: repeat(var(--md-columns), minmax(0, 1fr));');
|
||||
|
||||
foreach (['medium' => 600, 'expanded' => 840, 'large' => 1200, 'extra-large' => 1600] as $breakpoint => $width) {
|
||||
expect($css)->toContain("@media (width >= {$width}px) {\n --md-columns: var(--md-columns-{$breakpoint}, 1);");
|
||||
}
|
||||
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/grid.css';");
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ it('draws a choice as connected radios', function () {
|
||||
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
|
||||
->toMatch('/<label data-md-group-segment class="md-state-layer\s+md-touch-target\s*">/')
|
||||
->toContain('type="radio"')
|
||||
// The native control stays in the form and the accessibility tree, out of the drawing.
|
||||
->toMatch('/<input[^>]*class="md-visually-hidden"/')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
|
||||
@@ -72,8 +72,7 @@ it('draws its size from a stylesheet in the components layer', function () {
|
||||
|
||||
expect($css)->toContain('@layer material.components')
|
||||
->toContain('inline-size: var(--md-icon-size, 24px)')
|
||||
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/icon.css';");
|
||||
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)");
|
||||
});
|
||||
|
||||
it('names the icon for a screen reader when it carries the meaning alone', function () {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
|
||||
/**
|
||||
* The inputs, selection and data components, rewritten without Tailwind (plan step 36): each view
|
||||
* renders `data-md-*` attributes and no class list of its own beyond the interaction and text
|
||||
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
|
||||
* as px range queries, and is imported from the "Inputs, selection and data" block of all.css.
|
||||
* Every component stylesheet importing the stylesheets its view renders is
|
||||
* tests/Feature/StylesheetsTest.php's now, folded into one dataset over every component view (step
|
||||
* 42) rather than kept once per group.
|
||||
*
|
||||
* `pagination` is not in this dataset: its four views live in `resources/views/pagination/**`, not
|
||||
* `resources/views/components/`, so the check that reads a single `resources/views/components/
|
||||
* {name}.blade.php` file does not apply to it. It gets the two checks that do not depend on that
|
||||
* path below, and its own small version of the third.
|
||||
*/
|
||||
function inputComponents(): array
|
||||
{
|
||||
return [
|
||||
'form',
|
||||
'field',
|
||||
'input',
|
||||
'password',
|
||||
'textarea',
|
||||
'select',
|
||||
'file',
|
||||
'checkbox',
|
||||
'radio',
|
||||
'toggle',
|
||||
'chip',
|
||||
'chip-set',
|
||||
'choices',
|
||||
'slider',
|
||||
'search',
|
||||
'table',
|
||||
'sort-header',
|
||||
'datepicker',
|
||||
'timepicker',
|
||||
];
|
||||
}
|
||||
|
||||
dataset('input components', inputComponents());
|
||||
|
||||
/**
|
||||
* The checks that read a stylesheet alone also run on the one no view renders by itself:
|
||||
* selection.css, what the checkbox, radio and switch share (its own file header names them), which
|
||||
* each of their stylesheets imports too. Nothing renders it directly, so without this its shape
|
||||
* and token rules would go unchecked, the way navigation-item.css did before
|
||||
* NavigationStylesheetsTest.php picked it up.
|
||||
*/
|
||||
dataset('input stylesheets', [...inputComponents(), 'selection']);
|
||||
|
||||
/**
|
||||
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
|
||||
* breakpoints. The one exception is a viewport *height* in a query keyed on `orientation`: the time
|
||||
* picker lies its dial down by device orientation and height, which M3 explicitly does not make a
|
||||
* breakpoint (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour),
|
||||
* so that height may be any px. A width, in any query, is still a breakpoint. `rem` and `em` would
|
||||
* grow with the reader's text size, which neither should.
|
||||
*/
|
||||
function assertBreakpointsInPx(ComponentStylesheet $css, string $label): void
|
||||
{
|
||||
foreach ($css->mediaQueries() as $query) {
|
||||
$orientation = preg_match('/\(\s*orientation\s*:/', $query) === 1;
|
||||
|
||||
preg_match_all('/\(([^()]*)\)/', $query, $features);
|
||||
|
||||
foreach ($features[1] as $feature) {
|
||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
|
||||
$height = preg_match('/(?:^|[\s:<>=])(?:min-|max-)?height\b/', $feature) === 1 && ! str_contains($feature, 'width');
|
||||
|
||||
foreach ($lengths as [, $number, $unit]) {
|
||||
expect($unit)->toBe('px', "{$label}: {$query}");
|
||||
|
||||
if (! ($orientation && $height)) {
|
||||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours are roles, shadows are elevation levels, type is a type style, motion is a spring — and
|
||||
* breakpoints are px, at one of M3's four (an orientation query's height aside; see
|
||||
* assertBreakpointsInPx()).
|
||||
*
|
||||
* One named exception, in field.css alone: its autofill workaround (`transition: background-color
|
||||
* 604800s, color 604800s`) delays Chrome's autofill background for a week, which is not a spring and
|
||||
* is not meant to be one — a `var(--md-sys-motion-*)` duration there would visibly repaint on load.
|
||||
*/
|
||||
function assertTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
|
||||
{
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||
|
||||
if ($label === 'field.css') {
|
||||
$source = str_replace('transition: background-color 604800s, color 604800s;', '', $source);
|
||||
}
|
||||
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour
|
||||
// role (or a role mixed toward transparent, a disabled ring) — a day's or a year's "current"
|
||||
// outline, a focused time field's edge. Never a hand-made shadow.
|
||||
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
|
||||
|
||||
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
|
||||
|
||||
foreach ($shadows as [, $value]) {
|
||||
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
|
||||
}
|
||||
|
||||
assertBreakpointsInPx($css, $label);
|
||||
}
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
|
||||
}
|
||||
})->with('input stylesheets');
|
||||
|
||||
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php")))->toBe([]);
|
||||
})->with('input components');
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||
assertTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
|
||||
})->with('input stylesheets');
|
||||
|
||||
it('is imported from the inputs, selection and data block of all.css', function (string $name) {
|
||||
expect(allCssBlock('Inputs, selection and data'))->toContain("@import './components/{$name}.css';");
|
||||
})->with('input components');
|
||||
|
||||
// ---- pagination: its views live outside resources/views/components/ --------------------------
|
||||
|
||||
it('draws pagination from a stylesheet shaped like every package stylesheet', function () {
|
||||
$css = ComponentStylesheet::read('pagination');
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path('pagination')).'/'.$import))->toBeTrue("pagination.css imports {$import}, which does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it('takes pagination\'s values from the tokens and its breakpoints in px', function () {
|
||||
assertTokensAndPxBreakpoints(ComponentStylesheet::read('pagination'), 'pagination.css');
|
||||
});
|
||||
|
||||
it('is imported from the inputs, selection and data block of all.css for pagination', function () {
|
||||
expect(allCssBlock('Inputs, selection and data'))->toContain("@import './components/pagination.css';");
|
||||
});
|
||||
|
||||
dataset('pagination views', [
|
||||
'pagination/laravel/tailwind',
|
||||
'pagination/laravel/simple-tailwind',
|
||||
'pagination/livewire/tailwind',
|
||||
'pagination/livewire/simple-tailwind',
|
||||
]);
|
||||
|
||||
it('imports the stylesheet of every component pagination\'s views render', function (string $view) {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/{$view}.blade.php"), $tags);
|
||||
|
||||
$rendered = collect($tags[1])->unique()
|
||||
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
|
||||
// <x-pane>) has no import of its own to check here.
|
||||
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
|
||||
->map(fn (string $tag): string => "./{$tag}.css")
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_values(array_diff($rendered, ComponentStylesheet::read('pagination')->imports())))->toBe([]);
|
||||
})->with('pagination views');
|
||||
|
||||
it('writes no class list into pagination\'s views but the interaction and text classes', function (string $view) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/{$view}.blade.php")))->toBe([]);
|
||||
})->with('pagination views');
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Two checks every layout component repeated once per file: the root passes through `as`, a
|
||||
* visibility prop and the caller's own `class`/`style` (after its own style, where it writes one)
|
||||
* and any other attribute; the four with a `gap` prop replace it with a spacing token or drop to
|
||||
* `data-md-gap="none"` for anything else. Folded into one dataset each here, keyed by tag, rather
|
||||
* than kept once per component's own test file — <x-grid>'s style differs (a long per-breakpoint
|
||||
* column list before the caller's own, checked with `$styleEndsWith` instead of the exact string
|
||||
* the others match) and <x-stack>'s gap check has an extra invalid case (`space1000`, a token name
|
||||
* past the scale); both are still one dataset row, not a reason to keep the test itself apart.
|
||||
*/
|
||||
dataset('layout component root attributes', [
|
||||
'feed' => [
|
||||
'<x-feed as="ul" hide-below="medium" class="photos" style="padding-block: 1rem" />',
|
||||
['<' => 'ul', 'data-md-hide-below' => 'medium', 'class' => 'photos', 'style' => '--md-min-item: 240px; padding-block: 1rem;'],
|
||||
],
|
||||
'grid' => [
|
||||
'<x-grid as="ul" hide-below="expanded" class="gallery" style="align-items: start" />',
|
||||
['<' => 'ul', 'data-md-hide-below' => 'expanded', 'class' => 'gallery'],
|
||||
'--md-columns-extra-large: 1; align-items: start;',
|
||||
],
|
||||
'row' => [
|
||||
'<x-row as="nav" hide-from="expanded" class="toolbar-row" style="min-height: 3rem" aria-label="Filters" />',
|
||||
['<' => 'nav', 'data-md-hide-from' => 'expanded', 'class' => 'toolbar-row', 'style' => 'min-height: 3rem;', 'aria-label' => 'Filters'],
|
||||
],
|
||||
'stack' => [
|
||||
'<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />',
|
||||
['<' => 'ul', 'data-md-hide-below' => 'medium', 'data-md-hide-from' => 'large', 'class' => 'steps', 'style' => 'max-width: 40rem;'],
|
||||
],
|
||||
'pane' => [
|
||||
'<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />',
|
||||
['<' => 'section', 'data-md-hide-below' => 'expanded', 'class' => 'checkout', 'style' => 'max-width: 50rem;', 'aria-labelledby' => 'checkout-title'],
|
||||
],
|
||||
'list-detail' => [
|
||||
'<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />',
|
||||
['<' => 'section', 'data-md-hide-below' => 'medium', 'class' => 'inbox', 'style' => 'min-height: 30rem;', 'x-model' => 'chosen', 'x-modelable' => 'selected'],
|
||||
],
|
||||
'supporting-pane' => [
|
||||
'<x-supporting-pane as="main" hide-from="extra-large" class="editor" style="min-height: 20rem" />',
|
||||
['<' => 'main', 'data-md-hide-from' => 'extra-large', 'class' => 'editor', 'style' => 'min-height: 20rem;'],
|
||||
],
|
||||
]);
|
||||
|
||||
it('takes the element, the visibility props, and the caller\'s class and style after its own', function (string $blade, array $expected, ?string $styleEndsWith = null) {
|
||||
$root = layoutRoot((string) $this->blade($blade));
|
||||
|
||||
expect($root)->toMatchArray($expected);
|
||||
|
||||
if ($styleEndsWith !== null) {
|
||||
expect($root['style'])->toEndWith($styleEndsWith);
|
||||
}
|
||||
})->with('layout component root attributes');
|
||||
|
||||
dataset('layout component gaps', [
|
||||
'feed' => ['feed', 'space400', ['wide']],
|
||||
'grid' => ['grid', 'space300', ['1.5rem']],
|
||||
'row' => ['row', 'space100', ['tight']],
|
||||
'stack' => ['stack', 'space200', ['16px', 'space1000']],
|
||||
]);
|
||||
|
||||
it('replaces the gap prop with a spacing token, and with none for anything else', function (string $tag, string $token, array $invalid) {
|
||||
expect(layoutRoot((string) $this->blade("<x-{$tag} gap=\"{$token}\" />"))['data-md-gap'])->toBe($token);
|
||||
|
||||
foreach ($invalid as $bad) {
|
||||
expect(layoutRoot((string) $this->blade("<x-{$tag} gap=\"{$bad}\" />"))['data-md-gap'])->toBe('none');
|
||||
}
|
||||
})->with('layout component gaps');
|
||||
@@ -68,26 +68,12 @@ it('draws its own back row above the detail, unless the detail brings one', func
|
||||
->and($pane)->not->toContain('data-md-list-detail-back-row');
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-list-detail as="section" hide-below="medium" class="inbox" style="min-height: 30rem" x-model="chosen" />')))
|
||||
->toMatchArray([
|
||||
'<' => 'section',
|
||||
'data-md-hide-below' => 'medium',
|
||||
'class' => 'inbox',
|
||||
'style' => 'min-height: 30rem;',
|
||||
'x-model' => 'chosen',
|
||||
'x-modelable' => 'selected',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows one pane below expanded and both from it, at M3\'s fixed pane widths', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/list-detail.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
|
||||
expect($css)->toContain("@media (width >= 840px) {\n grid-template-columns: 360px minmax(0, 1fr);\n column-gap: var(--md-sys-measurement-space300);")
|
||||
->toContain("@media (width >= 1200px) {\n grid-template-columns: 412px minmax(0, 1fr);")
|
||||
->toContain("@media (width < 840px) {\n [data-md-list-detail]:not([data-md-selected]) > [data-md-list-detail-pane='detail'],\n [data-md-list-detail][data-md-selected] > [data-md-list-detail-pane='list'] {\n display: none;")
|
||||
->toContain("[data-md-list-detail-back] [data-md-icon]:is([dir='rtl'], [dir='rtl'] *)")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/list-detail.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/js/material.js'))->toContain("import './layout.js'");
|
||||
});
|
||||
|
||||
@@ -30,9 +30,11 @@ it('opens a popover menu from its trigger', function () {
|
||||
});
|
||||
|
||||
it('scrolls a menu too long for the window instead of running off it', function () {
|
||||
// The size limit is the popover chrome a submenu shares (menu-item.css imports this file for
|
||||
// it), so it sits under the selector list both are drawn from.
|
||||
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('data-md-menu-popover')
|
||||
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover]'))->toMatchArray([
|
||||
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover], [data-md-submenu]'))->toMatchArray([
|
||||
'max-block-size' => 'min(288px, calc(100dvh - 32px))',
|
||||
'overflow-y' => 'auto',
|
||||
]);
|
||||
@@ -297,7 +299,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
|
||||
// A modal bottom sheet, named by the menu's label. Its id is the same in every render, so
|
||||
// a Livewire morph patches it rather than swapping it (menu.js makes it unique and keeps
|
||||
// this one as the key); the lists carry keys of their own, as the popover does.
|
||||
->toContain('...materialBottomSheet(false, JSON.parse(')
|
||||
->toContain('...materialBottomSheet(JSON.parse(')
|
||||
->toContain('id="material-menu-sheet"')
|
||||
->not->toContain("material-menu-{$key[1]}-sheet\"")
|
||||
// A key given to the Blade component would become the key of the loop around the menu.
|
||||
|
||||
@@ -53,10 +53,10 @@ it('keeps the bar item on the label and state-layer colours M3 tokens', function
|
||||
$bar = ComponentStylesheet::read('navigation-bar-item');
|
||||
$shared = ComponentStylesheet::read('navigation-item');
|
||||
|
||||
// NavigationBarTokens names one label font for both icon positions: label-medium (N-08).
|
||||
// NavigationBarTokens names one label font for both icon positions: label-medium.
|
||||
expect($bar->declarations('[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill]', ['@container (width >= 600px)']))
|
||||
->not->toHaveKey('font')
|
||||
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19), from
|
||||
// The indicator and the pill wash in on-secondary-container, as the rail's do, from
|
||||
// the shared file both the bar's and the rail's items import.
|
||||
->and($shared->declarations('[data-md-navigation-bar-item] [data-md-navigation-indicator]::before, [data-md-navigation-bar-item] [data-md-navigation-pill]::before, [data-md-navigation-rail-item]::before, [data-md-navigation-rail-item] [data-md-navigation-indicator]::before'))
|
||||
->toHaveKey('background-color', 'var(--md-sys-color-on-secondary-container)');
|
||||
@@ -103,7 +103,7 @@ it('hides on a scroll down and springs back on a scroll up', function () {
|
||||
->toBe(['translate' => '0 100%'])
|
||||
// Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
|
||||
// `material.layout`, which this rule's own `material.components` always outranks, so both
|
||||
// sit in a layer now (N-02's era of an unlayered Tailwind utility is gone).
|
||||
// sit in a layer now (the era of an unlayered Tailwind utility is gone).
|
||||
->and($css->declarations('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden])'))
|
||||
->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ it('gives the collapsible, modal and adaptive rails a menu button and the store'
|
||||
->toContain('data-md-navigation-rail-menu')
|
||||
->toContain('aria-label="Collapse navigation"')
|
||||
->toContain('aria-expanded="true"')
|
||||
// The 40px menu button reaches M3's 48px target through the shared classes (N-01).
|
||||
// The 40px menu button reaches M3's 48px target through the shared classes.
|
||||
->toContain('class="md-state-layer md-focus-ring md-touch-target"')
|
||||
->not->toContain('data-md-navigation-rail-scrim')
|
||||
->not->toContain('x-trap')
|
||||
@@ -63,13 +63,13 @@ it('takes M3\'s optional divider and turns the container fill off', function ()
|
||||
|
||||
$css = ComponentStylesheet::read('navigation-rail');
|
||||
|
||||
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22), nor one
|
||||
// Neither reaches a rail open over a scrim, which is a surface over the page, nor one
|
||||
// sliding off the window as it closes.
|
||||
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
|
||||
->toBe(['border-inline-end' => '1px solid var(--md-sys-color-outline-variant)'])
|
||||
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
|
||||
->toBe(['background-color' => 'transparent'])
|
||||
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
|
||||
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead.
|
||||
->and($css->declarations('[data-md-navigation-rail=\'collapsible\']', ['@media (width < 600px)']))
|
||||
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
|
||||
});
|
||||
@@ -97,7 +97,7 @@ it('takes M3\'s narrow collapsed width, icons alone but still named', function (
|
||||
->and($css->declarations('[data-md-navigation-rail]'))
|
||||
->toHaveKey('--navigation-rail-collapsed-width', '96px')
|
||||
// Out of the drawing, not out of the page: clipped only while collapsed.
|
||||
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
|
||||
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]", ['@container style(--md-navigation-rail-value: collapsed)']))
|
||||
->toHaveKey('clip-path', 'inset(50%)');
|
||||
});
|
||||
|
||||
@@ -206,18 +206,18 @@ it('reproduces every branch of the old rail-collapsed variant for the rail\'s ow
|
||||
it('flattens a FAB nested in the rail header and morphs its label', function () {
|
||||
$css = ComponentStylesheet::read('navigation-rail');
|
||||
|
||||
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03). Now layered, like
|
||||
// A nested FAB rests at elevation 0, not the 3 a standalone one has. Now layered, like
|
||||
// toolbar.css's own docked-FAB override: fab.css's `[data-md-fab]` is one attribute, so the
|
||||
// doubled selector here (two) always wins without needing to sit outside the layer.
|
||||
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-fab], [data-md-navigation-rail-header] [data-md-fab][data-md-fab]:hover'))
|
||||
->toBe(['box-shadow' => 'none'])
|
||||
// One FAB whose label springs shut, not two swapped by display (N-23).
|
||||
// One FAB whose label springs shut, not two swapped by display.
|
||||
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span'))
|
||||
->toHaveKey('max-width', '256px');
|
||||
|
||||
expect($css->declarations("[data-md-navigation-rail-header] [data-md-fab] > span:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
|
||||
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span', ['@container style(--md-navigation-rail-value: collapsed)']))
|
||||
->toBe(['max-width' => '0', 'opacity' => '0'])
|
||||
->and($css->declarations("[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
|
||||
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]', ['@container style(--md-navigation-rail-value: collapsed)']))
|
||||
->toBe(['min-inline-size' => '0', 'aspect-ratio' => '1', 'gap' => '0']);
|
||||
});
|
||||
|
||||
@@ -260,21 +260,13 @@ it('draws the item\'s own state layer split between the item and its indicator',
|
||||
});
|
||||
|
||||
/**
|
||||
* Plain CSS has no variant to name "drawn collapsed" once, so every rule with a collapsed shape
|
||||
* writes the conditions out: navigation-rail.css, navigation-rail-item.css,
|
||||
* navigation-rail-section.css, and layout/scaffold.css for the actions row inside the rail's footer.
|
||||
* This walks every copy in those files — not one pinned rule — and fails if any drifts from the
|
||||
* seven conditions the Tailwind-era `rail-collapsed:` variant had, or if a rule has its collapsed
|
||||
* shape in some window bands but not in all five.
|
||||
* navigation-rail.css alone still writes "drawn collapsed" branch for branch: the two rules that
|
||||
* set the rail's own width and publish `--md-navigation-rail-value`, which the rail cannot query
|
||||
* on itself (a `@container style()` reads an ancestor, never the element that publishes the
|
||||
* property). This walks every copy of that seven-condition set and fails if any drifts from it, or
|
||||
* has its collapsed shape in some window bands but not in all five.
|
||||
*/
|
||||
dataset('stylesheets that draw a collapsed rail', [
|
||||
'navigation-rail',
|
||||
'navigation-rail-item',
|
||||
'navigation-rail-section',
|
||||
'../layout/scaffold',
|
||||
]);
|
||||
|
||||
it('writes "collapsed" with the same seven conditions in every copy, in every band', function (string $name) {
|
||||
it('writes the rail\'s own "collapsed" condition with the same seven conditions, in every band', function () {
|
||||
$closed = ':not([data-md-open])';
|
||||
$bands = [
|
||||
'always' => ["[data-md-navigation-rail='collapsed']", "[data-rail='collapsed'] [data-md-navigation-rail='collapsible']{$closed}", "[data-md-navigation-rail='modal']{$closed}"],
|
||||
@@ -284,14 +276,12 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
|
||||
'@media (width >= 1200px)' => ["[data-rail='collapsed'] [data-md-navigation-rail='adaptive']{$closed}"],
|
||||
];
|
||||
|
||||
// A condition matches the rail itself and everything in it; the scaffold's actions row can only
|
||||
// ever be inside a rail, so its copy keeps the descendant half alone.
|
||||
// A condition matches the rail itself and everything in it.
|
||||
$both = fn (array $conditions): array => array_merge(...array_map(fn (string $condition): array => [$condition, "{$condition} *"], $conditions));
|
||||
$inside = fn (array $conditions): array => array_map(fn (string $condition): string => "{$condition} *", $conditions);
|
||||
|
||||
$groups = [];
|
||||
|
||||
foreach (ComponentStylesheet::read($name)->rules() as $rule) {
|
||||
foreach (ComponentStylesheet::read('navigation-rail')->rules() as $rule) {
|
||||
preg_match_all('/:where\(((?:[^()]++|\((?1)\))*)\)/', $rule['selector'], $wheres, PREG_SET_ORDER);
|
||||
$wheres = array_filter($wheres, fn (array $where): bool => str_contains($where[1], 'data-md-navigation-rail='));
|
||||
|
||||
@@ -302,17 +292,15 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
|
||||
$media = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@media')));
|
||||
$band = match ($media) {
|
||||
[] => 'always',
|
||||
['@media (width >= 840px)', '@media (width < 1200px)'] => '@media (840px <= width < 1200px)',
|
||||
default => implode(' › ', $media),
|
||||
};
|
||||
|
||||
expect(array_key_exists($band, $bands))->toBeTrue("{$name}.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
|
||||
expect(array_key_exists($band, $bands))->toBeTrue("navigation-rail.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
|
||||
|
||||
foreach ($wheres as [$where, $conditions]) {
|
||||
$written = array_map(trim(...), preg_split('/,(?![^()]*\))/', $conditions));
|
||||
|
||||
expect(in_array($written, [$both($bands[$band]), $inside($bands[$band])], true))
|
||||
->toBeTrue("{$name}.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
|
||||
expect($written)->toBe($both($bands[$band]), "navigation-rail.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
|
||||
}
|
||||
|
||||
$key = str_replace(array_column($wheres, 0), '', $rule['selector']).' '.json_encode($rule['declarations']);
|
||||
@@ -322,6 +310,51 @@ it('writes "collapsed" with the same seven conditions in every copy, in every ba
|
||||
expect($groups)->not->toBeEmpty();
|
||||
|
||||
foreach ($groups as $key => $found) {
|
||||
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "{$name}.css: {$key} is drawn collapsed in some bands only");
|
||||
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "navigation-rail.css: {$key} is drawn collapsed in some bands only");
|
||||
}
|
||||
})->with('stylesheets that draw a collapsed rail');
|
||||
});
|
||||
|
||||
/**
|
||||
* Everything that takes a collapsed shape and is not the rail element itself reads the value the
|
||||
* rules above publish, one `@container style()` per rule, instead of a copy of the rail's own
|
||||
* seven conditions — the reason `--md-navigation-rail-value` is published at all (UPGRADE.md,
|
||||
* "From 2.0.0 to 2.1.0"). This walks every copy across navigation-rail.css,
|
||||
* navigation-rail-item.css, navigation-rail-section.css and layout/scaffold.css, fails if any of
|
||||
* them spells the query differently or wraps no declarations, and — outside navigation-rail.css,
|
||||
* where the two rules above are the only rules allowed to — fails if the old `:where()` copy of
|
||||
* the rail's conditions comes back.
|
||||
*/
|
||||
dataset('stylesheets with a rail descendant that draws a collapsed shape', [
|
||||
'navigation-rail',
|
||||
'navigation-rail-item',
|
||||
'navigation-rail-section',
|
||||
'../layout/scaffold',
|
||||
]);
|
||||
|
||||
it('reads the rail\'s published value with the same style container query in every copy', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
$rules = $css->rules();
|
||||
|
||||
$matches = array_values(array_filter(
|
||||
$rules,
|
||||
fn (array $rule): bool => array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')) !== [],
|
||||
));
|
||||
|
||||
expect($matches)->not->toBeEmpty();
|
||||
|
||||
foreach ($matches as $rule) {
|
||||
$containers = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')));
|
||||
|
||||
expect($containers)->toBe(['@container style(--md-navigation-rail-value: collapsed)'], "{$name}.css: `{$rule['selector']}` wraps a different container query")
|
||||
->and($rule['declarations'])->not->toBeEmpty("{$name}.css: `{$rule['selector']}` wraps an empty container query");
|
||||
}
|
||||
|
||||
if ($name === 'navigation-rail') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
expect(str_contains($rule['selector'], "data-md-navigation-rail='collapsed'"))
|
||||
->toBeFalse("{$name}.css: `{$rule['selector']}` copies the rail's conditions instead of querying its value");
|
||||
}
|
||||
})->with('stylesheets with a rail descendant that draws a collapsed shape');
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
|
||||
|
||||
/**
|
||||
* The navigation components, rewritten without Tailwind (plan step 36, the last audit group): each
|
||||
* view renders `data-md-*` attributes and no class list of its own beyond the interaction and text
|
||||
* classes (tests/Support/ViewClasses.php), writes its values from the tokens and its breakpoints
|
||||
* as px range queries, and is imported from the "Navigation" block of all.css. Every component
|
||||
* stylesheet importing the stylesheets its view renders is tests/Feature/StylesheetsTest.php's
|
||||
* now, folded into one dataset over every component view (step 42, `tabs`/`tab`'s N-16 pairing
|
||||
* included) rather than kept once per group.
|
||||
*
|
||||
* The dataset grows by one name per component commit, the same rule ContainmentStylesheetsTest.php
|
||||
* and the earlier groups follow. `navigation-bar` rejoined the dataset with the scaffold's own
|
||||
* rewrite: its one rule reading `--material-bottom-bar` stayed unlayered only while `<x-scaffold>`
|
||||
* published that variable through a Tailwind utility, which no layered rule could outrank; now
|
||||
* scaffold.css sets it in `material.layout`, which this file's `material.components` always beats,
|
||||
* so the whole stylesheet fits one `@layer material.components` block like every other entry here.
|
||||
* `section-nav` renders `<x-tabs>`'s hooks rather than `<x-tabs>` itself (it reuses its stylesheet
|
||||
* whole, tabs.css, N-16), so `navigationViews()` need not special-case it the way it does 'tabs'.
|
||||
*/
|
||||
function navigationComponents(): array
|
||||
{
|
||||
return [
|
||||
'app-bar',
|
||||
'toolbar',
|
||||
'tabs',
|
||||
'navigation-bar',
|
||||
'navigation-bar-item',
|
||||
'navigation-rail',
|
||||
'navigation-rail-item',
|
||||
'navigation-rail-section',
|
||||
'section-nav',
|
||||
'account-menu',
|
||||
'theme-toggle',
|
||||
'scheme-picker',
|
||||
];
|
||||
}
|
||||
|
||||
dataset('navigation components', navigationComponents());
|
||||
|
||||
/**
|
||||
* The checks that read a stylesheet alone also run on the one no view renders by itself:
|
||||
* navigation-item.css, the indicator fill and state layer the bar's and the rail's items share and
|
||||
* both import. Nothing else reaches it — the layout stylesheet tests skip component files they
|
||||
* import — so without this its layer, tokens and scoping would go unchecked.
|
||||
*/
|
||||
dataset('navigation stylesheets', [...navigationComponents(), 'navigation-item']);
|
||||
|
||||
/**
|
||||
* The blade view(s) a dataset name's checks read. One file for every name but 'tabs', whose panel
|
||||
* lives in a second view, tab.blade.php, sharing tabs.css with tabs.blade.php.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function navigationViews(string $name): array
|
||||
{
|
||||
return $name === 'tabs' ? ['tabs', 'tab'] : [$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails unless every media query in the stylesheet writes its lengths in px, at one of M3's four
|
||||
* breakpoints. `rem` and `em` would grow with the reader's text size, which a breakpoint must not.
|
||||
*/
|
||||
function assertNavigationBreakpointsInPx(ComponentStylesheet $css, string $label): void
|
||||
{
|
||||
foreach ($css->mediaQueries() as $query) {
|
||||
preg_match_all('/\(([^()]*)\)/', $query, $features);
|
||||
|
||||
foreach ($features[1] as $feature) {
|
||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
|
||||
|
||||
foreach ($lengths as [, $number, $unit]) {
|
||||
expect($unit)->toBe('px', "{$label}: {$query}");
|
||||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$label}: {$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours are roles, shadows are elevation levels or a ring, type is a type style, and breakpoints
|
||||
* are px, at one of M3's four.
|
||||
*/
|
||||
function assertNavigationTokensAndPxBreakpoints(ComponentStylesheet $css, string $label): void
|
||||
{
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
// A box-shadow is `none`, an elevation level, or a ring: `[inset] 0 0 0 <n>px` in a colour role.
|
||||
$colour = 'var\(--md-sys-color-[a-z-]+\)|color-mix\(in srgb, var\(--md-sys-color-[a-z-]+\) [^;]+?, transparent\)';
|
||||
|
||||
preg_match_all('/\bbox-shadow:\s*([^;]+);/', $source, $shadows, PREG_SET_ORDER);
|
||||
|
||||
foreach ($shadows as [, $value]) {
|
||||
expect(trim($value))->toMatch("/^(?:none|var\\(--md-sys-elevation-[0-5]\\)|(?:inset )?0 0 0 \\d+px (?:{$colour}))$/", "{$label}: box-shadow: {$value} is not none, an elevation or a ring in a colour role");
|
||||
}
|
||||
|
||||
assertNavigationBreakpointsInPx($css, $label);
|
||||
}
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
|
||||
}
|
||||
})->with('navigation stylesheets');
|
||||
|
||||
it('writes no class list into the view but the interaction and text classes', function (string $name) {
|
||||
foreach (navigationViews($name) as $view) {
|
||||
expect(ViewClasses::violations(File::get(__DIR__."/../../../resources/views/components/{$view}.blade.php")))->toBe([]);
|
||||
}
|
||||
})->with('navigation components');
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||
assertNavigationTokensAndPxBreakpoints(ComponentStylesheet::read($name), "{$name}.css");
|
||||
})->with('navigation stylesheets');
|
||||
|
||||
it('is imported from the navigation block of all.css', function (string $name) {
|
||||
expect(allCssBlock('Navigation'))->toContain("@import './components/{$name}.css';");
|
||||
})->with('navigation stylesheets');
|
||||
|
||||
it('scopes every element-wide selector to a data-md hook', function (string $name) {
|
||||
// `all.css` bundles every component stylesheet into every page, so a bare `html`, `body`,
|
||||
// `dialog` or `:root` rule would restyle the application's own pages. One that applies only
|
||||
// while a package component is on the page (`:root:has([data-md-toolbar-place…])`) is scoped.
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read($name)->css);
|
||||
|
||||
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\((?:> )?\[data-md-)/m');
|
||||
})->with('navigation stylesheets');
|
||||
@@ -57,7 +57,7 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
|
||||
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Only a subtitle<\/p>/')
|
||||
->not->toContain('data-md-modal-title');
|
||||
|
||||
// The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which
|
||||
// The markup is the same at every width; modal.css (ComponentStylesheetsTest) decides which
|
||||
// of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle.
|
||||
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
||||
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle]))')
|
||||
@@ -100,7 +100,6 @@ it('divides a scrolling body from its header and actions only while content is h
|
||||
->toContain('position: absolute;')
|
||||
// A token that reduced motion sets to zero, so there the rule simply appears.
|
||||
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
|
||||
->and(File::get(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/modal.css';")
|
||||
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
|
||||
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
|
||||
->toContain("directive('dialog-dividers'")
|
||||
@@ -209,7 +208,7 @@ it('slides a side sheet in from either edge', function () {
|
||||
|
||||
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
|
||||
expect((string) $this->blade('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
|
||||
->toContain('...materialBottomSheet(false, JSON.parse(')
|
||||
->toContain('...materialBottomSheet(JSON.parse(')
|
||||
->toContain('data-md-bottom-sheet-scrim')
|
||||
->toContain('x-trap.inert.noscroll="open"')
|
||||
->toContain('x-layer="open" x-on:material-escape="close()"')
|
||||
@@ -220,7 +219,7 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
|
||||
->toContain('md-focus-ring md-touch-target')
|
||||
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
|
||||
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
|
||||
->toContain('...materialBottomSheet(true, JSON.parse(')
|
||||
->toContain('...materialBottomSheet(JSON.parse(')
|
||||
->not->toContain('data-md-bottom-sheet-scrim')
|
||||
->not->toContain('aria-modal')
|
||||
->toContain('data-md-standard')
|
||||
@@ -289,7 +288,7 @@ it('slides a side sheet out as it slid in, from its own edge in either direction
|
||||
->toMatch('/<aside\s+x-cloak\s+x-show="open"\s+x-transition:enter="md-transition"\s+x-transition:leave="md-transition"/');
|
||||
});
|
||||
|
||||
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
|
||||
it('draws the drawer with the pane prop gone', function () {
|
||||
$view = File::get(__DIR__.'/../../../resources/views/components/drawer.blade.php');
|
||||
|
||||
expect($view)
|
||||
@@ -298,9 +297,7 @@ it('draws the drawer from a stylesheet imported from the containment block, with
|
||||
->not->toContain('paneCloseOnEscape')
|
||||
->not->toContain('pane-width')
|
||||
->not->toContain('pane-close-on-escape')
|
||||
->not->toContain('data-pane')
|
||||
->and(File::get(__DIR__.'/../../../resources/css/all.css'))
|
||||
->toContain("@import './components/drawer.css';");
|
||||
->not->toContain('data-pane');
|
||||
});
|
||||
|
||||
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
|
||||
@@ -330,10 +327,8 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun
|
||||
->toContain('aria-label="Close"');
|
||||
});
|
||||
|
||||
it('draws the bottom sheet from a stylesheet imported from the containment block and by the menu', function () {
|
||||
expect(File::get(__DIR__.'/../../../resources/css/all.css'))
|
||||
->toContain("@import './components/bottom-sheet.css';")
|
||||
->and(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
|
||||
it('draws the bottom sheet from a stylesheet imported by the menu', function () {
|
||||
expect(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
|
||||
->toContain("@import './bottom-sheet.css';")
|
||||
->and(File::get(__DIR__.'/../../../resources/js/bottom-sheet.js'))
|
||||
->toContain('data-md-bottom-sheet-handle')
|
||||
|
||||
@@ -68,24 +68,11 @@ it('caps its width with M3\'s narrow measure or the wider steps', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-pane width="40rem" />')))->not->toHaveKey('data-md-width');
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-pane as="section" hide-below="expanded" class="checkout" style="max-width: 50rem" aria-labelledby="checkout-title" />')))
|
||||
->toMatchArray([
|
||||
'<' => 'section',
|
||||
'data-md-hide-below' => 'expanded',
|
||||
'class' => 'checkout',
|
||||
'style' => 'max-width: 50rem;',
|
||||
'aria-labelledby' => 'checkout-title',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps M3\'s margin on the body, once, in the layout layer', function () {
|
||||
it('keeps M3\'s margin on the body, once', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/pane.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
|
||||
expect($css)->toContain('padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space200));')
|
||||
->toContain("@media (width >= 600px) {\n padding-inline: var(--md-layout-margin, var(--md-sys-measurement-space300));")
|
||||
->toContain("[data-md-pane-body] > * {\n --md-layout-margin: 0px;")
|
||||
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/pane.css';");
|
||||
->toContain("[data-md-pane][data-md-width='narrow'] {\n max-inline-size: 40rem;");
|
||||
});
|
||||
|
||||
@@ -56,24 +56,25 @@ it('draws the first frame on the server, at the value', function () {
|
||||
->toContain('<path d="M36.579 27.01A18 18 0 1 1 12.99 3.421" />');
|
||||
});
|
||||
|
||||
it('draws the active indicator in the colour and the track in its container', function (string $color, array $declarations) {
|
||||
$css = ComponentStylesheet::read('progress');
|
||||
|
||||
it('draws the active indicator in the colour and the track in its container', function (string $color) {
|
||||
// Every hue but primary comes straight off the shared colour-role table (color.css,
|
||||
// ColorTest.php): `stroke` reads `--md-container`, which is the hue's own container for
|
||||
// every one of them.
|
||||
expect((string) $this->blade('<x-progress value="50" :color="$color" />', ['color' => $color]))
|
||||
->toContain("data-md-color=\"{$color}\"")
|
||||
->and($css->declarations("[data-md-progress][data-md-color='{$color}']"))->toBe($declarations);
|
||||
})->with([
|
||||
'secondary' => ['secondary', ['color' => 'var(--md-sys-color-secondary)', 'stroke' => 'var(--md-sys-color-secondary-container)']],
|
||||
'tertiary' => ['tertiary', ['color' => 'var(--md-sys-color-tertiary)', 'stroke' => 'var(--md-sys-color-tertiary-container)']],
|
||||
'error' => ['error', ['color' => 'var(--md-sys-color-error)', 'stroke' => 'var(--md-sys-color-error-container)']],
|
||||
]);
|
||||
->toContain("data-md-color=\"{$color}\"");
|
||||
})->with(['secondary', 'tertiary', 'error']);
|
||||
|
||||
it('falls back to primary for an unknown colour, secondary-container track included', function () {
|
||||
$css = ComponentStylesheet::read('progress');
|
||||
|
||||
expect((string) $this->blade('<x-progress value="50" color="purple" />'))->toContain('data-md-color="primary"')
|
||||
->and($css->declarations('[data-md-progress]'))->toMatchArray([
|
||||
'color' => 'var(--md-sys-color-primary)',
|
||||
'color' => 'var(--md-color, var(--md-sys-color-primary))',
|
||||
'stroke' => 'var(--md-container, var(--md-sys-color-secondary-container))',
|
||||
])
|
||||
// Unlike every other hue, primary keeps a track of its own: the shared table's `primary`
|
||||
// entry is plain primary-container, which progress does not want.
|
||||
->and($css->declarations("[data-md-progress][data-md-color='primary']"))->toBe([
|
||||
'stroke' => 'var(--md-sys-color-secondary-container)',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -7,11 +7,6 @@ it('lays its children side by side in a div, with nothing set but itself', funct
|
||||
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-justify', 'data-md-wrap', 'data-md-stack-below']);
|
||||
});
|
||||
|
||||
it('spaces its children with a spacing token only, and with none for anything else', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-row gap="space100" />'))['data-md-gap'])->toBe('space100')
|
||||
->and(layoutRoot((string) $this->blade('<x-row gap="tight" />'))['data-md-gap'])->toBe('none');
|
||||
});
|
||||
|
||||
it('aligns its children across it and places them along it', function () {
|
||||
foreach (['center', 'start', 'end', 'stretch', 'baseline'] as $align) {
|
||||
expect(layoutRoot((string) $this->blade('<x-row :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
|
||||
@@ -39,22 +34,9 @@ it('stacks below one of M3\'s breakpoints, and never below compact', function ()
|
||||
}
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-row as="nav" hide-from="expanded" class="toolbar-row" style="min-height: 3rem" aria-label="Filters" />')))
|
||||
->toMatchArray([
|
||||
'<' => 'nav',
|
||||
'data-md-hide-from' => 'expanded',
|
||||
'class' => 'toolbar-row',
|
||||
'style' => 'min-height: 3rem;',
|
||||
'aria-label' => 'Filters',
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws the row from a stylesheet in the layout layer, stacking at the breakpoint in px', function () {
|
||||
it('draws the row from a stylesheet, stacking at the breakpoint in px', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/row.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;")
|
||||
->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/row.css';");
|
||||
expect($css)->toContain("@media (width < 840px) {\n [data-md-row][data-md-stack-below='expanded'] {\n flex-direction: column;")
|
||||
->toContain("[data-md-row][data-md-stack-below='expanded']:not([data-md-align]) {\n align-items: stretch;");
|
||||
});
|
||||
|
||||
@@ -197,15 +197,11 @@ it('stacks the actions row into a column through every branch a collapsed rail c
|
||||
|
||||
$css = scaffoldCss();
|
||||
|
||||
// The three width-independent branches share one :where() group; the four width-gated ones
|
||||
// each keep their own @media block, since CSS cannot merge different queries — the same shape
|
||||
// navigation-rail.css's own rewrite gave its internal parts.
|
||||
// The actions row is always inside a rail, never the rail element itself, so it reads the
|
||||
// rail's published value with one style container query instead of copying every branch a
|
||||
// collapsed rail can take.
|
||||
expect($css)
|
||||
->toContain("&:where(\n [data-md-navigation-rail='collapsed'] *,\n [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,\n [data-md-navigation-rail='modal']:not([data-md-open]) *\n ) {\n flex-direction: column;\n }")
|
||||
->toContain("@media (width < 600px) {\n &:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
|
||||
->toContain("@media (width < 840px) {\n &:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }")
|
||||
->toContain("@media (width >= 840px) {\n @media (width < 1200px) {\n &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }\n }")
|
||||
->toContain("@media (width >= 1200px) {\n &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }");
|
||||
->toContain("@container style(--md-navigation-rail-value: collapsed) {\n flex-direction: column;\n }");
|
||||
});
|
||||
|
||||
it('places each slot once', function () {
|
||||
@@ -264,8 +260,7 @@ it('marks the scaffold, and draws the FAB after the page\'s bar and before the p
|
||||
it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar, in the layout layer', function () {
|
||||
$css = scaffoldCss();
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
|
||||
expect($css)->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
|
||||
->toContain("@media (width >= 600px) {\n --md-scaffold-fab-margin: var(--md-sys-measurement-space300);")
|
||||
->toContain('max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)))')
|
||||
->toContain('+ var(--material-snackbar-height, 0px)')
|
||||
@@ -273,8 +268,7 @@ it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar,
|
||||
// The content region already has the window margin, so a pane inside draws none.
|
||||
// Only the region's own <main>: one an application nests in the page takes none of this.
|
||||
->toContain("[data-md-scaffold-content] > main {\n --md-layout-margin: 0px;")
|
||||
->not->toContain('[data-md-scaffold] main')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/scaffold.css';");
|
||||
->not->toContain('[data-md-scaffold] main');
|
||||
});
|
||||
|
||||
it('imports the stylesheets of the navigation rail, the navigation bar and the snackbar host', function () {
|
||||
|
||||
@@ -27,7 +27,8 @@ it('draws a radio per generated profile, bound, named and previewing on change',
|
||||
->toContain('Applies after saving')
|
||||
->toContain('data-md-scheme-picker-option="indigo"')
|
||||
->toContain('data-md-scheme-picker-option="teal"')
|
||||
->toMatch('/<input\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
|
||||
->toContain('class="md-visually-hidden"')
|
||||
->toMatch('/<input\s+class="md-visually-hidden"\s+wire:model="colorProfile"\s+type="radio"\s+name="colorProfile"\s+value="teal"/')
|
||||
->toContain('x-on:change="$store.theme.previewScheme($event.target.value)"')
|
||||
->toContain('>Teal</span>')
|
||||
->toContain('style="--swatch-light: #00897b; --swatch-dark: #80cbc4"');
|
||||
|
||||
@@ -117,6 +117,5 @@ it('draws the bar and the view from its stylesheet, at M3\'s widths', function (
|
||||
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {")
|
||||
// Leaving full screen, the header bar fades with the view instead of going on the first frame.
|
||||
->toContain('[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {')
|
||||
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/search.css';");
|
||||
->not->toMatch('/\\d(?:\\.\\d+)?rem/');
|
||||
});
|
||||
|
||||
@@ -150,8 +150,7 @@ it('draws the checkbox from its stylesheet, on the row the selection controls sh
|
||||
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
|
||||
->toContain('[data-md-selection-row]')
|
||||
->toContain('@layer material.components')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/checkbox.css';");
|
||||
->toContain('@layer material.components');
|
||||
});
|
||||
|
||||
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
|
||||
@@ -159,7 +158,6 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
|
||||
|
||||
expect($css)->toContain("@import './selection.css';")
|
||||
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/radio.css';")
|
||||
->and((string) $this->blade('<x-radio name="size" class="app-field-spacing" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
|
||||
->toMatch('/data-md-radio\\s+class="app-field-spacing" style="order: 1"/');
|
||||
});
|
||||
@@ -167,6 +165,5 @@ it('lays the radio group out from its stylesheet, inline only from 600px', funct
|
||||
it('draws the switch from its stylesheet', function () {
|
||||
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/toggle.css'))
|
||||
->toContain("@import './selection.css';")
|
||||
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/toggle.css';");
|
||||
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('is a labelled native range input under a drawing only the script touches', function () {
|
||||
$html = (string) $this->blade('<x-slider label="Volume" name="volume" value="40" :min="0" :max="200" :step="5" id="volume" />');
|
||||
@@ -46,8 +47,7 @@ it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track',
|
||||
->and($css)
|
||||
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
|
||||
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
|
||||
->toContain("@import './icon.css';")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/slider.css';");
|
||||
->toContain("@import './icon.css';");
|
||||
});
|
||||
|
||||
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
|
||||
@@ -249,23 +249,32 @@ it('keeps a range slider horizontal, as M3 asks', function () {
|
||||
->not->toContain('aria-orientation');
|
||||
});
|
||||
|
||||
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
|
||||
it('draws in the colour and its container, and greys out when disabled', function (string $color) {
|
||||
// Every hue but primary comes straight off the shared colour-role table (color.css,
|
||||
// ColorTest.php): the active pair is `--md-color`/`--md-on-color`, the inactive one is the
|
||||
// hue's own `--md-container`/`--md-on-container`, which for every one of them is its container.
|
||||
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
|
||||
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
|
||||
->toMatch('/<input[^>]*\sdisabled[\s>]/');
|
||||
})->with(['primary', 'tertiary', 'error', 'unknown' => 'pink']);
|
||||
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
|
||||
it('reads the shared colour-role table, keeping its own secondary-container for primary', function () {
|
||||
$css = ComponentStylesheet::read('slider');
|
||||
|
||||
if ($color === 'primary' || $color === 'pink') {
|
||||
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
|
||||
} else {
|
||||
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
|
||||
}
|
||||
|
||||
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
|
||||
})->with([
|
||||
'primary' => ['primary', 'primary', 'secondary-container'],
|
||||
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
|
||||
'error' => ['error', 'error', 'error-container'],
|
||||
'unknown' => ['pink', 'primary', 'secondary-container'],
|
||||
]);
|
||||
expect($css->declarations('[data-md-slider]'))->toMatchArray([
|
||||
'--slider-active' => 'var(--md-color, var(--md-sys-color-primary))',
|
||||
'--slider-inactive' => 'var(--md-container, var(--md-sys-color-secondary-container))',
|
||||
'--slider-on-active' => 'var(--md-on-color, var(--md-sys-color-on-primary))',
|
||||
'--slider-on-inactive' => 'var(--md-on-container, var(--md-sys-color-on-secondary-container))',
|
||||
])
|
||||
// Unlike every other hue, primary keeps its inactive track of its own: the shared table's
|
||||
// `primary` entry is plain primary-container, which the slider does not want.
|
||||
->and($css->declarations("[data-md-slider][data-md-color='primary']"))->toBe([
|
||||
'--slider-inactive' => 'var(--md-sys-color-secondary-container)',
|
||||
'--slider-on-inactive' => 'var(--md-sys-color-on-secondary-container)',
|
||||
])
|
||||
->and($css->declarations('[data-md-slider]:has([data-md-slider-input]:disabled)'))->toMatchArray([
|
||||
'--slider-active' => 'var(--slider-disabled-active)',
|
||||
'--slider-inactive' => 'var(--slider-disabled-inactive)',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -23,10 +23,7 @@ it('leaves the corners to the pair, growing the inner ones under the finger', fu
|
||||
|
||||
expect($css->declarations('[data-md-split-button] [data-md-split]'))->toBe([
|
||||
'--md-split-button-corner' => 'var(--md-split-button-inner)',
|
||||
'border-start-start-radius' => 'var(--md-split-button-corner)',
|
||||
'border-end-start-radius' => 'var(--md-split-button-corner)',
|
||||
'border-start-end-radius' => 'var(--md-split-button-corner)',
|
||||
'border-end-end-radius' => 'var(--md-split-button-corner)',
|
||||
'border-radius' => 'var(--md-split-button-corner)',
|
||||
])
|
||||
->and($css->declarations('[data-md-split-button] [data-md-split]:active'))->toBe(['--md-split-button-corner' => 'var(--md-split-button-inner-pressed)'])
|
||||
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true']"))->toBe(['--md-split-button-corner' => 'var(--md-split-button-full)'])
|
||||
|
||||
@@ -7,12 +7,6 @@ it('stacks its children in a div with no gap and no alignment of its own', funct
|
||||
->not->toHaveKeys(['data-md-gap', 'data-md-align', 'data-md-hide-below', 'data-md-hide-from']);
|
||||
});
|
||||
|
||||
it('spaces its children with a spacing token only, and with none for anything else', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-stack gap="space200" />'))['data-md-gap'])->toBe('space200')
|
||||
->and(layoutRoot((string) $this->blade('<x-stack gap="16px" />'))['data-md-gap'])->toBe('none')
|
||||
->and(layoutRoot((string) $this->blade('<x-stack gap="space1000" />'))['data-md-gap'])->toBe('none');
|
||||
});
|
||||
|
||||
it('aligns its children across it', function () {
|
||||
foreach (['stretch', 'start', 'center', 'end'] as $align) {
|
||||
expect(layoutRoot((string) $this->blade('<x-stack :align="$align" />', ['align' => $align]))['data-md-align'])->toBe($align);
|
||||
@@ -21,25 +15,12 @@ it('aligns its children across it', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-stack align="baseline" />')))->not->toHaveKey('data-md-align');
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-stack as="ul" hide-below="medium" hide-from="large" class="steps" style="max-width: 40rem" />')))
|
||||
->toMatchArray([
|
||||
'<' => 'ul',
|
||||
'data-md-hide-below' => 'medium',
|
||||
'data-md-hide-from' => 'large',
|
||||
'class' => 'steps',
|
||||
'style' => 'max-width: 40rem;',
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws the stack from a stylesheet in the layout layer, imported by all.css', function () {
|
||||
it('draws the stack from a stylesheet in the layout layer', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/stack.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
|
||||
expect($css)->toContain("[data-md-stack] {\n display: flex;\n flex-direction: column;\n gap: var(--md-gap);")
|
||||
// A stack inside another never takes its parent's gap.
|
||||
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/stack.css';");
|
||||
->toContain(":where([data-md-stack]) {\n --md-gap: 0px;");
|
||||
});
|
||||
|
||||
it('keeps a button at its label\'s width while the stack stretches the rest', function () {
|
||||
|
||||
@@ -50,23 +50,11 @@ it('docks the supporting pane as a bottom sheet below expanded, opened by its ha
|
||||
->and(layoutRoot((string) $this->blade('<x-supporting-pane compact="drawer" />'))['data-md-compact'])->toBe('below');
|
||||
});
|
||||
|
||||
it('takes the element, the visibility props and the caller\'s class and style', function () {
|
||||
expect(layoutRoot((string) $this->blade('<x-supporting-pane as="main" hide-from="extra-large" class="editor" style="min-height: 20rem" />')))
|
||||
->toMatchArray([
|
||||
'<' => 'main',
|
||||
'data-md-hide-from' => 'extra-large',
|
||||
'class' => 'editor',
|
||||
'style' => 'min-height: 20rem;',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the supporting pane at M3\'s widths from expanded, in the layout layer', function () {
|
||||
it('places the supporting pane at M3\'s widths from expanded', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/supporting-pane.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain("@media (width >= 840px) {\n grid-template-columns: minmax(0, 1fr) 360px;")
|
||||
expect($css)->toContain("@media (width >= 840px) {\n grid-template-columns: minmax(0, 1fr) 360px;")
|
||||
->toContain("@media (width >= 1200px) {\n grid-template-columns: minmax(0, 1fr) 412px;")
|
||||
->toContain("[data-md-supporting-pane][data-md-width='split'] {\n grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);")
|
||||
->toContain('inset-block-end: max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)));')
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/supporting-pane.css';");
|
||||
->toContain('inset-block-end: max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)));');
|
||||
});
|
||||
|
||||
@@ -61,13 +61,11 @@ it('puts the caller\'s class and style on the surface untouched', function () {
|
||||
it('draws the surface from a stylesheet in the layout layer, imported by all.css', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/surface.css');
|
||||
|
||||
expect($css)->toContain('@layer material.layout')
|
||||
->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
|
||||
expect($css)->toContain("[data-md-surface][data-md-level='surface-container-high'] {")
|
||||
->toContain('background-color: var(--md-sys-color-surface-container-high);')
|
||||
->toContain('border: 1px solid var(--md-sys-color-outline-variant);')
|
||||
->toContain("@import './spacing.css';")
|
||||
->toContain("@import './visibility.css';")
|
||||
// A pane on a surface keeps its content off the surface's edge again.
|
||||
->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;")
|
||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/surface.css';");
|
||||
->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;");
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
|
||||
->toContain('data-md-tab-indicator')
|
||||
->toContain('--tabs-indicator: share-indicator')
|
||||
->toContain('x-modelable="selected"')
|
||||
// The panel's id is the server's, so aria-controls never dangles (N-05).
|
||||
// The panel's id is the server's, so aria-controls never dangles.
|
||||
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
|
||||
->toContain('>3</span>');
|
||||
});
|
||||
@@ -69,7 +69,7 @@ it('entangles the chosen tab with Livewire and renders the one the property name
|
||||
->toContain(".entangle('tab').live")
|
||||
->not->toContain('x-modelable')
|
||||
->toMatch('/data-md-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
|
||||
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
|
||||
// The panel the property names is the one drawn, before Alpine entangles anything.
|
||||
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
|
||||
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
|
||||
});
|
||||
@@ -108,17 +108,17 @@ it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', functio
|
||||
$css = ComponentStylesheet::read('tabs');
|
||||
|
||||
expect($css->declarations('[data-md-tabs-bar][data-md-scrollable]'))
|
||||
// 52dp before the first of a scrollable set (N-10).
|
||||
// 52dp before the first of a scrollable set.
|
||||
->toHaveKey('padding-inline-start', '52px')
|
||||
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
|
||||
// A primary indicator is inset 2dp each side; a secondary one spans the tab.
|
||||
->and($css->declarations('[data-md-tab-indicator]'))
|
||||
->toHaveKey('inset-inline', '2px')
|
||||
->and($css->declarations('[data-md-tabs-bar][data-md-variant=\'secondary\'] [data-md-tab-indicator]'))
|
||||
->toHaveKey('inset-inline', '0')
|
||||
// The ring is 2px outside, as everywhere else in the package (N-20).
|
||||
// The ring is 2px outside, as everywhere else in the package.
|
||||
->and($css->declarations('[data-md-tab]:focus-visible'))
|
||||
->toBe(['color' => 'var(--md-sys-color-on-surface)', 'outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
|
||||
// A link marked as the page is the chosen tab too (N-21).
|
||||
// A link marked as the page is the chosen tab too.
|
||||
->and($css->declarations('[data-md-tab]:is([aria-selected=\'true\'], [aria-current=\'page\'])'))
|
||||
->toBe(['color' => 'var(--md-sys-color-primary)']);
|
||||
});
|
||||
@@ -200,7 +200,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
||||
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
|
||||
->toMatch('/data-md-section-nav-picker.*Security.*>\s*1\s*<.*<nav/s');
|
||||
|
||||
// The picker shows only below medium, the tab bar only from it (N-16's compact fallback).
|
||||
// The picker shows only below medium, the tab bar only from it (the compact fallback).
|
||||
$css = ComponentStylesheet::read('section-nav');
|
||||
|
||||
expect($css->declarations('[data-md-section-nav-picker]', ['@media (width >= 600px)']))
|
||||
@@ -218,7 +218,7 @@ it('marks the section whose url is the request\'s, and scrolls many sections', f
|
||||
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
|
||||
|
||||
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
|
||||
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
|
||||
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows.
|
||||
->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/')
|
||||
->not->toContain('grid-cols-')
|
||||
// Its label is `<x-tabs>`'s anatomy, drawn by tabs.css, not a one-line `md-truncate`.
|
||||
|
||||
+460
-473
File diff suppressed because it is too large
Load Diff
@@ -288,24 +288,12 @@ it('sends an expired form back to its page to refresh', function () {
|
||||
/**
|
||||
* Plan step 36: `errors::minimal`'s class lists moved into
|
||||
* resources/css/components/error-page.css, keyed on `data-md-error-*`. Its view lives outside
|
||||
* resources/views/components/, so it is not in ContainmentStylesheetsTest's dataset (whose
|
||||
* "imports what its view renders" check reads a fixed resources/views/components/<name>.blade.php
|
||||
* path); this is its own small stylesheet-shape test instead, checking the same things.
|
||||
* resources/views/components/, so it is not in ComponentStylesheetsTest's view-scoped datasets
|
||||
* (whose "imports what its view renders" and "no class list" checks read a fixed
|
||||
* resources/views/components/<name>.blade.php path) — those two stay here, reading its real view
|
||||
* path, while the stylesheet-only checks (shape, tokens, block import) run there instead, on the
|
||||
* `error-page` entry of its dataset.
|
||||
*/
|
||||
it('draws the error layout from a stylesheet shaped like every package stylesheet', function () {
|
||||
$css = ComponentStylesheet::read('error-page');
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path('error-page')).'/'.$import))->toBeTrue("error-page.css imports {$import}, which does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it('imports the stylesheet of every component the error layout renders', function () {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php'), $tags);
|
||||
|
||||
@@ -322,33 +310,6 @@ it('writes no class list into the error layout but the interaction and text clas
|
||||
expect(ViewClasses::violations(File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php')))->toBe([]);
|
||||
});
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function () {
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
|
||||
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
preg_match_all('/@media\s*([^{]+)\{/', $source, $queries);
|
||||
|
||||
foreach ($queries[1] as $query) {
|
||||
preg_match_all('/\(([^()]*)\)/', $query, $features);
|
||||
|
||||
foreach ($features[1] as $feature) {
|
||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
|
||||
|
||||
foreach ($lengths as [, $number, $unit]) {
|
||||
expect($unit)->toBe('px', $query);
|
||||
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is imported from the containment block of all.css', function () {
|
||||
expect(allCssBlock('Containment'))->toContain("@import './components/error-page.css';");
|
||||
});
|
||||
|
||||
it('styles the body only when it holds the error layout, and turns the shape on both paths', function () {
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
|
||||
|
||||
@@ -361,7 +322,7 @@ it('styles the body only when it holds the error layout, and turns the shape on
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan step 40: the fallback is `Stylesheets::bundle()` of the foundation and the error layout
|
||||
* Plan step 40: the fallback is the prebuilt bundle of the foundation and the error layout
|
||||
* (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries
|
||||
* their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the
|
||||
* bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face`
|
||||
@@ -382,13 +343,15 @@ it('bundles the foundation and the error layout into the fallback, without an im
|
||||
->toContain('[data-md-error-shape]')
|
||||
->toContain('[data-md-button]')
|
||||
->toContain('[data-md-shape]')
|
||||
// The foundation's reset and tokens travelled in too, not only the error layout.
|
||||
->toContain('[data-md-icon]')
|
||||
// The foundation's reset, tokens and page travelled in too, not only the error layout.
|
||||
->toContain('box-sizing: border-box')
|
||||
->toContain('--md-sys-color-surface');
|
||||
->toContain('--md-sys-color-surface')
|
||||
->toMatch('/html \{\s*background-color/');
|
||||
});
|
||||
|
||||
/**
|
||||
* Plan step 46: what the page inlines beside a build is `Stylesheets::bundle()` of the error layout
|
||||
* Plan step 46: what the page inlines beside a build is the prebuilt bundle of the error layout
|
||||
* alone. It reaches no `@font-face` — the package's only one is tokens/font.css's, which only the
|
||||
* foundation imports, and the application's build serves it — and no `url()` a request from the
|
||||
* page would have to resolve, so nothing needs dropping from it the way the fallback drops the face.
|
||||
@@ -398,33 +361,11 @@ it('inlines beside a build the error layout\'s bundle, which holds no font face,
|
||||
$css = (string) ErrorPage::layoutStyles();
|
||||
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
|
||||
|
||||
expect($css)->toBe(Stylesheets::bundle([$layout]))
|
||||
expect($css)->toBe(File::get(__DIR__.'/../../resources/dist/error-page.css'))
|
||||
->and(array_map(basename(...), Stylesheets::resolvedFiles([$layout])))
|
||||
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css'])
|
||||
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css', 'color.css'])
|
||||
->and($plain)->not->toContain('@font-face')
|
||||
->not->toContain('@import')
|
||||
->not->toMatch('/\burl\(/i')
|
||||
->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;');
|
||||
});
|
||||
|
||||
/**
|
||||
* The bundle's comments name `@font-face` too (foundation/base.css's and error-page.css's headers).
|
||||
* Read as the rule, each mention cut from inside its comment to the end of the next balanced block
|
||||
* and left the comment open, so base.css's `html` rule and icon.css's rules went missing from the
|
||||
* fallback. Only the real block may leave: what remains is the bundle, rule for rule, without it.
|
||||
*/
|
||||
it('drops only the real font face from the fallback, not the rules after a comment that names one', function () {
|
||||
$files = [
|
||||
realpath(__DIR__.'/../../resources/css/foundation.css'),
|
||||
realpath(__DIR__.'/../../resources/css/components/error-page.css'),
|
||||
];
|
||||
$withoutComments = fn (string $css): string => (string) preg_replace('~/\*.*?\*/~s', '', $css);
|
||||
$bundle = Stylesheets::bundle($files);
|
||||
|
||||
expect(substr_count($withoutComments($bundle), '@font-face'))->toBe(1)
|
||||
->and(substr_count($bundle, '@font-face'))->toBeGreaterThan(1)
|
||||
->and($withoutComments((string) ErrorPage::fallbackStyles()))
|
||||
->toStartWith($withoutComments((string) preg_replace('/@font-face\s*\{[^{}]*\}/', '', $bundle)))
|
||||
->toMatch('/html \{\s*background-color: var\(--md-sys-color-surface\);/')
|
||||
->toContain('[data-md-icon] {');
|
||||
});
|
||||
|
||||
+10
-14
@@ -4,17 +4,19 @@ use Illuminate\Support\Facades\Process;
|
||||
|
||||
/**
|
||||
* PHP cannot open a woff2 without the Brotli extension, so the axes are read by Node:
|
||||
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node the test is skipped, the
|
||||
* bin/check-font.mjs (fontkit) prints them as JSON. Without Node every test here is skipped, the
|
||||
* way `material:scheme` reports the same missing binary instead of guessing.
|
||||
*/
|
||||
it('keeps the variable axes the typescale depends on in the packaged font', function () {
|
||||
$node = config('livewire-material.node', 'node');
|
||||
beforeEach(function () {
|
||||
$this->node = config('livewire-material.node', 'node');
|
||||
|
||||
if (Process::run([$node, '--version'])->failed()) {
|
||||
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
|
||||
if (Process::run([$this->node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/fontkit')) {
|
||||
$this->markTestSkipped("Node ({$this->node}) or fontkit is missing; bin/check-font.mjs reads the woff2's axes.");
|
||||
}
|
||||
});
|
||||
|
||||
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs']);
|
||||
it('keeps the variable axes the typescale depends on in the packaged font', function () {
|
||||
$result = Process::run([$this->node, __DIR__.'/../../bin/check-font.mjs']);
|
||||
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
@@ -27,14 +29,8 @@ it('keeps the variable axes the typescale depends on in the packaged font', func
|
||||
->and($font['axes']['ROND'])->toMatchArray(['min' => 0, 'max' => 100]);
|
||||
});
|
||||
|
||||
it('fails loudly when the font has no variable axes at all', function () {
|
||||
$node = config('livewire-material.node', 'node');
|
||||
|
||||
if (Process::run([$node, '--version'])->failed()) {
|
||||
$this->markTestSkipped("Node ({$node}) could not run; `npm run check:font` reads the woff2's axes.");
|
||||
}
|
||||
|
||||
$result = Process::run([$node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
|
||||
it('fails loudly when the font file cannot be opened', function () {
|
||||
$result = Process::run([$this->node, __DIR__.'/../../bin/check-font.mjs', __DIR__.'/../Fixtures/no-such-font.woff2']);
|
||||
|
||||
expect($result->failed())->toBeTrue()
|
||||
->and($result->errorOutput())->toContain('no-such-font.woff2');
|
||||
|
||||
@@ -33,12 +33,6 @@ it('compiles an unprefixed component to the same view on every machine', functio
|
||||
->not->toContain(hash('xxh128', LivewireMaterialServiceProvider::componentPath()));
|
||||
});
|
||||
|
||||
it('still renders a view compiled when the namespace came from the component path', function () {
|
||||
$legacy = hash('xxh128', LivewireMaterialServiceProvider::componentPath());
|
||||
|
||||
expect(view()->exists($legacy.'::input'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('registers the components under a configured prefix', function () {
|
||||
config(['livewire-material.prefix' => 'm']);
|
||||
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseAssetController;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
use NoNameWeb\LivewireMaterial\Support\SchemeStylesheet;
|
||||
|
||||
/**
|
||||
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, bundled by
|
||||
* `Stylesheets::bundle()` and served without the application's Vite build, and the two package
|
||||
* The showcase's own stylesheet route (plan step 38): `all.css` plus `showcase.css`, prebuilt into
|
||||
* resources/dist/showcase.css and served without the application's Vite build, and the two package
|
||||
* folders its relative `url()`s point into.
|
||||
*/
|
||||
afterEach(function () {
|
||||
Stylesheets::resetCache();
|
||||
});
|
||||
|
||||
it('serves the bundle as long-cached CSS at the hash of its own content', function () {
|
||||
$response = $this->get(ShowcaseAssetController::url())
|
||||
->assertOk()
|
||||
@@ -35,11 +32,14 @@ it('redirects a hash that does not match the current bundle to the current one',
|
||||
it('serves the font and svg files the bundle references, mime-typed by extension', function () {
|
||||
$css = $this->get(ShowcaseAssetController::url())->getContent();
|
||||
|
||||
// The stylesheet sits in assets/css/, so its relative `../fonts/…` and `../svg/…` land on the asset route.
|
||||
expect(dirname(parse_url(ShowcaseAssetController::url(), PHP_URL_PATH)))->toBe(route('livewire-material.asset', ['path' => 'css'], false))
|
||||
->and($css)->toContain("url('../fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')")
|
||||
->toContain('url("../svg/symbols/outlined/check.svg")');
|
||||
|
||||
$fontUrl = route('livewire-material.asset', ['path' => 'fonts/google-sans-flex/GoogleSansFlex-Latin.woff2'], false);
|
||||
$svgUrl = route('livewire-material.asset', ['path' => 'svg/symbols/outlined/check.svg'], false);
|
||||
|
||||
expect($css)->toContain($fontUrl)->toContain($svgUrl);
|
||||
|
||||
$this->get($fontUrl)
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'font/woff2')
|
||||
@@ -93,7 +93,6 @@ it('bundles the application generated scheme, unlayered, when one sits beside it
|
||||
|
||||
file_put_contents($dir.'/material-scheme.css', ":root { --md-showcase-test-marker: #123456; }\n");
|
||||
config(['livewire-material.scheme' => $dir.'/material-scheme.json']);
|
||||
Stylesheets::resetCache();
|
||||
|
||||
try {
|
||||
$this->get(ShowcaseAssetController::url())
|
||||
@@ -105,15 +104,12 @@ it('bundles the application generated scheme, unlayered, when one sits beside it
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to Scheme::load() in the default shape when no generated stylesheet is found', function () {
|
||||
it('falls back to the scheme JSON in the generated shape when no generated stylesheet is found', function () {
|
||||
config(['livewire-material.scheme' => sys_get_temp_dir().'/livewire-material-showcase-missing-'.Str::random(8).'.json']);
|
||||
Stylesheets::resetCache();
|
||||
|
||||
$css = $this->get(ShowcaseAssetController::url())->assertOk()->getContent();
|
||||
|
||||
// The fallback's own one-line selector list, distinct from tokens/scheme.css's multi-line one
|
||||
// (already in all.css), so its presence here can only come from the appended fallback block.
|
||||
expect($css)->toContain("[data-contrast='medium'], [data-contrast='medium'][data-theme='light'], [data-contrast='medium'] [data-theme='light']");
|
||||
expect($css)->toEndWith(SchemeStylesheet::withProfiles(...Scheme::forStylesheet()))
|
||||
->toContain("[data-contrast='medium'][data-theme='dark']");
|
||||
});
|
||||
|
||||
it('mounts none of the showcase asset routes when the showcase is off', function () {
|
||||
@@ -122,7 +118,7 @@ it('mounts none of the showcase asset routes when the showcase is off', function
|
||||
$this->refreshApplication();
|
||||
|
||||
try {
|
||||
$this->get('/material/showcase.0000000000000000.css')->assertNotFound();
|
||||
$this->get('/material/assets/css/showcase.0000000000000000.css')->assertNotFound();
|
||||
$this->get('/material/assets/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2')->assertNotFound();
|
||||
} finally {
|
||||
putenv('MATERIAL_SHOWCASE=true');
|
||||
|
||||
@@ -4,84 +4,25 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* `Stylesheets::bundle()` (plan step 37) is what the showcase and the error page's fallback serve
|
||||
* without the application's own Vite build, so it has to do in PHP what Vite's bundled
|
||||
* postcss-import already does for a normal build: inline every `@import` once, first occurrence
|
||||
* kept, and leave a `url()` reachable from wherever the bundle ends up.
|
||||
* The stylesheets the package serves on its own — the showcase's and the error page's, outside an
|
||||
* application's Vite build — are prebuilt into resources/dist/ by `npm run build:stylesheets`
|
||||
* (bin/stylesheets.mjs), with Vite's own postcss-import: every `@import` inlined once, first
|
||||
* occurrence kept. This file pins that the committed files are current, the Vite deduplication they
|
||||
* rely on, and `Stylesheets::resolvedFiles()`, the import graph `DesignGuard` reads.
|
||||
*
|
||||
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
|
||||
* file's own header, layer statement and imports) — a concern that holds whether or not anything
|
||||
* ever bundles them. This file is about the bundler itself: what `bundle()` produces, and, at the
|
||||
* end, a real Vite build to pin the claim it is modelled on.
|
||||
*
|
||||
* Every helper here is named `stylesheetsBundle*` rather than reusing StylesheetsTest.php's
|
||||
* `stylesheet*` names — the two files may run in the same Pest process, and a duplicate top-level
|
||||
* function name is a fatal error, the same reason the four component-group stylesheet test files
|
||||
* each name their own `assert…TokensAndPxBreakpoints()` uniquely.
|
||||
* file's own header, layer statement and imports). `stylesheetsBundlePath()` is named apart from
|
||||
* StylesheetsTest.php's `stylesheetPath()` for the same reason: the two files may run in the same
|
||||
* Pest process, and a duplicate top-level function name is a fatal error.
|
||||
*/
|
||||
function stylesheetsBundlePath(string $path = ''): string
|
||||
{
|
||||
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
|
||||
return (string) realpath(ComponentStylesheet::cssPath($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* `$css` with every comment and quoted string blanked out — the same idea StylesheetsTest.php's
|
||||
* `stylesheetWithoutComments()` uses, kept local here for the reason above.
|
||||
*/
|
||||
function stylesheetsBundleWithoutComments(string $css): string
|
||||
{
|
||||
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first line of a stylesheet's header comment, the sentence naming what it draws — unique per
|
||||
* file (checked below), untouched by `bundle()` since it only ever rewrites an `@import` or a
|
||||
* `url()`, never a comment. A reliable per-file fingerprint in `bundle()`'s output, which is not
|
||||
* minified.
|
||||
*/
|
||||
function stylesheetsBundleMarker(string $file): string
|
||||
{
|
||||
$lines = explode("\n", File::get($file));
|
||||
|
||||
return trim((string) preg_replace('/^\s*\*\s?/', '', $lines[1] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function stylesheetsBundleComponentAndLayoutFiles(): array
|
||||
{
|
||||
return collect(File::files(stylesheetsBundlePath('components')))
|
||||
->merge(File::files(stylesheetsBundlePath('layout')))
|
||||
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
it('bundles all.css with every import resolved, foundation first, every component and layout file once', function () {
|
||||
$bundled = Stylesheets::bundle([stylesheetsBundlePath('all.css')]);
|
||||
|
||||
expect(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
|
||||
|
||||
$files = stylesheetsBundleComponentAndLayoutFiles();
|
||||
$markers = collect($files)->mapWithKeys(fn (string $file): array => [$file => stylesheetsBundleMarker($file)]);
|
||||
|
||||
expect($markers)->toHaveCount(count($files))
|
||||
->and($markers->unique()->count())->toBe($markers->count(), 'two files share a header marker');
|
||||
|
||||
foreach ($markers as $file => $marker) {
|
||||
expect(substr_count($bundled, $marker))->toBe(1, basename($file).' should appear exactly once in the bundle');
|
||||
}
|
||||
|
||||
$foundationPosition = strpos($bundled, stylesheetsBundleMarker(stylesheetsBundlePath('foundation.css')));
|
||||
$earliestOtherPosition = $markers->map(fn (string $marker): int => (int) strpos($bundled, $marker))->min();
|
||||
|
||||
expect($foundationPosition)->toBeInt()
|
||||
->and($foundationPosition)->toBeLessThan($earliestOtherPosition);
|
||||
});
|
||||
|
||||
it('resolves the files an entry imports, transitively, without bundling any content', function () {
|
||||
$files = Stylesheets::resolvedFiles([stylesheetsBundlePath('components/split-button.css')]);
|
||||
|
||||
@@ -93,7 +34,7 @@ it('resolves the files an entry imports, transitively, without bundling any cont
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves past what an application entry imports and bundle() refuses, without throwing', function () {
|
||||
it('resolves past what an application entry imports and only a Vite build resolves, without throwing', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put($dir.'/theme.css', '.theme {}');
|
||||
@@ -114,131 +55,6 @@ it('resolves past what an application entry imports and bundle() refuses, withou
|
||||
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.css']);
|
||||
});
|
||||
|
||||
it('keeps a file two others import once, at its first position', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
|
||||
File::put("{$dir}/shared.css", "@layer material.reset;\n.shared { color: green; }\n");
|
||||
File::put("{$dir}/a.css", "@import './shared.css';\n.a { color: red; }\n");
|
||||
File::put("{$dir}/b.css", "@import './shared.css';\n.b { color: blue; }\n");
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/a.css", "{$dir}/b.css"]);
|
||||
|
||||
expect(substr_count($bundled, '.shared { color: green; }'))->toBe(1)
|
||||
->and(strpos($bundled, '.shared'))->toBeLessThan(strpos($bundled, '.a { color: red; }'))
|
||||
->and($bundled)->toContain('.b { color: blue; }')
|
||||
->and(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import');
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('inlines every plain import form once, and nothing written in a comment or a string', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory("{$dir}/components", recursive: true);
|
||||
|
||||
foreach (['double', 'quoted-url', 'bare-url'] as $name) {
|
||||
// A leading byte-order mark and @charset mean nothing past a stylesheet's first byte.
|
||||
File::put("{$dir}/components/{$name}.css", "\u{FEFF}@charset \"UTF-8\";\n.{$name} {}\n");
|
||||
}
|
||||
|
||||
File::put("{$dir}/entry.css", <<<'CSS'
|
||||
/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */
|
||||
@layer a, b;
|
||||
@import "./components/double.css";
|
||||
@import url('./components/quoted-url.css');
|
||||
@IMPORT url( ./components/bare-url.css );
|
||||
@import './components/../components/double.css';
|
||||
.entry::before { content: "@import './components/never.css'; url(never.png)"; }
|
||||
CSS);
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/entry.css"]);
|
||||
|
||||
expect(substr_count($bundled, '.double {}'))->toBe(1)
|
||||
->and($bundled)->toContain('.quoted-url {}')
|
||||
->toContain('.bare-url {}')
|
||||
->toContain("/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */")
|
||||
->toContain(".entry::before { content: \"@import './components/never.css'; url(never.png)\"; }")
|
||||
->not->toContain('@charset')
|
||||
->not->toContain("\u{FEFF}")
|
||||
// The two left are the comment's and the string's.
|
||||
->and(substr_count(strtolower($bundled), '@import'))->toBe(2);
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws, naming the file, for an import the bundle could not inline without changing its meaning', function (string $css, string $reason) {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put("{$dir}/x.css", '.x {}');
|
||||
File::put("{$dir}/entry.css", $css);
|
||||
|
||||
try {
|
||||
Stylesheets::bundle(["{$dir}/entry.css"]);
|
||||
|
||||
$this->fail("Stylesheets::bundle() should have thrown for {$css}");
|
||||
} catch (RuntimeException $exception) {
|
||||
expect($exception->getMessage())->toContain('entry.css')->toContain($reason);
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
})->with([
|
||||
'a layer() condition' => ["@import './x.css' layer(x);", 'without layer()'],
|
||||
'a supports() condition' => ["@import './x.css' supports(display: grid);", 'without layer()'],
|
||||
'a media condition' => ['@import url(./x.css) screen;', 'without layer()'],
|
||||
'an absolute URL' => ["@import 'https://example.com/x.css';", 'absolute URL'],
|
||||
'an absolute path' => ["@import '/x.css';", 'absolute URL'],
|
||||
'a package specifier' => ["@import 'tailwindcss';", '"tailwindcss", which does not exist'],
|
||||
'an import after a rule' => [".a {}\n@import './x.css';", 'after a rule'],
|
||||
'an import inside a block' => ["@layer b { @import './x.css'; }", 'inside a block'],
|
||||
]);
|
||||
|
||||
it('rewrites a relative url() against the entry file\'s directory, or against $base when given', function () {
|
||||
// Mirrors resources/css/'s own shape: an entry (all.css) sitting where the bundle is "from",
|
||||
// a component one level under it (components/menu.css), and an asset one level above that
|
||||
// again (resources/fonts/, resources/svg/, siblings of resources/css/) — so a url() two
|
||||
// directories away from the file that wrote it lands one directory away from the entry.
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory("{$dir}/css/components", recursive: true);
|
||||
File::makeDirectory("{$dir}/fonts");
|
||||
|
||||
File::put("{$dir}/css/entry.css", "@import './components/widget.css';\n");
|
||||
File::put("{$dir}/css/components/widget.css", <<<'CSS'
|
||||
@font-face { src: url('../../fonts/widget.woff2') format('woff2'); }
|
||||
.widget { background: url("icon.svg"), url(https://example.com/a.png), url(#gradient), url(data:image/png;base64,AA==); }
|
||||
CSS);
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/css/entry.css"]);
|
||||
|
||||
// Resolved against the entry's own directory (css/) when no $base is given: the font
|
||||
// climbs out of css/ to the sibling fonts/ directory, the same shape resources/css/ has
|
||||
// relative to resources/fonts/; the icon, sitting beside widget.css, stays inside components/.
|
||||
expect($bundled)->toContain("url('../fonts/widget.woff2')")
|
||||
->toContain('url("components/icon.svg")')
|
||||
// An absolute URL, a # fragment and a data: URI are left exactly as written.
|
||||
->toContain('url(https://example.com/a.png)')
|
||||
->toContain('url(#gradient)')
|
||||
->toContain('url(data:image/png;base64,AA==)');
|
||||
|
||||
// $base stands for wherever the entry itself (the thing "resources/css/" is here) is
|
||||
// served from: the icon, which never left that directory, lands right under $base, while
|
||||
// the font's one directory climbed above the entry the same way it did with no $base at
|
||||
// all, so it climbs one directory above $base too — off the end of "assets", not inside it.
|
||||
Stylesheets::resetCache();
|
||||
$withBase = Stylesheets::bundle(["{$dir}/css/entry.css"], 'https://cdn.example.com/assets');
|
||||
|
||||
expect($withBase)->toContain("url('https://cdn.example.com/fonts/widget.woff2')")
|
||||
->toContain('url("https://cdn.example.com/assets/components/icon.svg")')
|
||||
->toContain('url(https://example.com/a.png)');
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
|
||||
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
|
||||
@@ -252,7 +68,7 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
|
||||
$walk = function (string $file, array $stack) use (&$walk, &$edges): void {
|
||||
expect(in_array($file, $stack, true))->toBeFalse('import cycle: '.implode(' → ', array_map(basename(...), [...$stack, $file])));
|
||||
|
||||
preg_match_all("/@import\\s+'([^']+)'/", stylesheetsBundleWithoutComments(File::get($file)), $matches);
|
||||
preg_match_all("/@import\\s+'([^']+)'/", ComponentStylesheet::withoutComments(File::get($file)), $matches);
|
||||
|
||||
foreach ($matches[1] as $import) {
|
||||
$edges++;
|
||||
@@ -265,84 +81,32 @@ it('keeps the package\'s import graph free of cycles, so a stylesheet always lan
|
||||
expect($edges)->toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('does not loop on a cycle, and keeps both files\' rules', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
/**
|
||||
* resources/dist/ is committed so applications need no Node; a stylesheet change without
|
||||
* `npm run build:stylesheets` would leave the showcase and the error pages serving the old rules.
|
||||
*/
|
||||
it('keeps the prebuilt stylesheets in resources/dist/ in step with resources/css/', function () {
|
||||
$node = config('livewire-material.node', 'node');
|
||||
|
||||
File::put("{$dir}/a.css", "@import './b.css';\n.a {}\n");
|
||||
File::put("{$dir}/b.css", "@import './a.css';\n.b {}\n");
|
||||
|
||||
try {
|
||||
$bundled = Stylesheets::bundle(["{$dir}/a.css"]);
|
||||
|
||||
expect($bundled)->toContain('.a {}')->toContain('.b {}');
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
if (Process::run([$node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/vite')) {
|
||||
$this->markTestSkipped("Node ({$node}) or Vite is missing; `npm run build:stylesheets` builds resources/dist/.");
|
||||
}
|
||||
});
|
||||
|
||||
it('throws naming the importer when an import does not exist', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
|
||||
File::put("{$dir}/broken.css", "@import './missing.css';\n");
|
||||
$outDir = sys_get_temp_dir().'/livewire-material-dist-'.Str::random(8);
|
||||
|
||||
try {
|
||||
Stylesheets::bundle(["{$dir}/broken.css"]);
|
||||
$result = Process::run([$node, __DIR__.'/../../bin/stylesheets.mjs', $outDir]);
|
||||
|
||||
$this->fail('Stylesheets::bundle() should have thrown.');
|
||||
} catch (RuntimeException $exception) {
|
||||
expect($exception->getMessage())->toContain('broken.css')->toContain('missing.css');
|
||||
expect($result->successful())->toBeTrue($result->errorOutput());
|
||||
|
||||
foreach (File::files($outDir) as $built) {
|
||||
$committed = __DIR__.'/../../resources/dist/'.$built->getFilename();
|
||||
|
||||
expect(is_file($committed) ? File::get($committed) : null)
|
||||
->toBe($built->getContents(), "resources/dist/{$built->getFilename()} is stale: run `npm run build:stylesheets`");
|
||||
}
|
||||
} finally {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
});
|
||||
|
||||
it('caches per file list and mtime, and resetCache() forces a fresh read', function () {
|
||||
$dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8);
|
||||
File::makeDirectory($dir);
|
||||
File::put("{$dir}/cached.css", '.before {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_000);
|
||||
|
||||
try {
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))->toBe('.before {}');
|
||||
|
||||
// The mtime is unchanged, so the cache serves the old content even though the file itself
|
||||
// now holds something else.
|
||||
File::put("{$dir}/cached.css", '.changed-but-same-mtime {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_000);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
||||
->toBe('.before {}', 'an unchanged mtime should still be served from the cache');
|
||||
|
||||
Stylesheets::resetCache();
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
||||
->toBe('.changed-but-same-mtime {}', 'resetCache() should force a fresh read');
|
||||
|
||||
// A later mtime invalidates the cache on its own, with no resetCache() needed.
|
||||
File::put("{$dir}/cached.css", '.after {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_100);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/cached.css"]))
|
||||
->toBe('.after {}', 'a changed mtime should invalidate the cache on its own');
|
||||
|
||||
// So does a later mtime on a file the bundle reached only through an @import — a changed
|
||||
// button.css must not leave a cached all.css bundle standing.
|
||||
File::put("{$dir}/entry.css", "@import './cached.css';\n");
|
||||
touch("{$dir}/entry.css", 1_700_000_000);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/entry.css"]))->toContain('.after {}');
|
||||
|
||||
File::put("{$dir}/cached.css", '.nested-change {}');
|
||||
touch("{$dir}/cached.css", 1_700_000_200);
|
||||
|
||||
expect(Stylesheets::bundle(["{$dir}/entry.css"]))
|
||||
->toContain('.nested-change {}')
|
||||
->and(Stylesheets::bundle(["{$dir}/entry.css"], ''))->toContain('.nested-change {}');
|
||||
} finally {
|
||||
Stylesheets::resetCache();
|
||||
File::deleteDirectory($dir);
|
||||
File::deleteDirectory($outDir);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -398,11 +162,11 @@ function stylesheetsBundleLeafRules(string $css): array
|
||||
}
|
||||
|
||||
/**
|
||||
* The Vite deduplication `bundle()` is modelled on, pinned against the real bundler
|
||||
* (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph): `all.css`, and
|
||||
* tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside the package, the
|
||||
* foundation, then split button, app bar, modal and pagination, which each import button.css, and
|
||||
* button.css once more.
|
||||
* The Vite deduplication the prebuilt stylesheets and every application's build rely on, pinned
|
||||
* against the real bundler (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph):
|
||||
* `all.css`, and tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside
|
||||
* the package, the foundation, then split button, app bar, modal and pagination, which each
|
||||
* import button.css, and button.css once more.
|
||||
*
|
||||
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
|
||||
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Support\Layout;
|
||||
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The package's stylesheets are plain CSS in `material.*` layers, with no Tailwind in them. Every
|
||||
@@ -18,87 +16,14 @@ use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
* below run over the whole tree `all.css` reaches (foundation, layout and components together)
|
||||
* rather than the foundation alone, and — one file it does not reach — `showcase.css`, checked on
|
||||
* its own further down. A component's own values (colours as roles, shadows as elevation levels,
|
||||
* and so on) are each group's own concern, in tests/Feature/Components/*StylesheetsTest.php;
|
||||
* `bundle()`, the PHP bundler `all.css` is built for, is tests/Feature/StylesheetsBundleTest.php's.
|
||||
* and so on) are ComponentStylesheetsTest's own concern.
|
||||
* The prebuilt bundles in resources/dist/ are tests/Feature/StylesheetsBundleTest.php's.
|
||||
*/
|
||||
const MATERIAL_LAYER_STATEMENT = '@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;';
|
||||
|
||||
function stylesheetPath(string $path = ''): string
|
||||
{
|
||||
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A stylesheet without its comments; a quoted string keeps whatever it holds.
|
||||
*/
|
||||
function stylesheetWithoutComments(string $css): string
|
||||
{
|
||||
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level statements (`@layer …;`, `@import …;`) and blocks of a stylesheet, in order, with
|
||||
* whitespace collapsed in statements and preludes.
|
||||
*
|
||||
* @return list<array{statement: string}|array{prelude: string, body: string}>
|
||||
*/
|
||||
function stylesheetItems(string $css): array
|
||||
{
|
||||
$css = stylesheetWithoutComments($css);
|
||||
$items = [];
|
||||
$start = 0;
|
||||
$depth = 0;
|
||||
$quote = null;
|
||||
$opening = 0;
|
||||
|
||||
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
|
||||
$char = $css[$i];
|
||||
|
||||
if ($quote !== null) {
|
||||
if ($char === '\\') {
|
||||
$i++;
|
||||
} elseif ($char === $quote) {
|
||||
$quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"' || $char === "'") {
|
||||
$quote = $char;
|
||||
} elseif ($char === '{') {
|
||||
if ($depth++ === 0) {
|
||||
$opening = $i;
|
||||
}
|
||||
} elseif ($char === '}' && --$depth === 0) {
|
||||
$items[] = [
|
||||
'prelude' => (string) preg_replace('/\s+/', ' ', trim(substr($css, $start, $opening - $start))),
|
||||
'body' => substr($css, $opening + 1, $i - $opening - 1),
|
||||
];
|
||||
$start = $i + 1;
|
||||
} elseif ($char === ';' && $depth === 0) {
|
||||
$items[] = ['statement' => preg_replace('/\s+/', ' ', trim(substr($css, $start, $i - $start))).';'];
|
||||
$start = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(trim(substr($css, $start)))->toBe('', 'The stylesheet ends inside an unclosed rule.');
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declarations of a flat block body, by property.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function stylesheetDeclarations(string $body): array
|
||||
{
|
||||
return collect(explode(';', $body))
|
||||
->map(fn (string $declaration): string => trim($declaration))
|
||||
->filter()
|
||||
->mapWithKeys(fn (string $declaration): array => [trim(strstr($declaration, ':', true)) => trim(substr(strstr($declaration, ':'), 1))])
|
||||
->all();
|
||||
return (string) realpath(ComponentStylesheet::cssPath($path));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,13 +31,13 @@ function stylesheetDeclarations(string $body): array
|
||||
*/
|
||||
function stylesheetBlock(string $css, string $prelude): string
|
||||
{
|
||||
foreach (stylesheetItems($css) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||||
if (($item['prelude'] ?? null) === $prelude) {
|
||||
return $item['body'];
|
||||
}
|
||||
|
||||
if (str_starts_with($item['prelude'] ?? '', '@layer ')) {
|
||||
foreach (stylesheetItems($item['body']) as $inner) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body'])) as $inner) {
|
||||
if (($inner['prelude'] ?? null) === $prelude) {
|
||||
return $inner['body'];
|
||||
}
|
||||
@@ -130,8 +55,8 @@ function stylesheetBlock(string $css, string $prelude): string
|
||||
*/
|
||||
function stylesheetImports(string $file): array
|
||||
{
|
||||
return collect(stylesheetItems(File::get($file)))
|
||||
->map(fn (array $item): ?string => preg_match('/^@import\s+([\'"])(.+?)\1/', $item['statement'] ?? '', $match) === 1 ? $match[2] : null)
|
||||
return collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))))
|
||||
->map(fn (array $item): ?string => ComponentStylesheet::importPath($item['statement'] ?? ''))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
@@ -205,8 +130,10 @@ it('brings every foundation file and text.css into the foundation, and nothing e
|
||||
|
||||
expect($parts->diff($foundation)->values()->all())->toBe([])
|
||||
->and(array_map(fn (string $import): string => basename($import), stylesheetImports(stylesheetPath('foundation.css'))))
|
||||
// In the order of their layers, the rules outside every layer beside the reset.
|
||||
->toBe(['reset.css', 'hidden.css', 'tokens.css', 'base.css', 'interaction.css', 'text.css'])
|
||||
// In the order of their layers, the rules outside every layer beside the reset — the seven
|
||||
// token files imported directly (plan step 33: foundation/tokens.css held only these
|
||||
// imports, and only foundation.css reached it, so it was folded in here).
|
||||
->toBe(['reset.css', 'hidden.css', 'scheme.css', 'shape.css', 'elevation.css', 'motion.css', 'type.css', 'state.css', 'spacing.css', 'base.css', 'interaction.css', 'text.css'])
|
||||
->and($foundation)
|
||||
->toContain('tokens/scheme.css', 'tokens/shape.css', 'tokens/elevation.css', 'tokens/motion.css', 'tokens/type.css', 'tokens/state.css', 'tokens/spacing.css', 'tokens/font.css')
|
||||
->and(array_values(preg_grep('/^components\//', $foundation)))->toBe([]);
|
||||
@@ -253,7 +180,7 @@ it('leaves no stylesheet under resources/css/ outside all.css or showcase.css',
|
||||
it('opens every stylesheet all.css reaches with a header, the layer statement and plain imports', function () {
|
||||
foreach (allStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
$items = stylesheetItems(File::get($file));
|
||||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file)));
|
||||
|
||||
expect(File::get($file))->toStartWith('/*', "{$name} has no header comment")
|
||||
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, "{$name} does not open with the layer statement");
|
||||
@@ -275,12 +202,11 @@ it('opens every stylesheet all.css reaches with a header, the layer statement an
|
||||
});
|
||||
|
||||
it('keeps every foundation rule inside its material layer, but the two that hide', function () {
|
||||
// Which layer each file writes into; tokens.css only imports.
|
||||
// Which layer each file writes into.
|
||||
$layers = [
|
||||
'foundation.css' => null,
|
||||
'foundation/reset.css' => 'material.reset',
|
||||
'foundation/hidden.css' => null,
|
||||
'foundation/tokens.css' => null,
|
||||
'foundation/base.css' => 'material.base',
|
||||
'foundation/interaction.css' => 'material.base',
|
||||
'text.css' => 'material.text',
|
||||
@@ -293,15 +219,15 @@ it('keeps every foundation rule inside its material layer, but the two that hide
|
||||
foreach (foundationStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
$layer = array_key_exists($name, $layers) ? $layers[$name] : 'material.tokens';
|
||||
$important += substr_count(stylesheetWithoutComments(File::get($file)), '!important');
|
||||
$important += substr_count(ComponentStylesheet::withoutComments(File::get($file)), '!important');
|
||||
|
||||
foreach (stylesheetItems(File::get($file)) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments(File::get($file))) as $item) {
|
||||
if (! isset($item['prelude'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($item['prelude'], '@layer ')) {
|
||||
$unlayered["{$name}: {$item['prelude']}"] = stylesheetDeclarations($item['body']);
|
||||
$unlayered["{$name}: {$item['prelude']}"] = ComponentStylesheet::flatDeclarations($item['body']);
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -321,7 +247,7 @@ it('keeps every foundation rule inside its material layer, but the two that hide
|
||||
it('defines x-cloak once, reached from both the foundation and all.css', function () {
|
||||
$definitions = collect(File::allFiles(stylesheetPath()))
|
||||
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'css')
|
||||
->filter(fn (SplFileInfo $file): bool => str_contains(stylesheetWithoutComments($file->getContents()), '[x-cloak]'))
|
||||
->filter(fn (SplFileInfo $file): bool => str_contains(ComponentStylesheet::withoutComments($file->getContents()), '[x-cloak]'))
|
||||
->map(fn (SplFileInfo $file): string => stylesheetName((string) $file->getRealPath()))
|
||||
->values()
|
||||
->all();
|
||||
@@ -333,37 +259,21 @@ it('defines x-cloak once, reached from both the foundation and all.css', functio
|
||||
|
||||
it('writes no Tailwind directive anywhere all.css reaches', function () {
|
||||
foreach (allStylesheets() as $file) {
|
||||
expect(stylesheetWithoutComments(File::get($file)))
|
||||
expect(ComponentStylesheet::withoutComments(File::get($file)))
|
||||
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/', stylesheetName($file))
|
||||
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/', stylesheetName($file));
|
||||
}
|
||||
});
|
||||
|
||||
it('writes no Tailwind utility class anywhere under resources/ or tests/', function () {
|
||||
// DesignGuard's own family table (check (i)) is the standing definition of a Tailwind
|
||||
// utility or variant — this test is that guard turned on the package's own source, so the
|
||||
// two never drift apart. tests/Feature/DesignGuardTest.php and tests/Fixtures/design-guard/
|
||||
// carry Tailwind on purpose (they are the guard's own fixtures) and are left out.
|
||||
$root = __DIR__.'/../..';
|
||||
$designGuardTest = (string) realpath($root.'/tests/Feature/DesignGuardTest.php');
|
||||
|
||||
$paths = collect(['resources/views', 'resources/js', 'src', 'tests/Browser', 'tests/Feature'])
|
||||
->flatMap(fn (string $directory): Collection => collect(File::allFiles($root.'/'.$directory)))
|
||||
->filter(fn (SplFileInfo $file): bool => in_array($file->getExtension(), ['php', 'js', 'ts'], true))
|
||||
->reject(fn (SplFileInfo $file): bool => (string) $file->getRealPath() === $designGuardTest)
|
||||
->map(fn (SplFileInfo $file): string => (string) $file->getRealPath())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($paths)->not->toBeEmpty();
|
||||
|
||||
expect(DesignGuard::scan($paths)->violations())->toBe([]);
|
||||
});
|
||||
// No Tailwind-shaped class anywhere the package ships or tests itself is
|
||||
// tests/Feature/DesignGuardTest.php's: one of its tests scans resources/views, resources/js, src
|
||||
// and the Workbench, another scans tests/Browser and tests/Feature, so nothing here repeats
|
||||
// either.
|
||||
|
||||
it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, in px', function () {
|
||||
$queries = collect(allStylesheets())
|
||||
->flatMap(function (string $file): array {
|
||||
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments(File::get($file)), $matches);
|
||||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments(File::get($file)), $matches);
|
||||
|
||||
return array_map(fn (string $query): string => stylesheetName($file).': '.trim($query), $matches[1]);
|
||||
});
|
||||
@@ -376,9 +286,9 @@ it('writes a media query, anywhere all.css reaches, only at M3\'s breakpoints, i
|
||||
$feature = substr($query, strpos($query, ': ') + 2);
|
||||
|
||||
// The one named exception (docs/reference/m3/components-navigation-selection-inputs.md §
|
||||
// Time pickers/Behaviour, and tests/Feature/Components/InputStylesheetsTest.php's
|
||||
// assertBreakpointsInPx()): a viewport *height* in an `orientation` query is not a
|
||||
// breakpoint, since M3 does not make one of it. A width, in any query, still is.
|
||||
// Time pickers/Behaviour, and tests/Feature/Components/ComponentStylesheetsTest.php's
|
||||
// token-values test): a viewport *height* in an `orientation` query is not a breakpoint,
|
||||
// since M3 does not make one of it. A width, in any query, still is.
|
||||
$orientationHeight = preg_match('/\(\s*orientation\s*:/', $feature) === 1
|
||||
&& preg_match('/(?:^|[\s:<>=(])(?:min-|max-)?height\b/', $feature) === 1
|
||||
&& ! str_contains($feature, 'width');
|
||||
@@ -408,7 +318,7 @@ it('declares M3\'s spacing scale as the reference gives it', function () {
|
||||
);
|
||||
|
||||
$reference = collect($rows)->mapWithKeys(fn (array $row): array => ["--md-sys-measurement-space{$row[1]}" => "{$row[3]}px"])->all();
|
||||
$tokens = stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
|
||||
$tokens = ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'));
|
||||
|
||||
expect($reference)->toHaveCount(13)
|
||||
->and($tokens)->toBe($reference);
|
||||
@@ -495,11 +405,11 @@ it('gives every component view a stylesheet, or lists it on a short, documented
|
||||
});
|
||||
|
||||
/**
|
||||
* Every component view the four group stylesheet tests (Action/Input/Containment/Navigation) used
|
||||
* to check one at a time, in one dataset instead: every `resources/views/components/*.blade.php`
|
||||
* backed by a `components/*.css` file of its own, `tabs` and `tab` folded into the one entry
|
||||
* `imports…()` below already special-cases (they share tabs.css, N-16), `theme-script` (no
|
||||
* stylesheet at all) and every layout component (the test above covers those) left out.
|
||||
* Every component view ComponentStylesheetsTest used to check one at a time, in one dataset
|
||||
* instead: every `resources/views/components/*.blade.php` backed by a `components/*.css` file of
|
||||
* its own, `tabs` and `tab` folded into the one entry `imports…()` below already special-cases
|
||||
* (they share tabs.css), `theme-script` (no stylesheet at all) and every layout component (the
|
||||
* test above covers those) left out.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -517,17 +427,34 @@ function componentStylesheetViews(): array
|
||||
|
||||
dataset('component stylesheet views', componentStylesheetViews());
|
||||
|
||||
/**
|
||||
* The `<x-livewire-material::…>` tags a view renders, including any inside a
|
||||
* `@include('livewire-material::partials.<name>', …)` it pulls in — markup a view moved out to
|
||||
* resources/views/partials/<name>.blade.php (menu's filter field, the timepicker's period picker)
|
||||
* still renders the same tags, just not inline where a plain regex over the view alone would see
|
||||
* them.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
function renderedComponentTags(string $view): array
|
||||
{
|
||||
$content = File::get($view);
|
||||
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', $content, $tags);
|
||||
preg_match_all('/@include\(\'livewire-material::partials\.([\w.-]+)\'/', $content, $partials);
|
||||
|
||||
return collect($tags[1])
|
||||
->merge(collect($partials[1])->flatMap(fn (string $partial): array => renderedComponentTags(__DIR__."/../../resources/views/partials/{$partial}.blade.php")))
|
||||
->all();
|
||||
}
|
||||
|
||||
it('imports, from every component stylesheet, the stylesheet of each component its view renders', function (string $name) {
|
||||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php (N-16); every other
|
||||
// tabs.css is shared by two views, tabs.blade.php and tab.blade.php; every other
|
||||
// component stylesheet in this dataset has exactly one view of its own name.
|
||||
$views = $name === 'tabs' ? ['tabs', 'tab'] : [$name];
|
||||
|
||||
$rendered = collect($views)
|
||||
->flatMap(function (string $view): array {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../resources/views/components/{$view}.blade.php"), $tags);
|
||||
|
||||
return $tags[1];
|
||||
})
|
||||
->flatMap(fn (string $view): array => renderedComponentTags(__DIR__."/../../resources/views/components/{$view}.blade.php"))
|
||||
->unique()
|
||||
// A rendered tag that is not a components/ stylesheet at all (a layout component's, e.g.
|
||||
// <x-pane>) has no import of its own to check here.
|
||||
@@ -607,7 +534,7 @@ it('imports, from every layout stylesheet, the stylesheet of each component its
|
||||
it('keeps every layout rule in material.layout, and the visibility props in material.visibility', function () {
|
||||
// layoutStylesheets() follows every @import, so it now reaches component stylesheets a layout
|
||||
// file draws on (pane.css: app-bar.css and button.css, for its own top app bar and back
|
||||
// button) — each already checked, in material.components, by its own group's stylesheet test.
|
||||
// button) — each already checked, in material.components, by ComponentStylesheetsTest.
|
||||
foreach (layoutStylesheets() as $file) {
|
||||
$name = stylesheetName($file);
|
||||
|
||||
@@ -621,9 +548,9 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
|
||||
default => 'material.layout',
|
||||
};
|
||||
|
||||
expect(stylesheetWithoutComments($css))->not->toContain('!important', $name);
|
||||
expect(ComponentStylesheet::withoutComments($css))->not->toContain('!important', $name);
|
||||
|
||||
foreach (stylesheetItems($css) as $item) {
|
||||
foreach (ComponentStylesheet::items(ComponentStylesheet::withoutComments($css)) as $item) {
|
||||
if (! isset($item['prelude'])) {
|
||||
continue;
|
||||
}
|
||||
@@ -637,21 +564,21 @@ it('keeps every layout rule in material.layout, and the visibility props in mate
|
||||
it('gives every spacing token a gap and a padding, and the layout components read only those', function () {
|
||||
$tokens = array_map(
|
||||
fn (string $property): string => substr($property, strlen('--md-sys-measurement-')),
|
||||
array_keys(stylesheetDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
|
||||
array_keys(ComponentStylesheet::flatDeclarations(stylesheetBlock(File::get(stylesheetPath('tokens/spacing.css')), ':root'))),
|
||||
);
|
||||
|
||||
expect(Layout::SPACING)->toBe($tokens);
|
||||
|
||||
$spacing = File::get(stylesheetPath('layout/spacing.css'));
|
||||
|
||||
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='none']")))->toBe(['--md-gap' => '0px']);
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||||
->and(stylesheetDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-gap='{$token}']")))->toBe(['--md-gap' => "var(--md-sys-measurement-{$token})"])
|
||||
->and(ComponentStylesheet::flatDeclarations(stylesheetBlock($spacing, "[data-md-padding='{$token}']")))->toBe(['padding' => "var(--md-sys-measurement-{$token})"]);
|
||||
}
|
||||
|
||||
expect(collect(stylesheetItems(stylesheetBlock($spacing, '@layer material.layout')))->pluck('prelude')->all())
|
||||
expect(collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($spacing, '@layer material.layout'))))->pluck('prelude')->all())
|
||||
->toHaveCount(1 + 2 * count($tokens));
|
||||
});
|
||||
|
||||
@@ -668,10 +595,10 @@ it('hides an element below and from every breakpoint but compact, and nothing el
|
||||
$expected["@media (width >= {$width}px)"] = "[data-md-hide-from='{$breakpoint}']";
|
||||
}
|
||||
|
||||
$actual = collect(stylesheetItems($visibility))->mapWithKeys(function (array $item): array {
|
||||
$rule = stylesheetItems($item['body'])[0];
|
||||
$actual = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments($visibility)))->mapWithKeys(function (array $item): array {
|
||||
$rule = ComponentStylesheet::items(ComponentStylesheet::withoutComments($item['body']))[0];
|
||||
|
||||
expect(stylesheetDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||
expect(ComponentStylesheet::flatDeclarations($rule['body']))->toBe(['display' => 'none']);
|
||||
|
||||
return [$item['prelude'] => $rule['prelude']];
|
||||
})->all();
|
||||
@@ -686,7 +613,7 @@ it('gives every md-type class exactly font, letter-spacing and font-variation-se
|
||||
// asserted directly rather than cross-checked against a second copy.
|
||||
preg_match_all('/\.md-(type-[\w-]+) \{\n(.*?)\n {4}\}/s', File::get(stylesheetPath('text.css')), $matches, PREG_SET_ORDER);
|
||||
|
||||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => stylesheetDeclarations($match[2])]);
|
||||
$classes = collect($matches)->mapWithKeys(fn (array $match): array => ["md-{$match[1]}" => ComponentStylesheet::flatDeclarations($match[2])]);
|
||||
|
||||
expect($classes)->toHaveCount(30);
|
||||
|
||||
@@ -730,18 +657,18 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
|
||||
],
|
||||
];
|
||||
|
||||
$classes = collect(stylesheetItems(stylesheetBlock($text, '@layer material.text')))
|
||||
$classes = collect(ComponentStylesheet::items(ComponentStylesheet::withoutComments(stylesheetBlock($text, '@layer material.text'))))
|
||||
->map(fn (array $item): string => $item['prelude'] ?? $item['statement'])
|
||||
->all();
|
||||
|
||||
expect($classes)->toBe($styles->merge(array_keys($inks))->merge(array_keys($rest))->map(fn (string $class): string => ".{$class}")->all());
|
||||
|
||||
foreach ($inks as $class => $role) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe(['color' => "var(--md-sys-color-{$role})"]);
|
||||
}
|
||||
|
||||
foreach ($rest as $class => $declarations) {
|
||||
expect(stylesheetDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||||
expect(ComponentStylesheet::flatDeclarations(stylesheetBlock($text, ".{$class}")))->toBe($declarations);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -754,7 +681,7 @@ it('ships exactly the documented text classes, each ink an M3 role', function ()
|
||||
// file keeps to.
|
||||
|
||||
it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left to sit between', function () {
|
||||
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||||
$app = ComponentStylesheet::withoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
|
||||
|
||||
// Opens by ordering the layers, so the order holds whichever the page links first — an
|
||||
// application with Tailwind of its own still opens its own entry the same way (its header).
|
||||
@@ -775,11 +702,11 @@ it('builds the package in the Workbench\'s one CSS entry, with no Tailwind left
|
||||
it('shapes showcase.css like a package stylesheet, with its documented unlayered exceptions', function () {
|
||||
$path = __DIR__.'/../../resources/css/showcase.css';
|
||||
$css = File::get($path);
|
||||
$items = stylesheetItems($css);
|
||||
$items = ComponentStylesheet::items(ComponentStylesheet::withoutComments($css));
|
||||
|
||||
expect($css)->toStartWith('/*', 'showcase.css has no header comment')
|
||||
->and($items[0]['statement'] ?? null)->toBe(MATERIAL_LAYER_STATEMENT, 'showcase.css does not open with the layer statement')
|
||||
->and(stylesheetWithoutComments($css))
|
||||
->and(ComponentStylesheet::withoutComments($css))
|
||||
->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b/')
|
||||
->not->toMatch('/--(?:theme|spacing|alpha)\(|\btheme\(|[\'"]tailwindcss[\'"]/');
|
||||
|
||||
@@ -814,7 +741,7 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
|
||||
// Bundled with all.css on every showcase page, so it styles its own hooks and classes only,
|
||||
// and each of those is still drawn by some showcase view.
|
||||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', stylesheetWithoutComments($css)), $preludes);
|
||||
preg_match_all('/([^{};]+)\{/', preg_replace('/@(?:layer|media)\b[^{]*\{/', '', ComponentStylesheet::withoutComments($css)), $preludes);
|
||||
$views = collect(File::allFiles(__DIR__.'/../../resources/views/showcase'))->map(fn ($file): string => $file->getContents())->implode("\n");
|
||||
|
||||
foreach ($preludes[1] as $prelude) {
|
||||
@@ -823,13 +750,13 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
}
|
||||
}
|
||||
|
||||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', stylesheetWithoutComments($css), $hooks);
|
||||
preg_match_all('/data-md-showcase(?:-\w+)*|\.showcase(?:-\w+)+/', ComponentStylesheet::withoutComments($css), $hooks);
|
||||
|
||||
foreach (array_unique($hooks[0]) as $hook) {
|
||||
expect(str_contains($views, ltrim($hook, '.')))->toBeTrue("showcase.css draws `{$hook}`, which no showcase view renders");
|
||||
}
|
||||
|
||||
preg_match_all('/@media\b([^{]*)\{/', stylesheetWithoutComments($css), $matches);
|
||||
preg_match_all('/@media\b([^{]*)\{/', ComponentStylesheet::withoutComments($css), $matches);
|
||||
|
||||
foreach ($matches[1] as $query) {
|
||||
$feature = trim($query);
|
||||
@@ -845,17 +772,17 @@ it('shapes showcase.css like a package stylesheet, with its documented unlayered
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps all.css under a gzip size budget, measured through Stylesheets::bundle()', function () {
|
||||
// 106,838 bytes gzipped today (2026-09-15), rounded up by about 10% to a number CI can hold
|
||||
// to without Node: PHP's own gzencode() on the same bytes Stylesheets::bundle() serves the
|
||||
// showcase and the error page's fallback. A little organic growth should not retrigger this
|
||||
// on every commit; a real jump means look at what grew since the budget was last raised.
|
||||
$budget = 120_000;
|
||||
it('keeps the showcase bundle, all.css with showcase.css, under a gzip size budget', function () {
|
||||
// 40,167 bytes gzipped today (2026-09-17), measured with PHP's own gzencode() on
|
||||
// resources/dist/showcase.css, the prebuilt bundle the showcase serves, comments stripped. A
|
||||
// little organic growth should not retrigger this on every commit; a real jump means look at
|
||||
// what grew since the budget was last raised.
|
||||
$budget = 44_000;
|
||||
|
||||
$gzipped = strlen((string) gzencode(Stylesheets::bundle([stylesheetPath('all.css')]), 9));
|
||||
$gzipped = strlen((string) gzencode(File::get(__DIR__.'/../../resources/dist/showcase.css'), 9));
|
||||
|
||||
expect($gzipped)->toBeLessThanOrEqual($budget, sprintf(
|
||||
'all.css now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||||
'The showcase bundle now gzips to %s bytes, over the %s-byte budget — see what grew, and raise the budget deliberately if it should have.',
|
||||
number_format($gzipped),
|
||||
number_format($budget),
|
||||
));
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
function packageCss(string $path = ''): string
|
||||
{
|
||||
return __DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path);
|
||||
return ComponentStylesheet::cssPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user