Files
livewire-material/tests/Feature/Components/NavigationRailTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 eda16ba727 Make the rail header FAB's collapsed shape win by specificity
Plan step 37 review. The collapsed rail FAB's gap and minimum width tied
with fab.css's extended-size and collapse-on-scroll rules at three
attributes and won only by coming after them. A deduplicating bundle
keeps that order (navigation-rail.css imports fab.css), but a build that
repeats a shared stylesheet need not: all.css imports fab.css again after
the scaffold has reached the rail, and the Workbench's Tailwind entry
inlines every occurrence. Doubling [data-md-extended] settles it by
specificity, as the flat shadow already was, so the order no longer
matters.

A scan of every component and layout stylesheet pair for same-layer,
same-property ties on a shared hook found no other override that relies
on order: the remaining ties are on mutually exclusive elements or sit
behind an import edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:08:39 +02:00

298 lines
20 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 (N-01).
->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 (N-22).
expect($css->declarations('[data-md-navigation-rail][data-md-divider]:not([data-md-open]) > [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-navigation-rail-panel]'))
->toBe(['background-color' => 'transparent'])
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
->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]: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]) * )"))
->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)']);
});
/**
* The Tailwind-era `rail-collapsed:` custom variant had seven branches for "this rail is drawn
* collapsed"; 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.
*/
it('reproduces every branch of the old rail-collapsed variant for the rail\'s own width', function () {
$css = ComponentStylesheet::read('navigation-rail');
// 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(['width' => 'var(--navigation-rail-collapsed-width)'])
// 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(['width' => 'var(--navigation-rail-collapsed-width)'])
// 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(['width' => 'var(--navigation-rail-collapsed-width)'])
// 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(['width' => 'var(--navigation-rail-collapsed-width)'])
// 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(['width' => 'var(--navigation-rail-collapsed-width)']);
});
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 (N-03). 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 (N-23).
->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: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(['max-width' => '0', 'opacity' => '0'])
->and($css->declarations("[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended]: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(['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)']);
});
/**
* Plain CSS has no variant to name "drawn collapsed" once, so every rule with a collapsed shape
* writes the conditions out: navigation-rail.css, navigation-rail-item.css,
* navigation-rail-section.css, and layout/scaffold.css for the actions row inside the rail's footer.
* This walks every copy in those files — not one pinned rule — and fails if any drifts from the
* seven conditions the Tailwind-era `rail-collapsed:` variant had, or if a rule has its collapsed
* shape in some window bands but not in all five.
*/
dataset('stylesheets that draw a collapsed rail', [
'navigation-rail',
'navigation-rail-item',
'navigation-rail-section',
'../layout/scaffold',
]);
it('writes "collapsed" with the same seven conditions in every copy, in every band', function (string $name) {
$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; the scaffold's actions row can only
// ever be inside a rail, so its copy keeps the descendant half alone.
$both = fn (array $conditions): array => array_merge(...array_map(fn (string $condition): array => [$condition, "{$condition} *"], $conditions));
$inside = fn (array $conditions): array => array_map(fn (string $condition): string => "{$condition} *", $conditions);
$groups = [];
foreach (ComponentStylesheet::read($name)->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',
['@media (width >= 840px)', '@media (width < 1200px)'] => '@media (840px <= width < 1200px)',
default => implode(' ', $media),
};
expect(array_key_exists($band, $bands))->toBeTrue("{$name}.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(in_array($written, [$both($bands[$band]), $inside($bands[$band])], true))
->toBeTrue("{$name}.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), "{$name}.css: {$key} is drawn collapsed in some bands only");
}
})->with('stylesheets that draw a collapsed rail');