Plan step 35: <x-app-shell> becomes <x-scaffold>, M3's and Compose's name for the structure of bars, rails and panes, with no alias; every package view, the showcase, the tests, the skills, README and UPGRADE follow. Its markup and Tailwind classes stay for the navigation rewrite. The new fab slot places the FAB as Compose's Scaffold does - bottom-end, 16px from the edges below medium and 24px from it, clear of the navigation bar and of a snackbar on screen (resources/css/layout/scaffold.css) - and the content region tells a pane inside that the margin is already drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
261 lines
13 KiB
PHP
261 lines
13 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. 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-navigation-rail]'); if (! rail || rail.dataset.navigationRail !== 'adaptive') return true; const state = Alpine.\$data(rail); return state.wide === window.matchMedia('(width >= 840px)').matches && state.roomy === window.matchMedia('(width >= 1200px)').matches; })()\")");
|
|
}
|
|
|
|
/**
|
|
* A shell whose page records, while the document is still being parsed — after the rail's
|
|
* markup and the stylesheet, before Alpine exists — what <html> and the rail looked like.
|
|
* That is what the first paint shows.
|
|
*/
|
|
function firstPaintProbe(): void
|
|
{
|
|
Route::middleware('web')->get('/rail-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body>
|
|
<x-scaffold :destinations="[
|
|
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true],
|
|
['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'],
|
|
]">
|
|
<script>
|
|
window.firstPaint = {
|
|
rail: document.documentElement.getAttribute('data-rail'),
|
|
width: getComputedStyle(document.querySelector('[data-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-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 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-navigation-rail-item]')).flexDirection === 'column'")
|
|
->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display === 'none'")
|
|
->assertAttribute('[data-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-navigation-rail-menu]', 'aria-expanded', 'false');
|
|
|
|
$page->click('[data-navigation-rail-menu]')
|
|
->assertScript(railWidth(256))
|
|
->assertScript('! '.RAIL.".hasAttribute('data-open')")
|
|
->assertScript("getComputedStyle(document.querySelector('[data-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-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));
|
|
});
|
|
|
|
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-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));
|
|
|
|
// 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-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-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 compact window from the app bar\'s menu button', function () {
|
|
$page = shellPage(599, 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(1200);
|
|
|
|
$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(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");
|
|
});
|