'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4], ['title' => 'Sent', 'icon' => 'send', 'url' => '/sent', 'active' => true], ['title' => 'Travel', 'icon' => 'flight', 'url' => '/travel', 'section' => 'Labels', 'bar' => false], ['title' => 'Receipts', 'icon' => 'receipt_long', 'url' => '/receipts', 'section' => 'Labels', 'bar' => false, 'navigate' => false], ]; } function scaffoldCss(): string { return (string) file_get_contents(__DIR__.'/../../../resources/css/layout/scaffold.css'); } it('draws the adaptive rail, the bar, the content region and the snackbar host', function () { $html = (string) $this->blade('The page', ['destinations' => scaffoldDestinations()]); expect($html) ->toContain('data-md-navigation-rail="adaptive"') ->toContain('data-md-navigation-bar') ->toContain('
toContain('The page') ->toContain('x-data="materialSnackbar"') ->toMatch('/and(strpos($html, 'data-md-skip-link'))->toBeLessThan(strpos($html, 'data-md-navigation-rail=')); }); it('puts every destination in the rail and only those marked for the bar in the bar', function () { $html = (string) $this->blade('', ['destinations' => scaffoldDestinations()]); $rail = substr($html, strpos($html, 'data-md-navigation-rail='), strpos($html, 'data-md-scaffold-bar') - strpos($html, 'data-md-navigation-rail=')); $bar = substr($html, strpos($html, 'data-md-scaffold-bar')); expect(substr_count($rail, 'toBe(4) ->and(substr_count($bar, 'toBe(2) ->and($bar)->toContain('Inbox')->toContain('Sent')->not->toContain('Travel') ->and($rail)->toContain('data-md-navigation-rail-heading>Labels

') ->and(substr_count($rail, 'data-md-navigation-rail-section'))->toBe(1) ->and($rail)->toMatch('/href="\/receipts"(?![^>]*wire:navigate)/') ->and(substr_count($html, 'aria-current="page"'))->toBe(2); }); it('speaks a destination\'s badge in its own words when it has them', function () { $html = (string) $this->blade('', ['destinations' => [ ['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'], ['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4], ]]); expect($html)->toContain('0 of 3 done') ->and(substr_count($html, '0 of 3 done'))->toBeGreaterThanOrEqual(2); }); it('marks the destination at the current URL when none says it is active', function () { Route::get('/shell-probe/inbox', fn () => Blade::render('', ['destinations' => [ ['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')], ['title' => 'Sent', 'icon' => 'send', 'url' => url('/shell-probe/sent')], ]])); $html = $this->withoutVite()->get('/shell-probe/inbox')->assertOk()->getContent(); expect($html)->toMatch('/href="[^"]*\/shell-probe\/inbox"[^>]*aria-current="page"/') ->and(substr_count($html, 'aria-current="page"'))->toBe(2); }); it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () { Livewire::component('scaffold-probe', new class extends Component { public string $page = ''; public function mount(): void { $this->page = url()->current(); } public function render(): string { return '
'; } }); $probe = Livewire::test('scaffold-probe'); expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2) ->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2) ->and($probe->html())->toMatch('/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"[^>]*aria-current="page"/'); }); it('shows a bar only when a destination asks for one, and none otherwise', function () { expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toContain('data-md-scaffold-bar') ->and((string) $this->blade('', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) ->not->toContain('data-md-scaffold-bar'); }); it('sets the bottom offset from the bar\'s own height, short or tall, only below medium', function () { $css = scaffoldCss(); expect($css) ->toContain("[data-md-scaffold]:has(> [data-md-scaffold-bar] [data-md-navigation-bar]:not([data-md-tall])) {\n @media (width < 600px) {\n --material-bottom-bar: calc(64px + var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));") ->toContain("[data-md-scaffold]:has(> [data-md-scaffold-bar] [data-md-navigation-bar][data-md-tall]) {\n @media (width < 600px) {\n --material-bottom-bar: calc(80px + var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));"); }); it('passes M3\'s tall bar through, and the bottom offset grows with it', function () { expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toMatch('/data-md-navigation-bar\s+data-md-tall\s/') ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-md-tall'); }); it('lets the bar leave the window while the page scrolls down', function () { expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toContain('data-md-hide-on-scroll') ->toContain('x-data="materialNavigationBar"') ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-md-hide-on-scroll'); }); it('exposes the rail that hides when collapsed', function () { expect((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->toContain('data-md-hide-when-collapsed') ->toContain("materialNavigationRail('adaptive', true)") ->and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-md-hide-when-collapsed') ->toContain("materialNavigationRail('adaptive', false)"); }); it('reads the window margin and the safe areas through variables an application can set', function () { $html = (string) $this->blade('', ['destinations' => scaffoldDestinations()]); // No inline value the view computed: every one of these is a variable's own fallback in CSS. expect($html)->not->toMatch('/(?toContain("--material-margin: var(--md-sys-measurement-space200);\n\n display: flex;") ->toContain("@media (width >= 600px) {\n --material-margin: var(--md-sys-measurement-space300);") ->toContain('top: calc(var(--material-safe-top, env(safe-area-inset-top)) + var(--md-sys-measurement-space200));') ->toContain('+ var(--material-bottom-extra, 0px))'); }); it('hides itself at rest and reveals a pill in the window\'s corner on focus', function () { $css = scaffoldCss(); expect($css) ->toContain("[data-md-skip-link] {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;") ->toContain('clip-path: inset(50%);') ->toContain("&:focus {\n position: fixed;\n z-index: 60;") ->toContain('border-radius: var(--md-sys-shape-corner-full);') ->toContain('box-shadow: var(--md-sys-elevation-3);'); }); it('is a column below medium and a row of the rail and the content region from it', function () { $css = scaffoldCss(); expect($css) ->toContain("[data-md-scaffold-row] {\n flex: 1 1 0%;\n\n @media (width >= 600px) {\n display: flex;\n }\n }") ->toContain("[data-md-scaffold-content] {\n display: flex;\n min-inline-size: 0;\n flex: 1 1 0%;\n flex-direction: column;\n }"); }); it('clips the content region below expanded and clears the bar below medium, never above', function () { $css = scaffoldCss(); expect($css) ->toContain("@media (width < 840px) {\n overflow-x: clip;\n }") ->toContain("@media (width < 600px) {\n padding-block-end: var(--material-bottom-bar);\n }"); }); it('pins the bar to the window\'s bottom edge, hidden from medium', function () { $css = scaffoldCss(); expect($css)->toContain("[data-md-scaffold-bar] {\n position: fixed;\n z-index: 30;\n inset-inline: 0;\n inset-block-end: 0;\n\n @media (width >= 600px) {\n display: none;\n }\n }"); }); it('stacks the actions row into a column through every branch a collapsed rail can take', function () { $html = (string) $this->blade(<<<'BLADE' ACTIONS BLADE, ['destinations' => scaffoldDestinations()]); expect($html)->toMatch('/data-md-scaffold-actions>\s*ACTIONS<\/span>/'); $css = scaffoldCss(); // The three width-independent branches share one :where() group; the four width-gated ones // each keep their own @media block, since CSS cannot merge different queries — the same shape // navigation-rail.css's own rewrite gave its internal parts. expect($css) ->toContain("&:where(\n [data-md-navigation-rail='collapsed'] *,\n [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,\n [data-md-navigation-rail='modal']:not([data-md-open]) *\n ) {\n flex-direction: column;\n }") ->toContain("@media (width < 600px) {\n &:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }") ->toContain("@media (width < 840px) {\n &:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }") ->toContain("@media (width >= 840px) {\n @media (width < 1200px) {\n &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }\n }") ->toContain("@media (width >= 1200px) {\n &:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {\n flex-direction: column;\n }\n }"); }); it('places each slot once', function () { $html = (string) $this->blade(<<<'BLADE'
WINDOW BAR
BRAND FAB FOOTER ACTIONS
APP BAR
PAGE
BLADE, ['destinations' => scaffoldDestinations()]); foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) { expect(substr_count($html, $slot))->toBe(1); } expect($html)->toMatch('/data-md-navigation-rail-header.*BRAND.*FAB.*data-md-navigation-rail-footer.*FOOTER.*data-md-scaffold-actions.*ACTIONS.*APP BAR.*blade(<<<'BLADE'
WINDOW BAR
PAGE
BLADE, ['destinations' => scaffoldDestinations()]); // Bars, then rails, then panes: the banner is outside the row the rail and the page share. expect($html) ->toMatch('/data-md-scaffold-banner.*WINDOW BAR.*data-md-navigation-rail=.*
and((string) $this->blade('', ['destinations' => scaffoldDestinations()])) ->not->toContain('data-md-scaffold-banner'); }); it('marks the scaffold, and draws the FAB after the page\'s bar and before the page', function () { $html = (string) $this->blade(<<<'BLADE'
APP BAR
PAGE
BLADE, ['destinations' => scaffoldDestinations()]); // M3 puts a FAB early in the focus order: after the bar, before the content. expect($html)->toMatch('/^\s*/') ->not->toContain('data-app-shell') ->toMatch('/APP BAR.*