Files
livewire-material/tests/Browser/NavigationTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 53bb000425 Let an application replace the safe-area insets and dock on the bottom bar
Components read the device's safe-area insets straight from env(), which a
browser test cannot fake and an application drawing its own status strip
cannot extend. Every inset the package reads, in its CSS and its views, is
now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)):
unchanged while the variables are unset.

The app shell's --material-bottom-bar also adds
var(--material-bottom-extra, 0px), so an application that docks something on
the phone's navigation bar (an offline banner) sets its height once and the
snackbar, a fab button and the page's bottom padding clear it.

A guard test fails on any env(safe-area-inset-*) outside such a variable, and
AppShellTest's assertion on the bar height's class follows the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:03:20 +02:00

207 lines
10 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
const RAIL = "document.querySelector('[data-navigation-rail]')";
const RAIL_PANEL = "document.querySelector('[data-navigation-rail-panel]')";
const BOTTOM_BAR = "document.querySelector('[data-app-shell-bar]')";
function navigationReady(mixed $page): mixed
{
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
{
// The window is resized after the page starts loading, and an adaptive rail hears of the new
// width from a media query's change event, which can arrive after the first key press.
return navigationReady(visit($path)->resize($width, $height))
->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); return ! rail || rail.dataset.navigationRail !== 'adaptive' || Alpine.\$data(rail).wide === window.matchMedia('(min-width: 64rem)').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-app-shell :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-navigation-rail]')).width,
alpine: typeof window.Alpine,
};
</script>
<p>The page</p>
</x-app-shell>
@livewireScripts
</body>
</html>
BLADE));
}
function railWidth(int $pixels): string
{
return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}";
}
it('shows the navigation bar on a phone and marks the current destination', function () {
shellPage(400, 860)
->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript(BOTTOM_BAR.".querySelectorAll('[data-navigation-bar-item]').length === 4")
->assertScript(BOTTOM_BAR.".querySelector('[aria-current=\"page\"]').textContent.includes('Inbox')")
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[aria-current=\"page\"] [data-navigation-indicator]')).backgroundImage !== 'none'")
->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[data-navigation-bar-item]:not([aria-current]) [data-navigation-indicator]')).backgroundImage === 'none'")
->assertNoJavaScriptErrors();
});
it('collapses the rail on a medium window, icon over label', function () {
shellPage(800)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-item]')).flexDirection === 'column'")
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display === 'none'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false');
});
it('expands the rail from lg, and keeps it collapsed across a reload from the first paint', function () {
firstPaintProbe();
$page = shellPage(1280, 900, '/rail-probe')
->assertScript(railWidth(256))
->assertScript("window.eval('window.firstPaint.width') === '256px'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
$page->click('[data-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false')
->assertScript(railWidth(96));
navigationReady($page->refresh())
->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'")
->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'")
->assertScript("window.eval('window.firstPaint.width') === '96px'")
->assertScript(railWidth(96));
});
it('opens the modal rail from its menu button below lg, holding focus until Escape or the scrim', function () {
$page = shellPage(800);
// From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it.
$page->script("document.querySelector('[data-navigation-rail-menu]').focus()");
$page->keys('[data-navigation-rail-menu]', 'Enter')
->assertScript(RAIL.".hasAttribute('data-open')")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
->assertScript(railWidth(96))
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display !== 'none'")
->assertScript(RAIL_PANEL.'.contains(document.activeElement)')
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') !== null")
->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
$page->keys(':focus', 'Escape')
->assertScript('! '.RAIL.".hasAttribute('data-open')")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.activeElement === document.querySelector('[data-navigation-rail-menu]')");
$page->keys('[data-navigation-rail-menu]', 'Enter')
->assertScript(RAIL.".hasAttribute('data-open')");
$page->click('[data-navigation-rail-scrim]')
->assertScript('! '.RAIL.".hasAttribute('data-open')")
->assertScript("localStorage.getItem('material-rail') === null");
});
it('slides the modal rail in on a phone from the app bar\'s menu button', function () {
$page = shellPage(400, 860);
$page->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0')
->assertScript(RAIL_PANEL.'.contains(document.activeElement)');
$page->keys(':focus', 'Escape')
->assertScript('! '.RAIL.".hasAttribute('data-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'");
});
it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
$page = shellPage(1280);
$page->click('[data-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'");
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
$page->click('[data-navigation-rail-panel] a[href$="/material/shell/starred"]')
->assertSeeIn('@shell-page', 'This is the starred page.')
->assertScript("window.eval('window.samePage') === true")
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
->assertScript(railWidth(96))
->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/shell/starred')")
->assertScript("document.querySelectorAll('[data-navigation-rail-panel] [aria-current=\"page\"]').length === 1")
->assertNoJavaScriptErrors();
});
it('skips to the content', function () {
$page = shellPage();
$page->assertScript("document.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])').matches('[data-skip-link]')");
// Safari and WebKit leave links out of the Tab order, so focus it directly.
$page->script("document.querySelector('[data-skip-link]').focus()");
$page->assertScript("getComputedStyle(document.querySelector('[data-skip-link]')).position === 'fixed'")
->assertVisible('[data-skip-link]');
$page->keys('[data-skip-link]', 'Enter')
->assertScript("location.hash === '#content'")
->assertScript("document.activeElement === document.getElementById('content')");
});
it('moves an app bar\'s content under a safe area an application sets', function () {
$row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)";
$page = navigationReady(visit('/material'));
$top = (int) $page->script($row);
$page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')");
$page->assertScript("{$row} === ".($top + 47))
->assertNoJavaScriptErrors();
});
it('lifts the snackbar and the page above something docked on the phone\'s bar', function () {
$snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))";
$contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))";
$page = shellPage(400, 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");
});