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. ->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, 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('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]", ['@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('')) ->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)']); }); /** * "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('')) ->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(''); 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)']); }); /** * 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');
Labels