tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
On a slow machine Escape could close the full-screen search and have it open again for good. A close hands focus back to the input — Escape does, and so does the view's focus trap as it lets go — and `focused()` told that return from someone coming to search by a 250ms wall-clock window. Both returns run on frames, and a runner painting a few frames a second took longer than that, so the returning focus opened the view again. A `returning` flag now covers the close until the hand-back has actually run, on the same frame, and the constant is gone. `hold()` times the full-screen layout from the exit's own duration token, the one Alpine's x-transition holds `display` for, instead of waiting a frame or two for the exit's transitions to appear: `getAnimations()` is empty both before an engine creates them and after they end, and a loaded engine can leave a second between frames. The browser tests were racing the same slow machine, reproduced in a Linux container like the runner, with its Playwright Firefox and WebKitGTK and two cores kept busy: - The browser plugin retries every script and action with a one-second attempt until the budget runs out. An in-page sleep longer than that only ever passed on the last attempt, which is where the 47-50s tests came from, and a script that clicks was run again against a page that had moved on. `onceInPage()` runs such a script once however often it is retried; the long sleeps are plain retried conditions now. - Under load WebKitGTK paints no frame while a tight setTimeout loop runs, so a sample loop saw the start value and then nothing. `caughtMidExit()` samples on animation frames, for 2.5s. - "No animations running" is also true before an opening transition exists, so a close could be sampled from a scrim at 4% opacity. `settled()` waits two frames before it asks. The workflow no longer uploads failure screenshots: Gitea's artifact service timed out on every attempt, two minutes per red run, and the job logs are readable without it. Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on macOS, and on Firefox and WebKitGTK in the Linux container under load, twice each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
725 lines
35 KiB
PHP
725 lines
35 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');
|
|
|
|
$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>
|
|
<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")
|
|
->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>
|
|
<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'")
|
|
->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 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)
|
|
->click('@shell-menu')
|
|
->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]')";
|
|
|
|
$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))
|
|
->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)
|
|
->click('#open-rail')
|
|
->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();
|
|
});
|