Files
livewire-material/tests/Feature/Components/NavigationRailTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00

361 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('draws each mode, falling back to collapsible', function (string $mode, string $expected) {
expect((string) $this->blade('<x-navigation-rail :mode="$mode"><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>', ['mode' => $mode]))
->toContain("data-md-navigation-rail=\"{$expected}\"")
->toContain('<nav')
->toContain('aria-label="Main"');
})->with([
['collapsed', 'collapsed'],
['expanded', 'expanded'],
['collapsible', 'collapsible'],
['modal', 'modal'],
['adaptive', 'adaptive'],
['sideways', 'collapsible'],
]);
it('gives the collapsible, modal and adaptive rails a menu button and the store', function () {
$collapsible = (string) $this->blade('<x-navigation-rail />');
expect($collapsible)
->toContain('x-data="materialNavigationRail(\'collapsible\', false)"')
->toContain('data-md-navigation-rail-menu')
->toContain('aria-label="Collapse navigation"')
->toContain('aria-expanded="true"')
// The 40px menu button reaches M3's 48px target through the shared classes.
->toContain('class="md-state-layer md-focus-ring md-touch-target"')
->not->toContain('data-md-navigation-rail-scrim')
->not->toContain('x-trap')
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
->toContain('aria-label="Expand navigation"')
->toContain('data-md-navigation-rail-scrim')
->toContain('x-trap.inert.noscroll="open"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-md-navigation-rail-menu')
->not->toContain('x-data')
->and((string) $this->blade('<x-navigation-rail mode="collapsible" :menu="false" />'))->not->toContain('data-md-navigation-rail-menu');
});
it('keeps the header and footer out of the scrolling destinations', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-navigation-rail mode="expanded" width="320px">
<x-slot:brand><span>Brand</span></x-slot:brand>
<x-slot:header><button>FAB</button></x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" />
<x-slot:footer><button>Account</button></x-slot:footer>
</x-navigation-rail>
BLADE);
expect($html)
->toContain('--navigation-rail-width: 320px')
->toMatch('/data-md-navigation-rail-header.*Brand.*FAB.*data-md-navigation-rail-destinations.*Inbox.*data-md-navigation-rail-footer.*Account/s');
});
it('takes M3\'s optional divider and turns the container fill off', function () {
expect((string) $this->blade('<x-navigation-rail mode="expanded" divider :fill="false" />'))
->toContain('data-md-divider')
->toContain('data-md-fill="false"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-md-divider')
->not->toContain('data-md-fill');
$css = ComponentStylesheet::read('navigation-rail');
// Neither reaches a rail open over a scrim, which is a surface over the page, nor one
// sliding off the window as it closes.
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['border-inline-end' => '1px solid var(--md-sys-color-outline-variant)'])
->and($css->declarations('[data-md-navigation-rail][data-md-fill=\'false\']:not([data-md-open], [data-md-closing=\'sheet\']) > [data-md-navigation-rail-panel]'))
->toBe(['background-color' => 'transparent'])
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead.
->and($css->declarations('[data-md-navigation-rail=\'collapsible\']', ['@media (width < 600px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
});
it('takes M3\'s narrow collapsed width, icons alone but still named', function () {
$narrow = (string) $this->blade('<x-navigation-rail width="narrow"><x-slot:header><button>FAB</button></x-slot:header><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>');
expect($narrow)
->toContain('data-md-width="narrow"')
// A narrow rail expands to the same 256px; `width` names one or the other, never both.
->toContain('--navigation-rail-width: 256px')
->toContain('data-md-navigation-rail-menu-row')
->toContain('data-md-navigation-rail-fab-row')
// The label is still rendered: it is what names the destination.
->toContain('<span data-md-navigation-label>Inbox</span>')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" width="320px" />'))
->not->toContain('data-md-width')
->toContain('--navigation-rail-width: 320px');
$css = ComponentStylesheet::read('navigation-rail');
// NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against the default 96dp.
expect($css->declarations('[data-md-navigation-rail][data-md-width=\'narrow\']'))
->toBe(['--navigation-rail-collapsed-width' => '80px'])
->and($css->declarations('[data-md-navigation-rail]'))
->toHaveKey('--navigation-rail-collapsed-width', '96px')
// Out of the drawing, not out of the page: clipped only while collapsed.
->and($css->declarations("[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label]", ['@container style(--md-navigation-rail-value: collapsed)']))
->toHaveKey('clip-path', 'inset(50%)');
});
it('centres the destinations on request, leaving the header and footer at their ends', function () {
expect((string) $this->blade('<x-navigation-rail mode="collapsed" align="center" />'))
->toContain('data-md-align="center"')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" />'))->not->toContain('data-md-align')
->and((string) $this->blade('<x-navigation-rail mode="collapsed" align="middle" />'))->not->toContain('data-md-align');
// Only the destinations move, and `safe` gives the top back when there are too many.
expect(ComponentStylesheet::read('navigation-rail')->declarations("[data-md-navigation-rail][data-md-align='center'] [data-md-navigation-rail-destinations]"))
->toBe(['justify-content' => 'safe center']);
});
it('hides a collapsible or adaptive rail entirely when told to, and only those', function () {
$hiding = (string) $this->blade('<x-navigation-rail hide-when-collapsed />');
expect($hiding)
->toContain('data-md-hide-when-collapsed')
->toContain("x-data=\"materialNavigationRail('collapsible', true)\"")
// It needs the scrim and the focus trap a modal rail has: it comes back over the page.
->toContain('data-md-navigation-rail-scrim')
->toContain('x-trap.inert.noscroll="open"')
->and((string) $this->blade('<x-navigation-rail mode="adaptive" hide-when-collapsed />'))
->toContain("x-data=\"materialNavigationRail('adaptive', true)\"")
// A modal rail is already over the page; the two fixed modes mean what they say.
->and((string) $this->blade('<x-navigation-rail mode="modal" hide-when-collapsed />'))
->not->toContain('data-md-hide-when-collapsed')
->toContain("materialNavigationRail('modal', false)")
->and((string) $this->blade('<x-navigation-rail mode="expanded" hide-when-collapsed />'))
->not->toContain('data-md-hide-when-collapsed')
->and((string) $this->blade('<x-navigation-rail />'))->not->toContain('data-md-hide-when-collapsed');
$css = ComponentStylesheet::read('navigation-rail');
expect($css->declarations("[data-md-navigation-rail][data-md-hide-when-collapsed]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toBe(['width' => '0'])
// The two bands where the window, not the visitor, collapses a rail keep theirs.
->and($css->declarations("[data-md-navigation-rail='collapsible'][data-md-hide-when-collapsed]", ['@media (width < 600px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)'])
->and($css->declarations("[data-md-navigation-rail='adaptive'][data-md-hide-when-collapsed]", ['@media (600px <= width < 840px)']))
->toBe(['width' => 'var(--navigation-rail-collapsed-width)']);
});
/**
* "This rail is drawn collapsed" has seven conditions in all; navigation-rail.css merges the three
* that carry no media query into one `:where()` (provably the same match set: an element matches
* `X:where(A, B, C)` exactly when it matches `X:where(A)` or `X:where(B)` or `X:where(C)`) and keeps
* the four media-gated ones separate, since CSS cannot merge different `@media` queries into one
* rule. This pins all five physical branches — and so every one of the seven original conditions —
* for the rail's own width, the property every branch sets, and for `--md-navigation-rail-value`,
* which each branch turns from `expanded` to `collapsed` so an application's style query answers
* exactly when the rail's own shape does.
*/
it('holds a closing rail on screen for its exit rather than transitioning display', function () {
$css = ComponentStylesheet::read('navigation-rail');
// Firefox cannot transition `display`, so no rule tries: the view sets `data-md-closing` beside
// `data-md-open`, in the same flush, and navigation.js drops it once the exit has run.
$discrete = collect($css->rules())
->flatMap(fn (array $rule): array => array_values($rule['declarations']))
->filter(fn (string $value): bool => str_contains($value, 'allow-discrete'));
expect($discrete)->toBeEmpty()
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
->toContain('x-bind:data-md-closing="closing"')
->toContain('x-effect="settle(open)"')
// The panel that leaves the window stays drawn, and in its open geometry, while it slides.
->and($css->declarations("[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-closing='sheet'] > [data-md-navigation-rail-panel]"))
->toBe(['display' => 'flex'])
->and($css->declarations("[data-md-navigation-rail='adaptive'][data-md-closing='sheet'] > [data-md-navigation-rail-panel]", ['@media (width < 600px)']))
->toBe(['display' => 'flex'])
->and($css->declarations("[data-md-navigation-rail]:is([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel]"))
->toMatchArray(['position' => 'fixed', 'width' => 'var(--navigation-rail-expanded-width)'])
// The scrim stays drawn for its fade whichever way the rail closes.
->and($css->declarations('[data-md-navigation-rail][data-md-closing] > [data-md-navigation-rail-scrim]'))
->toBe(['display' => 'block']);
});
it('reproduces every branch of the old rail-collapsed variant for the rail\'s own width and its value', function () {
$css = ComponentStylesheet::read('navigation-rail');
$collapsed = ['width' => 'var(--navigation-rail-collapsed-width)', '--md-navigation-rail-value' => 'collapsed'];
expect($css->declarations('[data-md-navigation-rail]'))->toMatchArray(['--md-navigation-rail-value' => 'expanded']);
// Branches 1-3: a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed and not
// open; a `modal` rail not open.
expect($css->declarations("[data-md-navigation-rail]:where( [data-md-navigation-rail='collapsed'], [data-md-navigation-rail='collapsed'] *, [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *, [data-md-navigation-rail='modal']:not([data-md-open]), [data-md-navigation-rail='modal']:not([data-md-open]) * )"))
->toBe($collapsed)
// Branch 4: below `medium` (600px), a `collapsible` rail regardless of choice.
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *)", ['@media (width < 600px)']))
->toBe($collapsed)
// Branch 5: below `expanded` (840px), an `adaptive` rail not open.
->and($css->declarations("[data-md-navigation-rail]:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *)", ['@media (width < 840px)']))
->toBe($collapsed)
// Branch 6: at `expanded` (840-1199), an `adaptive` rail not open, chosen collapsed or no
// choice yet (`data-rail-auto`).
->and($css->declarations("[data-md-navigation-rail]:where( :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (840px <= width < 1200px)']))
->toBe($collapsed)
// Branch 7: from `large` (1200px), an `adaptive` rail not open, only when chosen collapsed —
// M3 starts it expanded there by default.
->and($css->declarations("[data-md-navigation-rail]:where( [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]), [data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) * )", ['@media (width >= 1200px)']))
->toBe($collapsed);
});
it('flattens a FAB nested in the rail header and morphs its label', function () {
$css = ComponentStylesheet::read('navigation-rail');
// A nested FAB rests at elevation 0, not the 3 a standalone one has. Now layered, like
// toolbar.css's own docked-FAB override: fab.css's `[data-md-fab]` is one attribute, so the
// doubled selector here (two) always wins without needing to sit outside the layer.
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-fab], [data-md-navigation-rail-header] [data-md-fab][data-md-fab]:hover'))
->toBe(['box-shadow' => 'none'])
// One FAB whose label springs shut, not two swapped by display.
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span'))
->toHaveKey('max-width', '256px');
expect($css->declarations('[data-md-navigation-rail-header] [data-md-fab] > span', ['@container style(--md-navigation-rail-value: collapsed)']))
->toBe(['max-width' => '0', 'opacity' => '0'])
->and($css->declarations('[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]', ['@container style(--md-navigation-rail-value: collapsed)']))
->toBe(['min-inline-size' => '0', 'aspect-ratio' => '1', 'gap' => '0']);
});
it('draws a destination in both shapes, with its count on the icon and at the end', function () {
$html = (string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
expect($html)
->toContain('<a data-md-navigation-rail-item')
->toContain('wire:navigate')
->toContain('aria-current="page"')
->toContain('data-md-navigation-indicator')
->toContain('<span data-md-navigation-label>Inbox</span>')
->toContain('data-md-navigation-badge-icon')
->toContain('data-md-navigation-badge-end')
->toContain('<span class="md-visually-hidden">, 12</span>')
->and(substr_count($html, '>12</span>'))->toBe(2)
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" />'))->toContain('<button data-md-navigation-rail-item');
});
it('groups destinations under a heading that names the group', function () {
$html = (string) $this->blade('<x-navigation-rail-section label="Labels"><x-navigation-rail-item label="Travel" icon="flight" link="/travel" /></x-navigation-rail-section>');
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
expect($html)->toContain('role="group"')
->and($labelledBy[1] ?? null)->not->toBeNull()
->and($html)->toContain("<p id=\"{$labelledBy[1]}\" data-md-navigation-rail-heading>Labels</p>");
});
it('draws the item\'s own state layer split between the item and its indicator', function () {
$item = ComponentStylesheet::read('navigation-rail-item');
expect($item->declarations('[data-md-navigation-rail-item]'))
->toHaveKeys(['--navigation-item-layer', '--navigation-indicator-layer'])
->and($item->declarations('[data-md-navigation-rail-item][data-md-navigation-rail-item]::before'))
->toBe(['background-color' => 'var(--md-sys-color-on-secondary-container)', 'opacity' => 'var(--navigation-item-layer)'])
->and($item->declarations('[data-md-navigation-rail-item][data-md-navigation-rail-item] [data-md-navigation-indicator]::before'))
->toBe(['background-color' => 'var(--md-sys-color-on-secondary-container)', 'opacity' => 'var(--navigation-indicator-layer)']);
});
/**
* navigation-rail.css alone still writes "drawn collapsed" branch for branch: the two rules that
* set the rail's own width and publish `--md-navigation-rail-value`, which the rail cannot query
* on itself (a `@container style()` reads an ancestor, never the element that publishes the
* property). This walks every copy of that seven-condition set and fails if any drifts from it, or
* has its collapsed shape in some window bands but not in all five.
*/
it('writes the rail\'s own "collapsed" condition with the same seven conditions, in every band', function () {
$closed = ':not([data-md-open])';
$bands = [
'always' => ["[data-md-navigation-rail='collapsed']", "[data-rail='collapsed'] [data-md-navigation-rail='collapsible']{$closed}", "[data-md-navigation-rail='modal']{$closed}"],
'@media (width < 600px)' => ["[data-md-navigation-rail='collapsible']{$closed}"],
'@media (width < 840px)' => ["[data-md-navigation-rail='adaptive']{$closed}"],
'@media (840px <= width < 1200px)' => [":is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']{$closed}"],
'@media (width >= 1200px)' => ["[data-rail='collapsed'] [data-md-navigation-rail='adaptive']{$closed}"],
];
// A condition matches the rail itself and everything in it.
$both = fn (array $conditions): array => array_merge(...array_map(fn (string $condition): array => [$condition, "{$condition} *"], $conditions));
$groups = [];
foreach (ComponentStylesheet::read('navigation-rail')->rules() as $rule) {
preg_match_all('/:where\(((?:[^()]++|\((?1)\))*)\)/', $rule['selector'], $wheres, PREG_SET_ORDER);
$wheres = array_filter($wheres, fn (array $where): bool => str_contains($where[1], 'data-md-navigation-rail='));
if ($wheres === []) {
continue;
}
$media = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@media')));
$band = match ($media) {
[] => 'always',
default => implode(' ', $media),
};
expect(array_key_exists($band, $bands))->toBeTrue("navigation-rail.css: `{$rule['selector']}` sits in {$band}, which is no band of the rail's");
foreach ($wheres as [$where, $conditions]) {
$written = array_map(trim(...), preg_split('/,(?![^()]*\))/', $conditions));
expect($written)->toBe($both($bands[$band]), "navigation-rail.css: `{$where}` in {$band} is not the rail's collapsed conditions for that band");
}
$key = str_replace(array_column($wheres, 0), '', $rule['selector']).' '.json_encode($rule['declarations']);
$groups[$key][] = $band;
}
expect($groups)->not->toBeEmpty();
foreach ($groups as $key => $found) {
expect(array_values(array_unique($found)))->toEqualCanonicalizing(array_keys($bands), "navigation-rail.css: {$key} is drawn collapsed in some bands only");
}
});
/**
* Everything that takes a collapsed shape and is not the rail element itself reads the value the
* rules above publish, one `@container style()` per rule, instead of a copy of the rail's own
* seven conditions — the reason `--md-navigation-rail-value` is published at all (UPGRADE.md,
* "From 2.0.0 to 2.1.0"). This walks every copy across navigation-rail.css,
* navigation-rail-item.css, navigation-rail-section.css and layout/scaffold.css, fails if any of
* them spells the query differently or wraps no declarations, and — outside navigation-rail.css,
* where the two rules above are the only rules allowed to — fails if the old `:where()` copy of
* the rail's conditions comes back.
*/
dataset('stylesheets with a rail descendant that draws a collapsed shape', [
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'../layout/scaffold',
]);
it('reads the rail\'s published value with the same style container query in every copy', function (string $name) {
$css = ComponentStylesheet::read($name);
$rules = $css->rules();
$matches = array_values(array_filter(
$rules,
fn (array $rule): bool => array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')) !== [],
));
expect($matches)->not->toBeEmpty();
foreach ($matches as $rule) {
$containers = array_values(array_filter($rule['at'], fn (string $at): bool => str_starts_with($at, '@container')));
expect($containers)->toBe(['@container style(--md-navigation-rail-value: collapsed)'], "{$name}.css: `{$rule['selector']}` wraps a different container query")
->and($rule['declarations'])->not->toBeEmpty("{$name}.css: `{$rule['selector']}` wraps an empty container query");
}
if ($name === 'navigation-rail') {
return;
}
foreach ($rules as $rule) {
expect(str_contains($rule['selector'], "data-md-navigation-rail='collapsed'"))
->toBeFalse("{$name}.css: `{$rule['selector']}` copies the rail's conditions instead of querying its value");
}
})->with('stylesheets with a rail descendant that draws a collapsed shape');