Slide the navigation rail and fade its scrim out in Firefox too

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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 20:24:43 +02:00
co-authored by Claude Opus 5
parent 7db522826b
commit 92e560bef6
5 changed files with 226 additions and 30 deletions
+87
View File
@@ -555,3 +555,90 @@ it('reads expanded while a modal rail is open over the page, and collapsed once
->assertScript('! '.railShows('expanded-only'))
->assertNoJavaScriptErrors();
});
/**
* Closes a rail with the given script and reports whether, sampling every few ms in the page
* itself, it was ever caught part-way: the whole close in one round trip, since a separate
* `script` and `assertScript` apiece already take as long as the 200ms exit (see ContainmentTest's
* scrim fades).
*/
function caughtMidClose(mixed $page, string $close, string $partWay): bool
{
return $page->script(<<<JS
(async () => {
{$close}
for (let i = 0; i < 60; i++) {
if ({$partWay}) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS) === true;
}
/**
* Opened and done opening: no transition left on the element. Firefox creates a transition on its
* next refresh tick, so straight after the change its computed style already reads the end value
* while the entry has not begun, and a close then would start from where the entry did.
*/
function settled(string $element): string
{
return "{$element}.getAnimations().length === 0";
}
/** Still drawn, and strictly between resting at the window's edge and gone past it. */
function slidingOut(string $panel): string
{
return "(getComputedStyle({$panel}).display !== 'none' && {$panel}.getBoundingClientRect().left < -2 && {$panel}.getBoundingClientRect().right > 2)";
}
it('slides the compact rail out on close, rather than making it vanish', function () {
$page = shellPage(599, 860)
->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled(RAIL_PANEL).' && Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0');
expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue();
$page->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
->assertScript('! '.RAIL.".hasAttribute('data-md-closing')")
->assertNoJavaScriptErrors();
});
it('slides a rail that hides when collapsed out on close, rather than making it vanish', function () {
$page = navigationExtrasProbe();
$rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$panel = "{$rail}.querySelector(':scope > [data-md-navigation-rail-panel]')";
$page->click('#hiding-rail [data-md-navigation-rail-menu]')
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0")
->click('#open-hiding-rail')
->assertScript("{$rail}.hasAttribute('data-md-open')")
->assertScript(settled($panel)." && Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256");
expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue();
$page->assertScript("getComputedStyle({$panel}).display === 'none'")
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0")
->assertNoJavaScriptErrors();
});
it('fades a modal rail\'s scrim out on close while the rail stands collapsed in the layout', function () {
$scrim = RAIL.".querySelector(':scope > [data-md-navigation-rail-scrim]')";
$page = railValueProbe('modal', 1000)
->click('#open-rail')
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled($scrim)." && getComputedStyle({$scrim}).opacity === '1'");
$fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)";
expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue();
// The panel goes straight back into the layout: only the scrim was over the page.
$page->assertScript("getComputedStyle({$scrim}).display === 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").position === 'sticky'")
->assertScript(railWidth(96))
->assertNoJavaScriptErrors();
});
@@ -63,10 +63,11 @@ it('takes M3\'s optional divider and turns the container fill off', function ()
$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]'))
// 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-navigation-rail-panel]'))
->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)']))
@@ -151,6 +152,31 @@ it('hides a collapsible or adaptive rail entirely when told to, and only those',
* 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'];