Files
livewire-material/tests/Feature/Components/NavigationBarTest.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

106 lines
6.5 KiB
PHP

<?php
use Illuminate\Support\Str;
it('draws a navigation landmark around its items', function () {
expect((string) $this->blade('<x-navigation-bar><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toMatch('/<nav\s+aria-label="Main"\s+data-navigation-bar/')
->toContain('data-navigation-bar-items')
->and((string) $this->blade('<x-navigation-bar label="Sections" />'))->toContain('aria-label="Sections"');
});
it('links a destination through wire:navigate unless told not to', function () {
expect((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))
->toContain('<a data-navigation-bar-item')
->toContain('href="/inbox"')
->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-bar-item label="Help" icon="help" link="https://example.com" external />'))
->toContain('target="_blank"')
->not->toContain('wire:navigate');
});
it('is a button without a link', function () {
expect((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" wire:click="show(\'inbox\')" />'))
->toContain('<button data-navigation-bar-item')
->toContain('type="button"')
->not->toContain('href=');
});
it('marks the current destination with aria-current and the filled icon', function () {
$active = (string) $this->blade('<x-navigation-bar-item label="Starred" icon="star" link="/starred" active />');
$inactive = (string) $this->blade('<x-navigation-bar-item label="Starred" icon="star" link="/starred" />');
$symbol = fn (string $html): string => preg_match('/<svg.*?<\/svg>/s', $html, $svg) ? $svg[0] : '';
expect($active)->toContain('aria-current="page"')->toContain('data-active')
->and($inactive)->not->toContain('aria-current')->not->toContain('data-active')
->and($symbol($active))->toBe(trim((string) $this->blade('<x-icon name="star" filled class="size-6" />')))
->and($symbol($inactive))->toBe(trim((string) $this->blade('<x-icon name="star" class="size-6" />')))
->and($symbol($active))->not->toBe($symbol($inactive));
});
it('badges the icon with a dot or a count that screen readers hear', function () {
$count = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" badge="1200" />');
$dot = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" :badge="true" badge-label="New mail" />');
expect($count)->toContain('999+')->toContain('<span class="sr-only">, 1200</span>')
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
});
it('keeps the bar item on the label and state-layer colours M3 tokens', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css');
$horizontal = Str::of($css)->after('@container (width >= 37.5rem)')->before('/* ------')->toString();
expect($horizontal)
// NavigationBarTokens names one label font for both icon positions: label-medium (N-08).
->not->toContain('--md-sys-typescale-label-lg')
->and($css)
// The indicator and the pill wash in on-secondary-container, as the rail's do (N-19).
->toContain('[data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {');
});
it('takes M3\'s tall container, which keeps the vertical item layout at every width', function () {
expect((string) $this->blade('<x-navigation-bar tall><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toMatch('/data-navigation-bar\s+data-tall\s/')
->and((string) $this->blade('<x-navigation-bar />'))->not->toContain('data-tall');
$css = file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css');
expect($css)
// NavigationBarTokens.TallContainerHeight, 80dp, against the short bar's 64.
->toMatch('/\[data-navigation-bar\]\[data-tall\] \[data-navigation-bar-items\] \{\s+min-height: 5rem;/')
// The vertical layout is the base — a compact bar and a tall one at any width share it —
// and only the short bar's horizontal layout is behind the 600px container query, so no
// rule inside one reaches an item without saying `:not([data-tall])` first.
->and(preg_match_all('/@container \(width [<>]=? 37\.5rem\) \{\n(.*?)\n \}/s', $css, $blocks) > 0)->toBeTrue()
->and(implode("\n", $blocks[1]))->not->toMatch('/^ \[data-navigation-bar-item/m');
});
it('hides on a scroll down and springs back on a scroll up', function () {
expect((string) $this->blade('<x-navigation-bar hide-on-scroll />'))
->toContain('data-hide-on-scroll')
->toContain('x-data="materialNavigationBar"')
->toContain('x-bind:data-hidden="away ? \'\' : null"')
// Focus reaching the bar brings it back — the web's nearest answer to M3's "never hide it
// while a screen reader is active".
->toContain('x-on:focusin="show()"')
->and((string) $this->blade('<x-navigation-bar />'))
->not->toContain('data-hide-on-scroll')
->not->toContain('materialNavigationBar');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
// The spatial spring, which reduced motion zeroes along with every other duration token.
->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\] \{\s+transition: translate var\(--md-sys-motion-spatial-default-duration\) var\(--md-sys-motion-spatial-default\);/')
->toMatch('/\[data-navigation-bar\]\[data-hide-on-scroll\]\[data-hidden\] \{\s+translate: 0 100%;/')
// Unlayered, or the utility <x-scaffold> publishes the offset with would win.
->toContain('[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {')
->and(Str::of(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))->after('[data-app-shell]:has(')->toString())
->not->toContain('@layer');
expect(file_get_contents(__DIR__.'/../../../resources/js/navigation.js'))
// Never out from under a snackbar, a bottom sheet or a drawer resting on its edge.
->toContain("document.querySelectorAll('[data-toast], [role=\"dialog\"]')");
});