Files
livewire-material/tests/Feature/Components/NavigationBarTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 63d421ccb6 Draw the navigation bar and bar item without Tailwind
Plan step 36 (navigation group, second batch): <x-navigation-bar>'s and
<x-navigation-bar-item>'s class lists move into
resources/css/components/navigation-bar.css and navigation-bar-item.css,
keyed on data-md-navigation-bar (data-md-tall, data-md-hide-on-scroll/
-hidden) and data-md-navigation-bar-item (data-md-active), both in
material.components. The indicator's growing fill and its state layer,
shared with the rail's item, move into a new navigation-item.css both
files import — the day's own layer in datepicker.css is the precedent
for keeping it out of the shared foundation classes: the element
focused and pressed is the whole item, the layer drawn only on the
smaller indicator or pill inside it, which md-state-layer cannot do and
:focus-visible never matches. The wash colour moves from a dead
on-surface base overridden by both items to a shared on-secondary-
container declaration, and its opacities from literal 0.08/0.1 to
state.css's own tokens — no visible change, since the numbers matched.

Every size is px (16dp Tailwind quirks aside, this file had none); the
bar's own item-count and container-query layout, N-08's label-medium
fix and N-19's on-secondary-container wash already matched the audit,
so only hooks, units and layer needed to change.

The scaffold's hide-on-scroll offset rule (`--material-bottom-bar`,
navigation-bar.css) stays unlayered: <x-scaffold> still publishes that
variable with a Tailwind utility until its own rewrite, and a rule in
any layer loses to it regardless of specificity — unlike the FAB
overrides the rail commit moves into material.components, which only
have to beat another material.components rule.

navigation.css is not deleted yet (still Tailwind's for the rail); its
"Navigation" comment in tailwind.css narrows as each stream leaves.
navigation-bar.css is not in NavigationStylesheetsTest.php's dataset:
its one unlayered rule breaks that test's "every block is
material.components" assumption, so the same checks are in
NavigationBarTest.php instead, the same reason ErrorPagesTest.php
carries error-page.css's.

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

145 lines
8.8 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
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-md-navigation-bar/')
->toContain('data-md-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-md-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-md-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-md-active')
->and($inactive)->not->toContain('aria-current')->not->toContain('data-md-active')
->and($symbol($active))->toBe(trim((string) $this->blade('<x-icon name="star" filled />')))
->and($symbol($inactive))->toBe(trim((string) $this->blade('<x-icon name="star" />')))
->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="md-visually-hidden">, 1200</span>')
->and($dot)->toContain('data-md-dot')->toContain('<span class="md-visually-hidden">, New mail</span>')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->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('<x-navigation-bar tall><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toMatch('/data-md-navigation-bar\s+data-md-tall\s/')
->and((string) $this->blade('<x-navigation-bar />'))->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('<x-navigation-bar hide-on-scroll />'))
->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('<x-navigation-bar />'))
->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 <x-scaffold> 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';");
});