Plan step 25 (navigation Missing): M3's "hides on scroll-down, reappears on scroll-up" had no implementation. `<x-navigation-bar hide-on-scroll>` slides the bar out on the default spatial spring — an instant swap under reduced motion, which zeroes the duration token — and never while a snackbar, a bottom sheet or a drawer is anchored to its edge; focus reaching the bar brings it back. `<x-app-shell hide-bar-on-scroll>` picks it, and --material-bottom-bar follows the bar down and up so a `fab` button and the snackbar keep their offsets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
106 lines
6.5 KiB
PHP
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-app-shell> 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\"]')");
|
|
});
|