blade('')) ->toMatch('/toContain('data-navigation-bar-items') ->and((string) $this->blade(''))->toContain('aria-label="Sections"'); }); it('links a destination through wire:navigate unless told not to', function () { expect((string) $this->blade('')) ->toContain('toContain('href="/inbox"') ->toContain('wire:navigate') ->and((string) $this->blade(''))->not->toContain('wire:navigate') ->and((string) $this->blade('')) ->toContain('target="_blank"') ->not->toContain('wire:navigate'); }); it('is a button without a link', function () { expect((string) $this->blade('')) ->toContain('toContain('type="button"') ->not->toContain('href='); }); it('marks the current destination with aria-current and the filled icon', function () { $active = (string) $this->blade(''); $inactive = (string) $this->blade(''); $symbol = fn (string $html): string => preg_match('//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(''))) ->and($symbol($inactive))->toBe(trim((string) $this->blade(''))) ->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(''); $dot = (string) $this->blade(''); expect($count)->toContain('999+')->toContain(', 1200') ->and($dot)->toContain('size-1.5')->toContain(', New mail') ->and((string) $this->blade(''))->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('')) ->toMatch('/data-navigation-bar\s+data-tall\s/') ->and((string) $this->blade(''))->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('')) ->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('')) ->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 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\"]')"); });