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>
258 lines
13 KiB
PHP
258 lines
13 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
|
|
|
it('renders the tablist on the server with the first enabled tab chosen', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-tabs id="share" :tabs="[['name' => 'files', 'label' => 'Files', 'disabled' => true], ['name' => 'people', 'label' => 'People', 'icon' => 'group', 'badge' => 3]]">
|
|
<x-tab name="people">Anna</x-tab>
|
|
</x-tabs>
|
|
BLADE);
|
|
|
|
expect($html)
|
|
->toContain('role="tablist"')
|
|
->toContain('data-md-variant="primary"')
|
|
->toContain('id="share-people"')
|
|
->toContain('aria-controls="share-people-panel"')
|
|
->toMatch('/data-md-tab="people"\s+aria-controls="share-people-panel"\s+aria-selected="true"\s+tabindex="0"/')
|
|
->toMatch('/data-md-tab="files"\s+aria-controls="share-files-panel"\s+aria-selected="false"\s+tabindex="-1"/')
|
|
->toContain('disabled')
|
|
->toContain('data-md-tab-indicator')
|
|
->toContain('--tabs-indicator: share-indicator')
|
|
->toContain('x-modelable="selected"')
|
|
// The panel's id is the server's, so aria-controls never dangles.
|
|
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
|
|
->toContain('>3</span>');
|
|
});
|
|
|
|
it('renders every panel but the chosen one hidden, before Alpine boots', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-tabs :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People']]" selected="people">
|
|
<x-tab name="files">Three files.</x-tab>
|
|
<x-tab name="people">Anna and Ben.</x-tab>
|
|
</x-tabs>
|
|
BLADE);
|
|
|
|
expect($html)
|
|
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
|
|
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/')
|
|
// The tab button and its panel agree on the id the slot worked out for itself.
|
|
->toMatch('/aria-controls="(tabs-[0-9a-f]{8})-people-panel"/')
|
|
->toMatch('/id="(tabs-[0-9a-f]{8})-people-panel"/')
|
|
->toContain('data-md-tab-panel');
|
|
});
|
|
|
|
it('takes a variant, stacks icons and scrolls on request', function () {
|
|
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
|
|
->toContain('data-md-variant="secondary"')
|
|
->not->toContain('data-md-stacked')
|
|
->toContain('data-md-scrollable')
|
|
->and((string) $this->blade('<x-tabs stacked selected="b" :tabs="[[\'name\' => \'a\', \'label\' => \'A\'], [\'name\' => \'b\', \'label\' => \'B\']]" />'))
|
|
->toContain('data-md-stacked')
|
|
->toMatch('/data-md-tab="b"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
|
|
});
|
|
|
|
it('entangles the chosen tab with Livewire and renders the one the property names', function () {
|
|
Livewire::component('tabs-probe', new class extends Component
|
|
{
|
|
public string $tab = 'people';
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]"><x-tab name="files">Three files.</x-tab><x-tab name="people">Anna and Ben.</x-tab></x-tabs></div>';
|
|
}
|
|
});
|
|
|
|
expect(Livewire::test('tabs-probe')->html())
|
|
->toContain(".entangle('tab').live")
|
|
->not->toContain('x-modelable')
|
|
->toMatch('/data-md-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
|
|
// The panel the property names is the one drawn, before Alpine entangles anything.
|
|
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
|
|
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
|
|
});
|
|
|
|
it('tells the server at once only when the caller asked with wire:model.live', function () {
|
|
Livewire::component('tabs-deferred-probe', new class extends Component
|
|
{
|
|
public string $tab = 'files';
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-tabs wire:model="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]" /></div>';
|
|
}
|
|
});
|
|
|
|
Livewire::component('tabs-live-probe', new class extends Component
|
|
{
|
|
public string $tab = 'files';
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]" /></div>';
|
|
}
|
|
});
|
|
|
|
// @entangle appends `.live` itself for `wire:model.live`; the view adds nothing of its own, so a
|
|
// deferred `wire:model` stays deferred.
|
|
$live = Livewire::test('tabs-live-probe')->html();
|
|
|
|
expect(Livewire::test('tabs-deferred-probe')->html())->toContain(".entangle('tab')")->not->toContain(".entangle('tab').live")
|
|
->and(substr_count($live, ".entangle('tab').live"))->toBe(1)
|
|
->and($live)->not->toContain('.live.live');
|
|
});
|
|
|
|
it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', function () {
|
|
$css = ComponentStylesheet::read('tabs');
|
|
|
|
expect($css->declarations('[data-md-tabs-bar][data-md-scrollable]'))
|
|
// 52dp before the first of a scrollable set.
|
|
->toHaveKey('padding-inline-start', '52px')
|
|
// A primary indicator is inset 2dp each side; a secondary one spans the tab.
|
|
->and($css->declarations('[data-md-tab-indicator]'))
|
|
->toHaveKey('inset-inline', '2px')
|
|
->and($css->declarations('[data-md-tabs-bar][data-md-variant=\'secondary\'] [data-md-tab-indicator]'))
|
|
->toHaveKey('inset-inline', '0')
|
|
// The ring is 2px outside, as everywhere else in the package.
|
|
->and($css->declarations('[data-md-tab]:focus-visible'))
|
|
->toBe(['color' => 'var(--md-sys-color-on-surface)', 'outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
|
|
// A link marked as the page is the chosen tab too.
|
|
->and($css->declarations('[data-md-tab]:is([aria-selected=\'true\'], [aria-current=\'page\'])'))
|
|
->toBe(['color' => 'var(--md-sys-color-primary)']);
|
|
});
|
|
|
|
/**
|
|
* Plan step 46, M3 § Tabs: "Labels: single row by default; may wrap to a max second line if needed
|
|
* with truncation, or use scrollable tabs to give longer titles more room." Both views render the
|
|
* text of a label in its own `data-md-tab-text`, so the one rule in tabs.css clamps it.
|
|
*/
|
|
it('renders a label\'s text in its own hook, in tabs and section tabs alike', function () {
|
|
expect((string) $this->blade('<x-tabs :tabs="[[\'name\' => \'people\', \'label\' => \'People with access\', \'badge\' => 3]]" />'))
|
|
->toMatch('/<span data-md-tab-label>\s*<span data-md-tab-text>People with access<\/span>\s*<span[^>]*data-md-badge/')
|
|
->and((string) $this->blade('<x-section-nav :items="[[\'title\' => \'Two-factor authentication\', \'url\' => \'/security\', \'badge\' => 1]]" no-wire-navigate />'))
|
|
->toMatch('/<span data-md-tab-label>\s*<span data-md-tab-text>Two-factor authentication<\/span>\s*<span[^>]*data-md-badge/');
|
|
});
|
|
|
|
it('lets a fixed tab\'s label wrap to a second line and truncate there, and a scrollable one keep its row', function () {
|
|
$css = ComponentStylesheet::read('tabs');
|
|
|
|
expect($css->declarations('[data-md-tab]'))
|
|
// M3's 48px is the tab's minimum: it grows to hold a second line, and the bar stretches the rest.
|
|
->toHaveKey('min-height', '48px')
|
|
->not->toHaveKey('height')
|
|
->toHaveKey('min-width', '90px')
|
|
// The single row a scrollable tab's label and every badge keep.
|
|
->toHaveKey('white-space', 'nowrap')
|
|
->and($css->declarations('[data-md-tabs-bar][data-md-stacked] [data-md-tab]'))
|
|
->toBe(['min-height' => '64px'])
|
|
// The content fills a grown tab, so a primary indicator at its foot stays on the divider.
|
|
->and($css->declarations('[data-md-tab-content]'))
|
|
->toHaveKey('align-self', 'stretch')
|
|
->not->toHaveKey('height')
|
|
->and($css->declarations('[data-md-tab-label]'))
|
|
->toHaveKey('min-width', '0')
|
|
->and($css->declarations('[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text]'))
|
|
->toBe([
|
|
'display' => '-webkit-box',
|
|
'min-width' => '0',
|
|
'overflow' => 'hidden',
|
|
'-webkit-box-orient' => 'vertical',
|
|
'-webkit-line-clamp' => '2',
|
|
'text-align' => 'center',
|
|
'text-overflow' => 'ellipsis',
|
|
'white-space' => 'normal',
|
|
])
|
|
// The ring's room and the state layer still reach past a grown tab to the divider.
|
|
->and($css->declarations('[data-md-tab]::before'))
|
|
->toHaveKey('inset-block', 'calc(-1 * var(--tabs-ring))')
|
|
->and($css->declarations('[data-md-tab-indicator]'))
|
|
->toHaveKey('bottom', 'calc(-1 * var(--tabs-ring))')
|
|
->and(collect($css->rules())->filter(fn (array $rule): bool => str_contains($rule['selector'], 'data-md-tab-text'))->pluck('selector')->all())
|
|
->toBe(['[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text]']);
|
|
});
|
|
|
|
it('hides every panel until Alpine takes over, and stands the panel\'s content clear of the bar', function () {
|
|
$css = ComponentStylesheet::read('tabs');
|
|
|
|
expect($css->declarations('[data-md-tab-panel]'))
|
|
->toBe(['padding-top' => 'var(--md-sys-measurement-space300)', 'outline' => 'none']);
|
|
});
|
|
|
|
it('draws section navigation as secondary tabs and a picker', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-section-nav label="Settings" :items="[
|
|
['title' => 'Profile', 'icon' => 'person', 'url' => '/settings/profile'],
|
|
['title' => 'Security', 'icon' => 'lock', 'url' => '/settings/security', 'active' => true, 'badge' => 1],
|
|
]" />
|
|
BLADE);
|
|
|
|
expect($html)
|
|
->toContain('aria-label="Settings"')
|
|
->toContain('data-md-section-nav-picker')
|
|
->toContain('data-md-variant="secondary"')
|
|
->toMatch('/href="\/settings\/security"\s+data-md-tab\s+aria-current="page"\s+wire:navigate/')
|
|
->not->toMatch('/href="\/settings\/profile"\s+data-md-tab\s+aria-current/')
|
|
// The picker is a menu of places: the current one is the page, not a checked choice,
|
|
// and a section's badge shows there as well as on its tab.
|
|
->not->toContain('role="menuitemcheckbox"')
|
|
->toMatch('/role="menuitem"[^>]*aria-current="page"[^>]*href="\/settings\/security"/')
|
|
->toMatch('/data-md-section-nav-picker.*Security.*>\s*1\s*<.*<nav/s');
|
|
|
|
// The picker shows only below medium, the tab bar only from it (the compact fallback).
|
|
$css = ComponentStylesheet::read('section-nav');
|
|
|
|
expect($css->declarations('[data-md-section-nav-picker]', ['@media (width >= 600px)']))
|
|
->toBe(['display' => 'none'])
|
|
->and($css->declarations('[data-md-section-nav] > nav', ['@media (width < 600px)']))
|
|
->toBe(['display' => 'none'])
|
|
// Each link's list item is a flex row, so tabs.css's `li > [data-md-tab]` shares its width.
|
|
->and($css->declarations('[data-md-section-nav] [data-md-tabs-bar] > li'))
|
|
->toBe(['display' => 'flex', 'min-inline-size' => '0']);
|
|
});
|
|
|
|
it('marks the section whose url is the request\'s, and scrolls many sections', function () {
|
|
$this->get('/');
|
|
|
|
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
|
|
|
|
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
|
|
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows.
|
|
->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/')
|
|
->not->toContain('grid-cols-')
|
|
// Its label is `<x-tabs>`'s anatomy, drawn by tabs.css, not a one-line `md-truncate`.
|
|
->toMatch('/data-md-tab\s+aria-current="page"\s*>\s*<span data-md-tab-content>\s*<span data-md-tab-label>\s*<span data-md-tab-text>S3<\/span>/')
|
|
->not->toMatch('/data-md-tab-content>(?:(?!<\/a>).)*md-truncate/s')
|
|
->not->toContain('wire:navigate')
|
|
// Four still share the row.
|
|
->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)]))
|
|
->not->toContain('data-md-scrollable');
|
|
});
|
|
|
|
it('keeps the page\'s section current while a Livewire component on it updates', function () {
|
|
Livewire::component('section-nav-probe', new class extends Component
|
|
{
|
|
public string $page = '';
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->page = url()->current();
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-section-nav :items="[[\'title\' => \'Here\', \'url\' => $page], [\'title\' => \'Elsewhere\', \'url\' => \'/elsewhere\']]" no-wire-navigate /></div>';
|
|
}
|
|
});
|
|
|
|
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-md-tab\s+aria-current="page"/';
|
|
|
|
$probe = Livewire::test('section-nav-probe');
|
|
|
|
expect($probe->html())->toMatch($current)
|
|
->and($probe->call('$refresh')->html())->toMatch($current)
|
|
// Once as the tab, once as the picker's item: one section, drawn for both widths.
|
|
->and(substr_count($probe->html(), 'aria-current="page"'))->toBe(2);
|
|
});
|