An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
7.2 KiB
PHP
114 lines
7.2 KiB
PHP
<?php
|
|
|
|
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
|
|
|
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.
|
|
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, 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%'])
|
|
// Reads --material-bottom-bar back from <x-scaffold>: layout/scaffold.css publishes it in
|
|
// `material.layout`, which this rule's own `material.components` always outranks, so both
|
|
// sit in a layer now (the era of an unlayered Tailwind utility is gone).
|
|
->and($css->declarations('[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden])'))
|
|
->toBe(['--material-bottom-bar' => 'calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px))']);
|
|
|
|
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\"]')");
|
|
});
|