Files
Andreas Reinhold / reiniandClaude Sonnet 5 cfc57a9867 Land a caller's class and style on the scaffold and tooltip roots
Plan step 39 (part 0): the step 38 review found <x-scaffold> and
<x-tooltip> never referencing $attributes, so a caller's class and
style were silently dropped. The scaffold's root is its single
data-md-scaffold div; the tooltip's root, documented in its header, is
the standalone wrapper it draws around a trigger (data-md-tooltip-anchor,
merged with its own anchor-name) — passed an anchor instead, it renders
only a popover fragment beside another component's root and takes
nothing of the caller's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:37:20 +02:00

309 lines
17 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],
];
}
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('<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('The page')
->toContain('x-data="materialSnackbar"')
->toMatch('/<a\s+href="#content"\s+data-md-skip-link/')
->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('<x-scaffold :destinations="$destinations" />', ['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, '<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('takes the caller\'s class and style on its own root', function () {
$root = layoutRoot((string) $this->blade('<x-scaffold class="app-shell" style="--material-banner: 4rem" :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]));
expect($root)->toMatchArray(['<' => 'div', 'data-md-scaffold' => '', 'class' => 'app-shell', 'style' => '--material-banner: 4rem']);
});
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('shows a bar only when a destination asks for one, and none otherwise', function () {
expect((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->toContain('data-md-scaffold-bar')
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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('<x-scaffold :destinations="$destinations" tall-bar />', ['destinations' => scaffoldDestinations()]))
->toMatch('/data-md-navigation-bar\s+data-md-tall\s/')
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-md-tall');
});
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 window margin and the safe areas through variables an application can set', function () {
$html = (string) $this->blade('<x-scaffold :destinations="$destinations" />', ['destinations' => scaffoldDestinations()]);
// No inline value the view computed: every one of these is a variable's own fallback in CSS.
expect($html)->not->toMatch('/(?<!,)env\(safe-area-inset/');
$css = scaffoldCss();
expect($css)
->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'
<x-scaffold :destinations="$destinations">
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
</x-scaffold>
BLADE, ['destinations' => scaffoldDestinations()]);
expect($html)->toMatch('/data-md-scaffold-actions>\s*<span>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'
<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-md-scaffold-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-md-scaffold-banner.*WINDOW BAR.*data-md-navigation-rail=.*<main/s')
->and((string) $this->blade('<x-scaffold :destinations="$destinations" />', ['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'
<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-md-scaffold\s*>/')
->not->toContain('data-app-shell')
->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 = scaffoldCss();
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.
// Only the region's own <main>: one an application nests in the page takes none of this.
->toContain("[data-md-scaffold-content] > main {\n --md-layout-margin: 0px;")
->not->toContain('[data-md-scaffold] main')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './layout/scaffold.css';");
});
it('imports the stylesheets of the navigation rail, the navigation bar and the snackbar host', function () {
$css = scaffoldCss();
expect($css)
->toContain("@import '../components/navigation-rail.css';")
->toContain("@import '../components/navigation-rail-item.css';")
->toContain("@import '../components/navigation-rail-section.css';")
->toContain("@import '../components/navigation-bar.css';")
->toContain("@import '../components/navigation-bar-item.css';")
->toContain("@import '../components/toast.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();
});