Files
livewire-material/tests/Feature/Components/ScaffoldTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 925d9a4439 Fold layout.css and components.css into all.css
Plan step 37: all.css replaces the two interim import lists with one
entry for an application that wants everything — the foundation, every
layout stylesheet and every component stylesheet, grouped under the
same block comments components.css used, plus a Layout block. It also
directly imports the three files nothing imported by name before
(layout/spacing.css, layout/visibility.css, components/selection.css),
so every file under components/ and layout/ is now one @import away.

Every test that read a block of components.css or layout.css now reads
the matching block of all.css through one shared helper (allCssBlock(),
in tests/Pest.php so it loads for any test run) instead of repeating
the same substr() search in each file. StylesheetsTest.php's shape,
Tailwind-free and breakpoint checks, previously run twice (once for
the foundation, once for the layout tree), now run once over the whole
tree all.css reaches, since every component and layout stylesheet is
plain CSS after step 36; a new test asserts all.css imports everything
under components/ and layout/ exactly once. The Workbench imports
all.css in place of layout.css and components.css.

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

303 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('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>/')
->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();
});