tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
The browser plugin runs every call on a page again when its first attempt takes over a second, and on the runner a press can. For most presses that costs nothing. For two kinds it breaks the test: a press that opens a dialog, a sheet, a full-screen view or a modal rail over its own trigger, whose second press can never land, and a press that changes state a second one would change again — a menu trigger, a toggle, a chip, a range picker's day, a paging key, a Save. The bottom sheet with preset heights timed out on WebKit that way after the date picker had on Firefox. Every such press in the browser suite now goes through pressOnce(), 253 of them across sixteen files, not only the ones the runner happened to catch; focus moves inside an open view, Escape, links and plain "set" actions keep the retry, which is harmless for them. Two samples that were still racing the machine: - The standard side sheet's exit is caught half-way with its motion stretched, as every other mid-exit sample is, and fullSpeed() takes the stretch off again before the test times a reopen against the real exit. Under load on Linux WebKit it had failed two runs in five; it passes ten in ten. - The switch-and-checkbox row measures its widths once, so it now waits for the resize to land and the brand face to load before it does. Browser 300 passed on Firefox and WebKitGTK in a Linux container held to two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
768 lines
37 KiB
PHP
768 lines
37 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>
|
|
<link rel="icon" href="data:,">
|
|
<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');
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-navigation-rail-menu]');
|
|
|
|
$page->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)");
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-navigation-rail-menu]');
|
|
|
|
$page
|
|
// 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');
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-navigation-rail-menu]');
|
|
|
|
$page->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()");
|
|
|
|
// Enter on the focused button is a native click on it, and it toggles the rail: pressOnce(),
|
|
// tests/Pest.php.
|
|
pressOnce($page)->keys('[data-md-navigation-rail-menu]', 'Enter');
|
|
|
|
$page->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]')");
|
|
|
|
// Enter on the focused button is a native click on it, and it toggles the rail: pressOnce(),
|
|
// tests/Pest.php.
|
|
pressOnce($page)->keys('[data-md-navigation-rail-menu]', 'Enter');
|
|
|
|
$page->assertScript(RAIL.".hasAttribute('data-md-open')");
|
|
|
|
// The scrim closes the modal rail: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-navigation-rail-scrim]');
|
|
|
|
$page->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);
|
|
|
|
// Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('@shell-menu');
|
|
|
|
$page->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);
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-navigation-rail-menu]');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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");
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]');
|
|
|
|
$page->assertScript("{$railWidth} === 0")
|
|
->assertScript("! {$rail}.hasAttribute('data-md-open')");
|
|
|
|
// The only way back: an application's own menu button calling $store.rail.show(), which opens
|
|
// the rail back over the page: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#open-hiding-rail');
|
|
|
|
$page->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)";
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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'");
|
|
|
|
// The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-navigation-rail-menu]');
|
|
|
|
$page->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'));
|
|
|
|
// Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#open-rail');
|
|
|
|
$page->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 once a frame in the page
|
|
* itself, it was ever caught part-way (caughtMidExit() in tests/Pest.php). 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 — has moved the couple of pixels `$partWay` looks for
|
|
* within a handful of the stretched exit's frames.
|
|
*/
|
|
function caughtMidClose(mixed $page, string $close, string $partWay): bool
|
|
{
|
|
return $page->script(caughtMidExit($close, $partWay)) === true;
|
|
}
|
|
|
|
/** 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);
|
|
|
|
// Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('@shell-menu');
|
|
|
|
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
|
|
->assertScript(settled(RAIL_PANEL))
|
|
->assertScript('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]')";
|
|
|
|
// The menu button toggles the rail, then the open button brings it back over the page:
|
|
// pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]');
|
|
|
|
$page->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0");
|
|
|
|
pressOnce($page)->click('#open-hiding-rail');
|
|
|
|
$page->assertScript("{$rail}.hasAttribute('data-md-open')")
|
|
->assertScript(settled($panel))
|
|
->assertScript("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);
|
|
|
|
// Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#open-rail');
|
|
|
|
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
|
|
->assertScript(settled($scrim))
|
|
->assertScript("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();
|
|
});
|