waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
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-navigation-rail]'); if (! rail || rail.dataset.navigationRail !== '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 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'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
The page
@livewireScripts
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-navigation-bar-item]').length === 4")
->assertScript(BOTTOM_BAR.".querySelector('[aria-current=\"page\"]').textContent.includes('Inbox')")
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[aria-current=\"page\"] [data-navigation-indicator]')).backgroundImage !== 'none'")
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[data-navigation-bar-item]:not([aria-current]) [data-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-navigation-rail-item]')).flexDirection === 'column'")
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display === 'none'")
->assertAttribute('[data-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-navigation-rail-menu]', 'aria-expanded', 'false');
$page->click('[data-navigation-rail-menu]')
->assertScript(railWidth(256))
->assertScript('! '.RAIL.".hasAttribute('data-open')")
->assertScript("getComputedStyle(document.querySelector('[data-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-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));
});
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-navigation-rail-menu]', 'aria-expanded', 'true');
$page->click('[data-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
->assertAttribute('[data-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-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-navigation-rail-menu]').focus()");
$page->keys('[data-navigation-rail-menu]', 'Enter')
->assertScript(RAIL.".hasAttribute('data-open')")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
->assertScript(railWidth(96))
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display !== 'none'")
->assertScript(RAIL_PANEL.'.contains(document.activeElement)')
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') !== null")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
$page->keys(':focus', 'Escape')
->assertScript('! '.RAIL.".hasAttribute('data-open')")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.activeElement === document.querySelector('[data-navigation-rail-menu]')");
$page->keys('[data-navigation-rail-menu]', 'Enter')
->assertScript(RAIL.".hasAttribute('data-open')");
$page->click('[data-navigation-rail-scrim]')
->assertScript('! '.RAIL.".hasAttribute('data-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-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-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-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-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-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/shell/starred')")
->assertScript("document.querySelectorAll('[data-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-skip-link]')");
// Safari and WebKit leave links out of the Tab order, so focus it directly.
$page->script("document.querySelector('[data-skip-link]').focus()");
$page->assertScript("getComputedStyle(document.querySelector('[data-skip-link]')).position === 'fixed'")
->assertVisible('[data-skip-link]');
$page->keys('[data-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-app-bar] [data-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");
});