'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],
];
}
it('draws the adaptive rail, the bar, the content region and the snackbar host', function () {
$html = (string) $this->blade('The page', ['destinations' => shellDestinations()]);
expect($html)
->toContain('data-navigation-rail="adaptive"')
->toContain('data-navigation-bar')
->toContain('toContain('max-expanded:overflow-x-clip')
->toContain('The page')
->toContain('x-data="materialSnackbar"')
->toMatch('/and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-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' => shellDestinations()]);
$rail = substr($html, strpos($html, 'data-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-navigation-rail='));
$bar = substr($html, strpos($html, 'data-app-shell-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-navigation-rail-heading>Labels
')
->and(substr_count($rail, 'data-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('app-shell-probe', new class extends Component
{
public string $page = '';
public function mount(): void
{
$this->page = url()->current();
}
public function render(): string
{
return '
';
}
});
$probe = Livewire::test('app-shell-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('lifts the snackbar above the bar only when there is a bar', function () {
expect((string) $this->blade('', ['destinations' => shellDestinations()]))
->toContain('max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
->and((string) $this->blade('', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
->not->toContain('data-app-shell-bar')
->not->toContain('--material-bottom-bar:');
});
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
expect((string) $this->blade('', ['destinations' => shellDestinations()]))
->toContain('data-navigation-bar data-tall ')
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
->not->toContain('[--material-bottom-bar:calc(4rem+')
->and((string) $this->blade('', ['destinations' => shellDestinations()]))
->not->toContain('data-tall')
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
});
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
$html = (string) $this->blade('', ['destinations' => shellDestinations()]);
expect($html)
->toContain('+var(--material-bottom-extra,0px))]')
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
->not->toMatch('/(?blade(<<<'BLADE'
BRAND
FAB
FOOTER
ACTIONS
PAGE
BLADE, ['destinations' => shellDestinations()]);
foreach (['WINDOW BAR', 'BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
expect(substr_count($html, $slot))->toBe(1);
}
expect($html)->toMatch('/data-navigation-rail-header.*BRAND.*FAB.*data-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*blade(<<<'BLADE'
PAGE
BLADE, ['destinations' => shellDestinations()]);
// Bars, then rails, then panes: the banner is outside the row the rail and the page share.
expect($html)
->toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*and((string) $this->blade('', ['destinations' => shellDestinations()]))
->not->toContain('data-app-shell-banner');
});
it('serves the showcase\'s app shell pages', function () {
$html = $this->withoutVite()
->get('/material/shell/starred')
->assertOk()
->assertSee('This is the starred page.')
->getContent();
expect($html)->toMatch('/href="[^"]*\/material\/shell\/starred"[^>]*aria-current="page"/');
$this->withoutVite()->get('/material/shell')->assertOk()->assertSee('This is the inbox page.');
$this->withoutVite()->get('/material/shell/elsewhere')->assertNotFound();
});