blade('', ['mode' => $mode])) ->toContain("data-md-navigation-rail=\"{$expected}\"") ->toContain('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(''); 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('')) ->toContain('aria-label="Expand navigation"') ->toContain('data-md-navigation-rail-scrim') ->toContain('x-trap.inert.noscroll="open"') ->and((string) $this->blade('')) ->not->toContain('data-md-navigation-rail-menu') ->not->toContain('x-data') ->and((string) $this->blade(''))->not->toContain('data-md-navigation-rail-menu'); }); it('keeps the header and footer out of the scrolling destinations', function () { $html = (string) $this->blade(<<<'BLADE' Brand 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('')) ->toContain('data-md-divider') ->toContain('data-md-fill="false"') ->and((string) $this->blade('')) ->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(''); 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('Inbox') ->and((string) $this->blade('')) ->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('')) ->toContain('data-md-align="center"') ->and((string) $this->blade(''))->not->toContain('data-md-align') ->and((string) $this->blade(''))->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(''); 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('')) ->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('')) ->not->toContain('data-md-hide-when-collapsed') ->toContain("materialNavigationRail('modal', false)") ->and((string) $this->blade('')) ->not->toContain('data-md-hide-when-collapsed') ->and((string) $this->blade(''))->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]: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(''); expect($html) ->toContain('toContain('wire:navigate') ->toContain('aria-current="page"') ->toContain('data-md-navigation-indicator') ->toContain('Inbox') ->toContain('data-md-navigation-badge-icon') ->toContain('data-md-navigation-badge-end') ->toContain(', 12') ->and(substr_count($html, '>12'))->toBe(2) ->and((string) $this->blade(''))->not->toContain('wire:navigate') ->and((string) $this->blade(''))->toContain('