Files
livewire-material/tests/Feature/Components/ScaffoldTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 0013e0154d Rename the app shell to the scaffold and give it a FAB slot
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
2026-09-14 14:51:48 +02:00

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-navigation-rail="adaptive"')
->toContain('data-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-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-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, '<a data-navigation-rail-item'))->toBe(4)
->and(substr_count($bar, '<a data-navigation-bar-item'))->toBe(2)
->and($bar)->toContain('Inbox')->toContain('Sent')->not->toContain('Travel')
->and($rail)->toContain('data-navigation-rail-heading>Labels</p>')
->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('<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-navigation-bar\s+data-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-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-hide-on-scroll')
->toContain('x-data="materialNavigationBar"')
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-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-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', true)")
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-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-navigation-rail-header.*BRAND.*FAB.*data-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-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();
});