Files
livewire-material/tests/Browser/NavigationTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00

732 lines
36 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
const RAIL = "document.querySelector('[data-md-navigation-rail]')";
const RAIL_PANEL = "document.querySelector('[data-md-navigation-rail-panel]')";
const BOTTOM_BAR = "document.querySelector('[data-md-scaffold-bar]')";
function navigationReady(mixed $page): mixed
{
return ready($page);
}
function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
{
// The window is resized after the page starts loading, and an adaptive rail hears of the new
// width from a media query's change event, which can arrive after the first key press. Both
// of its queries are M3 window size classes: `expanded` (840px) says the rail is standard
// rather than modal, `large` (1200px) that it starts expanded rather than collapsed.
return navigationReady(visit($path)->resize($width, $height))
->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-md-navigation-rail]'); if (! rail || rail.dataset.mdNavigationRail !== 'adaptive') return true; const state = Alpine.\$data(rail); return state.wide === window.matchMedia('(width >= 840px)').matches && state.roomy === window.matchMedia('(width >= 1200px)').matches; })()\")");
}
/**
* A shell whose page records, while the document is still being parsed — after the rail's
* markup and the stylesheet, before Alpine exists — what <html> and the rail looked like.
* That is what the first paint shows.
*/
function firstPaintProbe(): void
{
Route::middleware('web')->get('/rail-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<x-scaffold :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'],
]">
<script>
window.firstPaint = {
rail: document.documentElement.getAttribute('data-rail'),
width: getComputedStyle(document.querySelector('[data-md-navigation-rail]')).width,
alpine: typeof window.Alpine,
};
</script>
<p>The page</p>
</x-scaffold>
@livewireScripts
</body>
</html>
BLADE));
}
function railWidth(int $pixels): string
{
return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}";
}
it('shows the navigation bar on a compact window and marks the current destination', function () {
shellPage(599, 860)
->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript(BOTTOM_BAR.".querySelectorAll('[data-md-navigation-bar-item]').length === 4")
->assertScript(BOTTOM_BAR.".querySelector('[aria-current=\"page\"]').textContent.includes('Inbox')")
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[aria-current=\"page\"] [data-md-navigation-indicator]')).backgroundImage !== 'none'")
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[data-md-navigation-bar-item]:not([aria-current]) [data-md-navigation-indicator]')).backgroundImage === 'none'")
->assertNoJavaScriptErrors();
});
it('collapses the rail across the medium class, icon over label, from its first pixel', function () {
shellPage(600)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertNoJavaScriptErrors();
shellPage(839)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-item]')).flexDirection === 'column'")
->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display === 'none'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false');
});
it('starts the rail collapsed across the expanded class and expands it in the layout', function () {
// 840-1199 is M3's expanded class: a standard rail, collapsed until someone says otherwise,
// whose menu button widens it in place — no scrim, no focus trap, the page beside it live.
shellPage(1199)
->assertScript(railWidth(96))
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
->assertScript("localStorage.getItem('material-rail') === null")
->assertNoJavaScriptErrors();
$page = shellPage(840)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false');
$page->click('[data-md-navigation-rail-menu]')
->assertScript(railWidth(256))
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-scrim]')).display === 'none'")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.documentElement.getAttribute('data-rail') === 'expanded'")
->assertScript("! document.documentElement.hasAttribute('data-rail-auto')")
->assertScript("localStorage.getItem('material-rail') === 'expanded'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true')
->assertNoJavaScriptErrors();
// The choice outlives the reload, and holds to the top of the class.
navigationReady($page->refresh())->assertScript(railWidth(256));
});
const TOGGLE_RAIL = "window.eval(\"Alpine.store('rail').toggle()\")";
it('flips the standing rail from $store.rail.toggle() as drawn, whatever is stored', function () {
// The expanded class with nothing stored: `data-rail` says `expanded` (`rail.default`), and
// the rail is drawn collapsed all the same — so an application's own shortcut expands it on
// the first press, as the rail's menu button does, rather than collapsing a collapsed rail.
$page = shellPage(960)
->assertScript(railWidth(96))
->assertScript("document.documentElement.getAttribute('data-rail') === 'expanded'")
->assertScript("document.documentElement.hasAttribute('data-rail-auto')");
$page->script(TOGGLE_RAIL);
$page->assertScript(railWidth(256))
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("localStorage.getItem('material-rail') === 'expanded'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true');
$page->script(TOGGLE_RAIL);
$page->assertScript(railWidth(96))
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
->assertNoJavaScriptErrors();
// Large: expanded to begin with, so the first press collapses it.
$page = shellPage(1512)->assertScript(railWidth(256));
$page->script(TOGGLE_RAIL);
$page->assertScript(railWidth(96))
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
->assertNoJavaScriptErrors();
});
it('opens and closes the modal rail from $store.rail.toggle() below expanded, storing nothing', function () {
// Medium: collapsed in the layout, and expanding it means the scrim, as its menu button does.
$page = shellPage(768)->assertScript(railWidth(96));
$page->script(TOGGLE_RAIL);
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
->assertScript(railWidth(96));
$page->script(TOGGLE_RAIL);
$page->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("localStorage.getItem('material-rail') === null")
->assertNoJavaScriptErrors();
// Compact: nothing is drawn until the modal rail slides in, as `$store.rail.show()` brings it.
$page = shellPage(393, 852)->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'");
$page->script(TOGGLE_RAIL);
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'");
$page->script(TOGGLE_RAIL);
$page->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript("localStorage.getItem('material-rail') === null")
->assertNoJavaScriptErrors();
});
it('morphs the rail header FAB between collapsed (icon only) and extended (with its label)', function () {
$fab = "document.querySelector('[data-md-navigation-rail-fab-row] [data-md-fab]')";
$label = "document.querySelector('[data-md-navigation-rail-fab-row] [data-md-fab] > span')";
$page = shellPage(840)
// Collapsed: the label is clipped to nothing, and the FAB is a plain square icon button —
// navigation-rail.css's [data-md-fab] > span and [data-md-fab][data-md-extended] rules.
->assertScript("getComputedStyle({$label}).opacity === '0'")
->assertScript("getComputedStyle({$label}).maxWidth === '0px'")
->assertScript("Math.round({$fab}.getBoundingClientRect().width) === Math.round({$fab}.getBoundingClientRect().height)");
$page->click('[data-md-navigation-rail-menu]')
// The label's width and the FAB's own shape both spring on the rail's spatial-default
// transition; wait past it for the settled, extended end state.
->wait(0.6)
->assertScript("getComputedStyle({$label}).opacity === '1'")
->assertScript("parseFloat(getComputedStyle({$label}).maxWidth) > 0")
->assertScript("{$fab}.getBoundingClientRect().width > {$fab}.getBoundingClientRect().height")
->assertSee('Compose')
->assertNoJavaScriptErrors();
});
it('expands the rail from large, and keeps it collapsed across a reload from the first paint', function () {
firstPaintProbe();
// 1200 is the first pixel of M3's large class, where the rail starts expanded. The first-paint
// probe runs before the resize, so the harness's own window must be in the same class — it is:
// Playwright opens 1280 wide.
$page = shellPage(1200, 900, '/rail-probe')
->assertScript(railWidth(256))
->assertScript("window.eval('window.firstPaint.width') === '256px'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true');
$page->click('[data-md-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false')
->assertScript(railWidth(96));
navigationReady($page->refresh())
->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'")
->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'")
->assertScript("window.eval('window.firstPaint.width') === '96px'")
->assertScript(railWidth(96));
// Extra-large is the same band, and the collapse just chosen still holds there. The same page
// widened: every visit() opens a fresh browser context, whose storage holds no choice.
$page->resize(1600, 900)
->assertScript("window.matchMedia('(width >= 1600px)').matches")
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript(railWidth(96));
});
it('expands the rail by default at extra-large', function () {
shellPage(1600, 900)
->assertScript(railWidth(256))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true')
->assertNoJavaScriptErrors();
});
it('opens the modal rail from its menu button below expanded, holding focus until Escape or the scrim', function () {
$page = shellPage(839);
// From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it.
$page->script("document.querySelector('[data-md-navigation-rail-menu]').focus()");
$page->keys('[data-md-navigation-rail-menu]', 'Enter')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
->assertScript(railWidth(96))
->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display !== 'none'")
->assertScript(RAIL_PANEL.'.contains(document.activeElement)')
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') !== null")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true');
$page->keys(':focus', 'Escape')
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.activeElement === document.querySelector('[data-md-navigation-rail-menu]')");
$page->keys('[data-md-navigation-rail-menu]', 'Enter')
->assertScript(RAIL.".hasAttribute('data-md-open')");
$page->click('[data-md-navigation-rail-scrim]')
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("localStorage.getItem('material-rail') === null");
});
it('slides the modal rail in on a compact window from the app bar\'s menu button', function () {
$page = shellPage(599, 860);
$page->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0')
->assertScript(RAIL_PANEL.'.contains(document.activeElement)');
$page->keys(':focus', 'Escape')
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'");
});
it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
$page = shellPage(1200);
$page->click('[data-md-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'");
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
$page->click('[data-md-navigation-rail-panel] a[href$="/material/shell/starred"]')
->assertSeeIn('@shell-page', 'This is the starred page.')
->assertScript("window.eval('window.samePage') === true")
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
->assertScript(railWidth(96))
->assertScript("document.querySelector('[data-md-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/shell/starred')")
->assertScript("document.querySelectorAll('[data-md-navigation-rail-panel] [aria-current=\"page\"]').length === 1")
->assertNoJavaScriptErrors();
});
it('skips to the content', function () {
$page = shellPage();
$page->assertScript("document.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])').matches('[data-md-skip-link]')");
// Safari and WebKit leave links out of the Tab order, so focus it directly.
$page->script("document.querySelector('[data-md-skip-link]').focus()");
$page->assertScript("getComputedStyle(document.querySelector('[data-md-skip-link]')).position === 'fixed'")
->assertVisible('[data-md-skip-link]');
$page->keys('[data-md-skip-link]', 'Enter')
->assertScript("location.hash === '#content'")
->assertScript("document.activeElement === document.getElementById('content')");
});
it('moves an app bar\'s content under a safe area an application sets', function () {
$row = "Math.round(document.querySelector('[data-md-app-bar] [data-md-app-bar-row]').getBoundingClientRect().top)";
$page = navigationReady(visit('/material'));
$top = (int) $page->script($row);
$page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')");
$page->assertScript("{$row} === ".($top + 47))
->assertNoJavaScriptErrors();
});
it('lifts the snackbar and the page above something docked on the phone\'s bar', function () {
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
$page = shellPage(599, 860);
$snackbar = (int) $page->script($snackbarBottom);
$content = (int) $page->script($contentPadding);
expect($content)->toBe(64)->and($snackbar)->toBe(80);
$page->script("document.documentElement.style.setProperty('--material-bottom-extra', '40px')");
$page->assertScript("{$snackbarBottom} === 120")
->assertScript("{$contentPadding} === 104");
});
/**
* A page for the navigation bar and rail props the shell's own adaptive rail never exercises:
* `tall`, `hide-on-scroll` (with a pinned snackbar), a narrow centred rail, and a rail that hides
* when collapsed. A wide, fixed viewport throughout, so none of M3's breakpoints interfere with
* what each example's own props already decide.
*/
function navigationExtrasProbe(): mixed
{
Route::middleware('web')->get('/navigation-extras-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<div id="tall-bar">
<x-navigation-bar tall>
<x-navigation-bar-item label="Inbox" icon="inbox" link="#a" no-wire-navigate active />
<x-navigation-bar-item label="Sent" icon="send" link="#b" no-wire-navigate />
</x-navigation-bar>
</div>
<div id="hide-bar">
<x-navigation-bar hide-on-scroll>
<x-navigation-bar-item label="Inbox" icon="inbox" link="#a" no-wire-navigate active />
<x-navigation-bar-item label="Sent" icon="send" link="#b" no-wire-navigate />
</x-navigation-bar>
</div>
<div id="narrow-rail" style="display: flex">
<x-navigation-rail mode="collapsed" width="narrow" align="center">
<x-navigation-rail-item label="Inbox" icon="inbox" link="#a" no-wire-navigate active />
<x-navigation-rail-item label="Starred" icon="star" link="#b" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#c" no-wire-navigate />
</x-navigation-rail>
</div>
<div id="hiding-rail" style="display: flex">
<x-navigation-rail hide-when-collapsed>
<x-navigation-rail-item label="Canvas" icon="brush" link="#a" no-wire-navigate active />
<x-navigation-rail-item label="Layers" icon="layers" link="#b" no-wire-navigate />
</x-navigation-rail>
<button type="button" id="open-hiding-rail" x-data x-on:click="$store.rail.show()">Open navigation</button>
</div>
<div id="adaptive-hiding-rail" style="display: flex">
<x-navigation-rail mode="adaptive" hide-when-collapsed>
<x-navigation-rail-item label="Canvas" icon="brush" link="#a" no-wire-navigate active />
<x-navigation-rail-item label="Layers" icon="layers" link="#b" no-wire-navigate />
</x-navigation-rail>
</div>
<div style="height: 250vh"></div>
<x-toast />
@livewireScripts
</body>
</html>
BLADE));
return navigationReady(visit('/navigation-extras-probe')->resize(1000, 900));
}
it('draws the tall navigation bar, icon over label at every width', function () {
$page = navigationExtrasProbe();
$page->assertScript("Math.round(document.querySelector('#tall-bar [data-md-navigation-bar-items]').getBoundingClientRect().height) === 80")
->assertScript("getComputedStyle(document.querySelector('#tall-bar [data-md-navigation-pill]')).flexDirection === 'column'")
->assertNoJavaScriptErrors();
});
it('hides the bar on a scroll down and never while a pinned snackbar is on screen', function () {
$page = navigationExtrasProbe();
$hidden = "document.querySelector('#hide-bar [data-md-navigation-bar]').hasAttribute('data-md-hidden')";
$page->script('window.scrollTo(0, 400)');
$page->assertScript("{$hidden} === true")
->assertNoJavaScriptErrors();
// Back to the top brings it back, as a fresh scroll-down would; pin a snackbar first.
$page->script('window.scrollTo(0, 0)');
$page->assertScript("{$hidden} === false");
$page->script("materialToast('Pinned', { sticky: true })");
$page->assertScript("document.querySelector('[data-md-toast-snackbar]')?.textContent.includes('Pinned')");
$page->script('window.scrollTo(0, 400)');
$page->assertScript("{$hidden} === false")
->assertNoJavaScriptErrors();
});
it('draws the narrow collapsed width and centres the destinations', function () {
$page = navigationExtrasProbe();
$page->assertScript("Math.round(document.querySelector('#narrow-rail [data-md-navigation-rail]').getBoundingClientRect().width) === 80")
->assertScript("getComputedStyle(document.querySelector('#narrow-rail [data-md-navigation-rail-destinations]')).justifyContent === 'safe center'")
->assertNoJavaScriptErrors();
});
it('takes a rail out of the layout entirely when it hides collapsed, and back over the page', function () {
$page = navigationExtrasProbe();
$rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$railWidth = "Math.round({$rail}.getBoundingClientRect().width)";
$page->assertScript("{$railWidth} > 200")
->click('#hiding-rail [data-md-navigation-rail-menu]')
->assertScript("{$railWidth} === 0")
->assertScript("! {$rail}.hasAttribute('data-md-open')");
// The only way back: an application's own menu button calling $store.rail.show().
$page->click('#open-hiding-rail')
->assertScript("{$rail}.hasAttribute('data-md-open')")
->assertScript("getComputedStyle(document.querySelector('#hiding-rail [data-md-navigation-rail-panel]')).display === 'flex'")
->assertNoJavaScriptErrors();
});
it('docks a rail that hid itself back into the layout from $store.rail.toggle(), not over the page', function () {
$page = navigationExtrasProbe();
$rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$railWidth = "Math.round({$rail}.getBoundingClientRect().width)";
$page->click('#hiding-rail [data-md-navigation-rail-menu]')
->assertScript("{$railWidth} === 0");
// The first interactive rail on the page is this one: the narrow rail above it is fixed.
$page->script(TOGGLE_RAIL);
$page->assertScript("{$railWidth} === 256")
->assertScript("! {$rail}.hasAttribute('data-md-open')")
->assertScript("localStorage.getItem('material-rail') === 'expanded'");
$page->script(TOGGLE_RAIL);
$page->assertScript("{$railWidth} === 0")
->assertScript("! {$rail}.hasAttribute('data-md-open')")
->assertNoJavaScriptErrors();
});
it('takes an adaptive rail out of the layout across the expanded class when it hides collapsed, standing again from large', function () {
$page = navigationExtrasProbe();
$rail = "document.querySelector('#adaptive-hiding-rail [data-md-navigation-rail]')";
$railWidth = "Math.round({$rail}.getBoundingClientRect().width)";
// `medium` (600-839): the window collapses the rail, not the visitor, so M3's "collapsed rail
// may never hide" holds even with hide-when-collapsed set — it stays at its collapsed width.
$page->resize(839, 900)
->assertScript("{$railWidth} === 96")
->assertScript("! {$rail}.hasAttribute('data-md-open')")
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
->assertNoJavaScriptErrors();
// `expanded` (840-1199): nothing chosen yet, so the rail is away rather than shown collapsed.
$page->resize(840, 900)
->assertScript("{$railWidth} === 0")
->assertScript("! {$rail}.hasAttribute('data-md-open')")
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
->assertNoJavaScriptErrors();
$page->resize(1199, 900)
->assertScript("{$railWidth} === 0")
->assertScript("document.documentElement.hasAttribute('data-rail-auto')");
// `large` (1200+): M3 starts an adaptive rail expanded by default, so it stands in the layout
// again rather than staying away.
$page->resize(1200, 900)
->assertScript("{$railWidth} === 256")
->assertScript("! {$rail}.hasAttribute('data-md-open')")
->assertScript("document.documentElement.hasAttribute('data-rail-auto')")
->assertNoJavaScriptErrors();
});
/**
* One rail in the given mode, whose footer holds two lines an application styles by the rail's
* value with style queries — the way `--md-navigation-rail-value` is meant to be read. An inline
* script after the rail records the value and the lines' display while the document is still being
* parsed, before Alpine exists: what the first paint shows.
*/
function railValueProbe(string $mode, int $width = 1280): mixed
{
Route::middleware('web')->get('/rail-value-probe/{mode}', fn (string $mode) => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>
@container style(--md-navigation-rail-value: collapsed) {
[data-test='expanded-only'] { display: none; }
}
@container style(--md-navigation-rail-value: expanded) {
[data-test='collapsed-only'] { display: none; }
}
</style>
</head>
<body>
<div style="display: flex; min-height: 100dvh">
<x-navigation-rail :mode="$mode" label="Probe">
<x-navigation-rail-item label="Inbox" icon="inbox" link="#a" no-wire-navigate active />
<x-navigation-rail-item label="Sent" icon="send" link="#b" no-wire-navigate />
<x-slot:footer>
<p data-test="expanded-only">Signed in as Ada</p>
<p data-test="collapsed-only">A</p>
</x-slot:footer>
</x-navigation-rail>
<script>
window.firstPaint = {
value: getComputedStyle(document.querySelector('[data-md-navigation-rail]')).getPropertyValue('--md-navigation-rail-value').trim(),
expandedOnly: getComputedStyle(document.querySelector('[data-test="expanded-only"]')).display,
collapsedOnly: getComputedStyle(document.querySelector('[data-test="collapsed-only"]')).display,
alpine: typeof window.Alpine,
};
</script>
<main>
<button type="button" id="open-rail" x-data x-on:click="$store.rail.show()">Open navigation</button>
<p data-test="outside">Outside the rail</p>
</main>
</div>
@livewireScripts
</body>
</html>
BLADE, ['mode' => $mode]));
return navigationReady(visit("/rail-value-probe/{$mode}")->resize($width, 900));
}
const RAIL_VALUE = 'getComputedStyle('.RAIL.").getPropertyValue('--md-navigation-rail-value').trim()";
function railShows(string $line): string
{
return "getComputedStyle(document.querySelector('[data-test=\"{$line}\"]')).display !== 'none'";
}
it('publishes a fixed rail\'s value from the first paint, for style queries in its content', function () {
railValueProbe('collapsed')
->assertScript("window.firstPaint.alpine === 'undefined'")
->assertScript("window.firstPaint.value === 'collapsed'")
->assertScript("window.firstPaint.expandedOnly === 'none' && window.firstPaint.collapsedOnly !== 'none'")
->assertScript(RAIL_VALUE." === 'collapsed'")
->assertScript('! '.railShows('expanded-only'))
->assertScript(railShows('collapsed-only'))
// Outside a rail the property is unset, so neither value's query matches.
->assertScript("getComputedStyle(document.querySelector('[data-test=\"outside\"]')).getPropertyValue('--md-navigation-rail-value') === ''")
->assertNoJavaScriptErrors();
railValueProbe('expanded')
->assertScript("window.firstPaint.value === 'expanded'")
->assertScript("window.firstPaint.expandedOnly !== 'none' && window.firstPaint.collapsedOnly === 'none'")
->assertScript(RAIL_VALUE." === 'expanded'")
->assertScript(railShows('expanded-only'))
->assertScript('! '.railShows('collapsed-only'))
->assertNoJavaScriptErrors();
});
it('moves an adaptive rail\'s value with the window and the menu button, in step with its width', function () {
$page = railValueProbe('adaptive', 839)
->assertScript(railWidth(96))
->assertScript(RAIL_VALUE." === 'collapsed'")
->assertScript('! '.railShows('expanded-only'));
// `large`: M3 starts an adaptive rail expanded while nothing is chosen.
$page->resize(1200, 900)
->assertScript(railWidth(256))
->assertScript(RAIL_VALUE." === 'expanded'")
->assertScript(railShows('expanded-only'))
->assertScript('! '.railShows('collapsed-only'));
// `expanded`: collapsed until the menu button widens it in place.
$page->resize(840, 900)
->assertScript(RAIL_VALUE." === 'collapsed'")
->click('[data-md-navigation-rail-menu]')
->assertScript(railWidth(256))
->assertScript(RAIL_VALUE." === 'expanded'")
->assertScript(railShows('expanded-only'))
->assertNoJavaScriptErrors();
});
it('reads expanded while a modal rail is open over the page, and collapsed once it closes', function () {
$page = railValueProbe('modal', 1000)
->assertScript(RAIL_VALUE." === 'collapsed'")
->assertScript('! '.railShows('expanded-only'));
$page->click('#open-rail')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(RAIL_VALUE." === 'expanded'")
->assertScript(railShows('expanded-only'))
->assertScript('! '.railShows('collapsed-only'));
$page->keys('[data-md-navigation-rail-panel]', 'Escape')
->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript(RAIL_VALUE." === 'collapsed'")
->assertScript('! '.railShows('expanded-only'))
->assertNoJavaScriptErrors();
});
/**
* Closes a rail with the given script and reports whether, sampling every few ms in the page
* itself, it was ever caught part-way: the whole close in one round trip, since a separate
* `script` and `assertScript` apiece already take as long as the 200ms exit (see ContainmentTest's
* scrim fades).
*/
function caughtMidClose(mixed $page, string $close, string $partWay): bool
{
return $page->script(<<<JS
(async () => {
{$close}
for (let i = 0; i < 60; i++) {
if ({$partWay}) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS) === true;
}
/**
* Opened and done opening: no transition left on the element. Firefox creates a transition on its
* next refresh tick, so straight after the change its computed style already reads the end value
* while the entry has not begun, and a close then would start from where the entry did.
*/
function settled(string $element): string
{
return "{$element}.getAnimations().length === 0";
}
/** Still drawn, and strictly between resting at the window's edge and gone past it. */
function slidingOut(string $panel): string
{
return "(getComputedStyle({$panel}).display !== 'none' && {$panel}.getBoundingClientRect().left < -2 && {$panel}.getBoundingClientRect().right > 2)";
}
it('slides the compact rail out on close, rather than making it vanish', function () {
$page = shellPage(599, 860)
->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled(RAIL_PANEL).' && Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0');
expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue();
$page->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript('! '.RAIL.".hasAttribute('data-md-closing')")
->assertNoJavaScriptErrors();
});
it('slides a rail that hides when collapsed out on close, rather than making it vanish', function () {
$page = navigationExtrasProbe();
$rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$panel = "{$rail}.querySelector(':scope > [data-md-navigation-rail-panel]')";
$page->click('#hiding-rail [data-md-navigation-rail-menu]')
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0")
->click('#open-hiding-rail')
->assertScript("{$rail}.hasAttribute('data-md-open')")
->assertScript(settled($panel)." && Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256");
expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue();
$page->assertScript("getComputedStyle({$panel}).display === 'none'")
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0")
->assertNoJavaScriptErrors();
});
it('fades a modal rail\'s scrim out on close while the rail stands collapsed in the layout', function () {
$scrim = RAIL.".querySelector(':scope > [data-md-navigation-rail-scrim]')";
$page = railValueProbe('modal', 1000)
->click('#open-rail')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled($scrim)." && getComputedStyle({$scrim}).opacity === '1'");
$fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)";
expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue();
// The panel goes straight back into the layout: only the scrim was over the page.
$page->assertScript("getComputedStyle({$scrim}).display === 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").position === 'sticky'")
->assertScript(railWidth(96))
->assertNoJavaScriptErrors();
});