Plan step 36 (navigation group, second batch, cleanup). Every leftover data-navigation-*, data-tall, data-hide-on-scroll, data-hidden, data-width, data-align, data-divider, data-fill and component-level data-open reference outside the rewritten stylesheets and views moves to data-md-*, in the files the two component commits left alone: tests/Feature/Components/ScaffoldTest.php (the scaffold's own rewrite is a later batch, but it renders the bar and the rail today and asserts their hooks); tests/Browser/NavigationTest.php, ShowcaseTest.php and ColourProfilesTest.php (selectors only — document-level attributes, data-rail, data-rail-auto and data-app-shell*, keep their names). NavigationTest.php also gets the browser tests the plan owed this group (docs/plans/material-3-browser-tests.md § Navigation): a tall bar's vertical layout at a width where the short bar would go horizontal; hide-on-scroll never firing while a pinned snackbar is on screen; a narrow rail's 80px width and centred destinations; a rail that hides when collapsed leaving the layout and coming back only through an application's own menu button. resources/css/components/navigation.css and its import in tailwind.css are deleted now that nothing imports the file any more (the previous commit's message said this already happened there; it did not — this is where it actually lands). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
219 lines
12 KiB
PHP
219 lines
12 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
function scaffoldDestinations(): array
|
|
{
|
|
return [
|
|
['title' => '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('<x-scaffold :destinations="$destinations">The page</x-scaffold>', ['destinations' => scaffoldDestinations()]);
|
|
|
|
expect($html)
|
|
->toContain('data-md-navigation-rail="adaptive"')
|
|
->toContain('data-md-navigation-bar')
|
|
->toContain('<main id="content" tabindex="-1" wire:transition.navigate')
|
|
->toContain('max-expanded:overflow-x-clip')
|
|
->toContain('The page')
|
|
->toContain('x-data="materialSnackbar"')
|
|
->toMatch('/<a\s+href="#content"\s+data-skip-link/')
|
|
->and(strpos($html, 'data-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('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
|
|
|
|
$rail = substr($html, strpos($html, 'data-md-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-md-navigation-rail='));
|
|
$bar = substr($html, strpos($html, 'data-app-shell-bar'));
|
|
|
|
expect(substr_count($rail, '<a data-md-navigation-rail-item'))->toBe(4)
|
|
->and(substr_count($bar, '<a data-md-navigation-bar-item'))->toBe(2)
|
|
->and($bar)->toContain('Inbox')->toContain('Sent')->not->toContain('Travel')
|
|
->and($rail)->toContain('data-md-navigation-rail-heading>Labels</p>')
|
|
->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('<x-scaffold :destinations="$destinations" />', ['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('<x-scaffold :destinations="$destinations" />', ['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 '<div><x-scaffold :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
|
|
}
|
|
});
|
|
|
|
$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('lifts the snackbar above the bar only when there is a bar', function () {
|
|
expect((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
|
->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('<x-scaffold :destinations="$destinations" />', ['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('<x-scaffold :destinations="$destinations" tall-bar />', ['destinations' => scaffoldDestinations()]))
|
|
->toMatch('/data-md-navigation-bar\s+data-md-tall\s/')
|
|
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
|
|
->not->toContain('[--material-bottom-bar:calc(4rem+')
|
|
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
|
->not->toContain('data-md-tall')
|
|
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
|
|
});
|
|
|
|
it('lets the bar leave the window while the page scrolls down', function () {
|
|
expect((string) $this->blade('<x-scaffold :destinations="$destinations" hide-bar-on-scroll />', ['destinations' => scaffoldDestinations()]))
|
|
->toContain('data-md-hide-on-scroll')
|
|
->toContain('x-data="materialNavigationBar"')
|
|
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
|
->not->toContain('data-md-hide-on-scroll');
|
|
});
|
|
|
|
it('exposes the rail that hides when collapsed', function () {
|
|
expect((string) $this->blade('<x-scaffold :destinations="$destinations" hide-rail-when-collapsed />', ['destinations' => scaffoldDestinations()]))
|
|
->toContain('data-md-hide-when-collapsed')
|
|
->toContain("materialNavigationRail('adaptive', true)")
|
|
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
|
->not->toContain('data-md-hide-when-collapsed')
|
|
->toContain("materialNavigationRail('adaptive', false)");
|
|
});
|
|
|
|
it('reads the safe area and anything docked on the bar through variables an application can set', function () {
|
|
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
|
|
|
|
expect($html)
|
|
->toContain('+var(--material-bottom-extra,0px))]')
|
|
->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]')
|
|
->not->toMatch('/(?<!,)env\(safe-area-inset/');
|
|
});
|
|
|
|
it('places each slot once', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-scaffold :destinations="$destinations">
|
|
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
|
|
<x-slot:brand><span>BRAND</span></x-slot:brand>
|
|
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
|
|
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
|
|
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
|
|
<x-slot:top><header>APP BAR</header></x-slot:top>
|
|
PAGE
|
|
</x-scaffold>
|
|
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-app-shell-actions.*ACTIONS.*APP BAR.*<main.*PAGE/s');
|
|
});
|
|
|
|
it('spans the window with a banner, above the rail, and renders none without the slot', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-scaffold :destinations="$destinations">
|
|
<x-slot:banner><header>WINDOW BAR</header></x-slot:banner>
|
|
PAGE
|
|
</x-scaffold>
|
|
BLADE, ['destinations' => scaffoldDestinations()]);
|
|
|
|
// 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-md-navigation-rail=.*<main/s')
|
|
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
|
->not->toContain('data-app-shell-banner');
|
|
});
|
|
|
|
it('marks the scaffold, and draws the FAB after the page\'s bar and before the page', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-scaffold :destinations="$destinations">
|
|
<x-slot:top><header>APP BAR</header></x-slot:top>
|
|
<x-slot:fab><button>NEW</button></x-slot:fab>
|
|
PAGE
|
|
</x-scaffold>
|
|
BLADE, ['destinations' => scaffoldDestinations()]);
|
|
|
|
// M3 puts a FAB early in the focus order: after the bar, before the content.
|
|
expect($html)->toMatch('/^\s*<div\s+data-app-shell\s+data-md-scaffold\b/')
|
|
->toMatch('/APP BAR.*<div data-md-scaffold-fab><button>NEW<\/button><\/div>\s*<main id="content".*PAGE/s')
|
|
->and(substr_count($html, 'NEW'))->toBe(1)
|
|
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
|
|
->not->toContain('data-md-scaffold-fab');
|
|
});
|
|
|
|
it('places the FAB at the bottom-end corner, clear of the bar and of a snackbar, in the layout layer', function () {
|
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/scaffold.css');
|
|
|
|
expect($css)->toContain('@layer material.layout')
|
|
->toContain('--md-scaffold-fab-margin: var(--md-sys-measurement-space200);')
|
|
->toContain("@media (width >= 600px) {\n --md-scaffold-fab-margin: var(--md-sys-measurement-space300);")
|
|
->toContain('max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom)))')
|
|
->toContain('+ var(--material-snackbar-height, 0px)')
|
|
->toContain('+ min(var(--material-snackbar-height, 0px), var(--md-sys-measurement-space200))')
|
|
// The content region already has the window margin, so a pane inside draws none.
|
|
->toContain("[data-md-scaffold] main {\n --md-layout-margin: 0px;")
|
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/scaffold.css';");
|
|
});
|
|
|
|
it('is the only name for it: there is no app shell to fall back on', function () {
|
|
expect(fn () => $this->blade('<x-app-shell :destinations="[]" />'))->toThrow(Exception::class)
|
|
->and(file_exists(__DIR__.'/../../../resources/views/components/app-shell.blade.php'))->toBeFalse();
|
|
});
|
|
|
|
it('serves the showcase\'s scaffold 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();
|
|
});
|