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 FAB Account 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('FAB'); 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('blade(''); preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy); expect($html)->toContain('role="group"') ->and($labelledBy[1] ?? null)->not->toBeNull() ->and($html)->toContain("Labels"); }); 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');
Labels