blade('')) ->toMatch('/toContain('data-md-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-md-active') ->and($inactive)->not->toContain('aria-current')->not->toContain('data-md-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('data-md-dot')->toContain(', New mail') ->and((string) $this->blade(''))->not->toContain('md-visually-hidden'); }); it('keeps the bar item on the label and state-layer colours M3 tokens', function () { $bar = ComponentStylesheet::read('navigation-bar-item'); $shared = ComponentStylesheet::read('navigation-item'); // NavigationBarTokens names one label font for both icon positions: label-medium (N-08). expect($bar->declarations('[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill]', ['@container (width >= 600px)'])) ->not->toHaveKey('font') // The indicator and the pill wash in on-secondary-container, as the rail's do (N-19), from // the shared file both the bar's and the rail's items import. ->and($shared->declarations('[data-md-navigation-bar-item] [data-md-navigation-indicator]::before, [data-md-navigation-bar-item] [data-md-navigation-pill]::before, [data-md-navigation-rail-item]::before, [data-md-navigation-rail-item] [data-md-navigation-indicator]::before')) ->toHaveKey('background-color', 'var(--md-sys-color-on-secondary-container)'); }); it('takes M3\'s tall container, which keeps the vertical item layout at every width', function () { expect((string) $this->blade('')) ->toMatch('/data-md-navigation-bar\s+data-md-tall\s/') ->and((string) $this->blade(''))->not->toContain('data-md-tall'); $css = ComponentStylesheet::read('navigation-bar'); // NavigationBarTokens.TallContainerHeight, 80dp, against the short bar's 64. expect($css->declarations('[data-md-navigation-bar][data-md-tall] [data-md-navigation-bar-items]')) ->toBe(['min-height' => '80px']); // 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 it reaches an item without saying `:not([data-md-tall])` first. $item = ComponentStylesheet::read('navigation-bar-item'); expect($item->has('[data-md-navigation-bar-item]'))->toBeTrue() ->and($item->has('[data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item]', ['@container (width >= 600px)']))->toBeTrue(); }); it('hides on a scroll down and springs back on a scroll up', function () { expect((string) $this->blade('')) ->toContain('data-md-hide-on-scroll') ->toContain('x-data="materialNavigationBar"') ->toContain('x-bind:data-md-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-md-hide-on-scroll') ->not->toContain('materialNavigationBar'); $css = ComponentStylesheet::read('navigation-bar'); // The spatial spring, which reduced motion zeroes along with every other duration token. expect($css->declarations('[data-md-navigation-bar][data-md-hide-on-scroll]')) ->toBe(['transition' => 'translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)']) ->and($css->declarations('[data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]')) ->toBe(['translate' => '0 100%']) ->and($css->css) // Unlayered, or the Tailwind utility still publishes the offset with would win — // its own rewrite has not landed yet, so this one rule stays unlayered until it does. ->toContain('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) {') ->and(Str::of($css->css)->after('[data-md-scaffold]: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-md-toast-snackbar], [role=\"dialog\"]')"); }); /** * navigation-bar.css is not in NavigationStylesheetsTest.php's dataset: it keeps one rule * unlayered on purpose, so its shape does not match every other stylesheet's single * `@layer material.components` block. These are the same checks, minus that one assumption — the * same reason ErrorPagesTest.php carries error-page.css's. */ it('draws navigation-bar.css shaped like every package stylesheet but its one unlayered rule', function () { $css = ComponentStylesheet::read('navigation-bar'); expect($css->css)->toStartWith('/*') ->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;') ->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/') ->and($css->blocks())->toContain('@layer material.components') ->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/'); expect(ViewClasses::violations(File::get(__DIR__.'/../../../resources/views/components/navigation-bar.blade.php')))->toBe([]); $source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css); expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m'); $components = File::get(__DIR__.'/../../../resources/css/components.css'); $block = substr($components, (int) strpos($components, '/* Navigation */')); expect($block)->toContain("@import './components/navigation-bar.css';"); });