Files
livewire-material/tests/Browser/NavigationTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 94d6e31432
tests / feature (8.4) (push) Successful in 1m45s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Successful in 8m6s
tests / browser (firefox, firefox) (push) Failing after 12m52s
tests / browser (safari, webkit) (push) Failing after 13m1s
Catch an exit half-way on a slow machine, not only on a fast one
The browser suite passes on every engine here and fails on the runner,
which takes three times as long: each failing test triggers a close and
then samples for an in-between state — a rail part-way out, a scrim
part-way faded, a menu's exit copy part-way sunk — and a starved runner
takes its one sample after the 150-650ms exit has already finished.

So the tests that assert *that* something animates now stretch every
motion duration token to three seconds first (`slowMotion()`, beside
`ready()` in tests/Pest.php; ActionsTest's own copy of it goes). The two
polling helpers grew their budget to match: the rail panel and the sheet
slide on emphasized-accelerate, which is under 1% of its travel at a
quarter of the way through, so a 300ms window no longer reached the
threshold once the exit itself was three seconds long.

The two full-screen date picker tests waited for a resize through
click()'s own retry, which ate the whole 15s budget on Firefox; they now
wait for the new width and a settled document first. The bottom sheet's
preset test waits for its entry to finish before pressing the grip.

`hold()` in search.js reads the view's animations a frame after the
closed state, as the rail's settle() does, but one frame is not always
enough: an engine that starts them on its next tick shows none, and the
full-screen layout would end at once, mid-exit. An empty list is now
asked again on the following frame.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit,
and again on WebKit under ten spinning cores.

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

741 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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). Called after slowMotion(), so the panel's own close — on the emphasized-accelerate
* easing, which stays close to its start for a good part of its run — still needs a couple of
* seconds of that stretched exit before its position has moved the couple of pixels `$partWay`
* looks for; 2000ms (400 × 5ms) covers that with room to spare, short of the full exit ending.
*/
function caughtMidClose(mixed $page, string $close, string $partWay): bool
{
return $page->script(<<<JS
(async () => {
{$close}
for (let i = 0; i < 400; 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');
slowMotion($page);
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");
slowMotion($page);
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)";
slowMotion($page);
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();
});