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
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-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\"]')");
|
|
});
|