A rail that was open over the page left it on `transition: … display … allow-discrete`: the compact adaptive rail's slide-out, the slide-out of a rail that hides when collapsed, and every rail scrim's fade. Firefox does not transition `display`, even with `allow-discrete` (Chrome 117 and Safari 18 do), so there the panel and the scrim vanished on the first frame. Unlike the sheets' scrims these are drawn by attribute rules on `data-md-open`, not `x-show`, so Alpine had nothing to hold. navigation.js now marks a rail that closes `data-md-closing` — `sheet` when the panel leaves the window, `scrim` when it stands in the layout again — and drops it once the panel's `translate` and the scrim's `opacity` transitions have finished (at once under reduced motion, or when the rail opens again). navigation-rail.css keeps what is leaving displayed, and a sliding panel in its open geometry, while the attribute is set, and no longer transitions `display` anywhere, so Chrome and Safari do not hold a second time. The view sets it from `x-effect`, beside the `x-bind` that drops `data-md-open`, so both attributes change in one flush: a `$watch` a microtask later let a style read in between settle the scrim as already hidden. The collapsed branches and `--md-navigation-rail-value` are untouched. NavigationTest samples each exit part-way in the page (the compact slide, the hide-when-collapsed slide, a modal rail's scrim) and checks it ends hidden; all three fail on main in Firefox and pass in Chrome, Firefox and Safari. They wait for the entry to finish first: Firefox creates a transition on its next refresh tick, so straight after a change its computed style already reads the end value. NavigationRailTest pins the holds and that no rule transitions `display`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
328 lines
22 KiB
PHP
328 lines
22 KiB
PHP
<?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), 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 (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, 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('<x-navigation-rail mode="modal" />'))
|
||
->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 (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');
|