Plan step 25 (navigation Missing): M3's "Expanded behavior | Hide when collapsed" was only implicit below `medium` in the adaptive rail. `<x-navigation-rail hide-when-collapsed>` (collapsible and adaptive rails) takes the rail out of the layout when it is collapsed; `$store.rail.show()` from an app-bar menu button brings it back expanded over a scrim, sliding in, and its own menu button docks it again. It does not reach the bands where the window, not the visitor, collapses a rail (below `medium` for collapsible, `medium` for adaptive), since M3's collapsed rail may never hide. Every `rail-collapsed` branch now stops while the rail is open. `<x-app-shell hide-rail-when-collapsed>` exposes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
172 lines
10 KiB
PHP
172 lines
10 KiB
PHP
<?php
|
|
|
|
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-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-navigation-rail-menu')
|
|
->toContain('aria-label="Collapse navigation"')
|
|
->toContain('aria-expanded="true"')
|
|
// The 40px menu button reaches M3's 48px target through the shared utility (N-01).
|
|
->toContain('touch-target')
|
|
->not->toContain('data-navigation-rail-scrim')
|
|
->not->toContain('x-trap')
|
|
->and((string) $this->blade('<x-navigation-rail mode="modal" />'))
|
|
->toContain('aria-label="Expand navigation"')
|
|
->toContain('data-navigation-rail-scrim')
|
|
->toContain('x-trap.inert.noscroll="open"')
|
|
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
|
|
->not->toContain('data-navigation-rail-menu')
|
|
->not->toContain('x-data')
|
|
->and((string) $this->blade('<x-navigation-rail mode="collapsible" :menu="false" />'))->not->toContain('data-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="20rem">
|
|
<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: 20rem')
|
|
->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-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-divider')
|
|
->toContain('data-fill="false"')
|
|
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
|
|
->not->toContain('data-divider')
|
|
->not->toContain('data-fill');
|
|
|
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
|
|
// Neither reaches a rail open over a scrim, which is a surface over the page (N-22).
|
|
->toContain('[data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {')
|
|
->toContain("[data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {")
|
|
// A collapsible rail is held to its collapsed width where M3 asks for a bar instead (N-24).
|
|
->toMatch("/\\[data-navigation-rail='collapsible'\\] \\{\\s+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-width="narrow"')
|
|
// A narrow rail expands to the same 16rem; `width` names one or the other, never both.
|
|
->toContain('--navigation-rail-width: 16rem')
|
|
// 40px menu button and 56px FAB centred in 80px rather than in 96.
|
|
->toContain('rail-collapsed:ps-5')
|
|
->toContain('rail-collapsed:px-3')
|
|
// The label is still rendered: it is what names the destination.
|
|
->toContain('<span data-navigation-label>Inbox</span>')
|
|
->and((string) $this->blade('<x-navigation-rail mode="collapsed" width="20rem" />'))
|
|
->not->toContain('data-width')
|
|
->toContain('--navigation-rail-width: 20rem');
|
|
|
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
|
|
// NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against the default 96dp.
|
|
->toMatch("/\\[data-navigation-rail\\]\\[data-width='narrow'\\] \\{\\s+--navigation-rail-collapsed-width: 5rem;/")
|
|
->toMatch('/--navigation-rail-collapsed-width: 6rem;/')
|
|
// Out of the drawing, not out of the page.
|
|
->toContain("[data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {");
|
|
});
|
|
|
|
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-align="center"')
|
|
->and((string) $this->blade('<x-navigation-rail mode="collapsed" />'))->not->toContain('data-align')
|
|
->and((string) $this->blade('<x-navigation-rail mode="collapsed" align="middle" />'))->not->toContain('data-align');
|
|
|
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
|
|
// Only the destinations move, and `safe` gives the top back when there are too many.
|
|
->toMatch("/\\[data-navigation-rail\\]\\[data-align='center'\\] \\[data-navigation-rail-destinations\\] \\{\\s+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-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-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-hide-when-collapsed')
|
|
->toContain("materialNavigationRail('modal', false)")
|
|
->and((string) $this->blade('<x-navigation-rail mode="expanded" hide-when-collapsed />'))
|
|
->not->toContain('data-hide-when-collapsed')
|
|
->and((string) $this->blade('<x-navigation-rail />'))->not->toContain('data-hide-when-collapsed');
|
|
|
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
|
|
->toMatch('/\[data-navigation-rail\]\[data-hide-when-collapsed\] \{\s+@variant rail-collapsed \{\s+width: 0;/')
|
|
// The two bands where the window, not the visitor, collapses a rail keep theirs.
|
|
->toMatch("/@media \\(width < 37\\.5rem\\) \\{\\s+\\[data-navigation-rail='collapsible'\\]\\[data-hide-when-collapsed\\]/")
|
|
->toMatch("/@media \\(37\\.5rem <= width < 52\\.5rem\\) \\{\\s+\\[data-navigation-rail='adaptive'\\]\\[data-hide-when-collapsed\\]/")
|
|
// Every collapsed branch stops while the rail is open, or what comes back would be narrow.
|
|
->and(substr_count(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'), ':not([data-open])'))
|
|
->toBeGreaterThanOrEqual(12);
|
|
});
|
|
|
|
it('flattens a FAB nested in the rail header and morphs its label', function () {
|
|
// Both rules hang off `data-fab` on <x-fab>'s root, and both are unlayered, because what they
|
|
// beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities.
|
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css'))
|
|
// A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03).
|
|
->toContain('[data-navigation-rail-header] [data-fab],')
|
|
->toContain('[data-navigation-rail-header] [data-fab]:hover {')
|
|
// One FAB whose label springs shut, not two swapped by display (N-23).
|
|
->toContain('[data-navigation-rail-header] [data-fab] > span {')
|
|
->toMatch('/@variant rail-collapsed \{\s+min-width: 0;\s+aspect-ratio: 1;\s+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-navigation-rail-item')
|
|
->toContain('wire:navigate')
|
|
->toContain('aria-current="page"')
|
|
->toContain('data-navigation-indicator')
|
|
->toContain('<span data-navigation-label>Inbox</span>')
|
|
->toContain('hidden rail-collapsed:contents')
|
|
->toContain('rail-collapsed:hidden')
|
|
->toContain('<span class="sr-only">, 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-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-navigation-rail-heading>Labels</p>");
|
|
});
|