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
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>
741 lines
36 KiB
PHP
741 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). 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();
|
||
});
|