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
207 lines
10 KiB
PHP
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");
|
|
});
|