From 207270a23d76f3660e5aaec31168f80492e998ea Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Thu, 17 Sep 2026 06:06:23 +0200 Subject: [PATCH] Stick a standard side sheet under the top safe area and the scaffold's sticky app bar The standard sheet was sticky at `top: 0` and `100dvh` tall, so in an installed app with a status bar its head row and close button slid under the status bar once the page scrolled, where the modal sheet already starts at `--material-safe-top`. Inside `` a sticky `` in the `top` slot spans the content region, sheet and all, and covered it the same way, safe area or not. The sheet now sticks at `--drawer-top`, the top safe area, or under a scaffold's sticky bar that safe area plus the bar's 64px row (just the row, if taller than the safe area, for a medium or large bar, which collapses to it), and is as tall as the window below that. Every side sheet keeps the bottom safe area inside its bottom padding. A browser test scrolls a standard sheet in a scaffold with no bar, a small bar and a large bar at a 47px top and 34px bottom inset and measures where it sticks, its foot, its close button and its padding; it fails without the change. Co-Authored-By: Claude Opus 5 (1M context) --- UPGRADE.md | 5 ++ .../livewire-material-development/SKILL.md | 2 +- resources/css/components/drawer.css | 24 +++++++-- resources/views/components/drawer.blade.php | 4 +- tests/Browser/ContainmentTest.php | 49 +++++++++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 1225dfaf..78da365f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -49,6 +49,11 @@ sheet opened from a modal sheet but rendered elsewhere on the page was also hidden from screen readers by the sheet's `aria-hidden`, and Tab could not move inside the dialog; both work now. An application's own guard around a sheet's Escape can go. +- **A standard `` sticks under the top safe area** (`--material-safe-top`, else the + device's inset) instead of at the window's edge, so its head and close button no longer slide + under an installed app's status bar as the page scrolls; inside `` it also sticks + under a sticky `` in the `top` slot, which covered it. It is as tall as the window + below that, and every side sheet keeps the bottom safe area inside its bottom padding. ## From 2.0.0 to 2.1.0 diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 0b04a91b..4d9fd56a 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -585,7 +585,7 @@ An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `s Modal surfaces stack: a ``, menu, select or searchable choice opened inside or over a modal side sheet, bottom sheet or the modal rail — or one sheet opened from another, nested or rendered elsewhere on the page — takes its own Escape, so one press closes one layer, and the layer on top stays readable to a screen reader while the sheet under it keeps the rest of the page hidden. Render the dialog wherever suits the page; nothing needs to be moved inside the sheet. -`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (``, or a caller's own row). +`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, sticky and as tall as the window below the top safe area (and below ``'s sticky `top` app bar), an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (``, or a caller's own row). ### `` diff --git a/resources/css/components/drawer.css b/resources/css/components/drawer.css index 6fb1f3b1..7af556b8 100644 --- a/resources/css/components/drawer.css +++ b/resources/css/components/drawer.css @@ -89,6 +89,7 @@ flex-direction: column; overflow-y: auto; padding: var(--md-sys-measurement-space300); + padding-block-end: calc(var(--md-sys-measurement-space300) + var(--material-safe-bottom, env(safe-area-inset-bottom))); background-color: var(--md-sys-color-surface-container-low); color: var(--md-sys-color-on-surface); box-shadow: var(--md-sys-elevation-1); @@ -147,14 +148,18 @@ /* Co-planar from expanded: sticky beside the content, no scrim, no elevation, flat on `surface` with an outline-variant rule down the inner edge instead. It only fades here — - an in-flow box does not travel like an overlay does. */ + an in-flow box does not travel like an overlay does. It sticks under the top safe area + (`--drawer-top`), as tall as the window below it, and its padding keeps its foot clear of + the bottom one, so a status bar never covers its head once the page scrolls. */ @media (width >= 840px) { [data-md-drawer][data-md-standard] { + --drawer-top: var(--material-safe-top, env(safe-area-inset-top)); + position: sticky; - top: 0; + top: var(--drawer-top); display: flex; justify-content: flex-end; - height: 100dvh; + height: calc(100dvh - var(--drawer-top)); flex-shrink: 0; align-self: flex-start; overflow: clip; @@ -165,6 +170,19 @@ transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate); } + /* In , a sticky `` in the `top` slot spans the content region, sheet + and all, so the sheet sticks under it: a small, centre-aligned or search bar is its 64px + row (AppBarSmallTokens.ContainerHeight) under the safe area it pads itself with; a medium + or large bar collapses to that 64px row alone, which the safe area may still be taller + than. */ + [data-md-scaffold-content]:has(> [data-md-app-bar][data-md-sticky]) [data-md-drawer][data-md-standard] { + --drawer-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 64px); + } + + [data-md-scaffold-content]:has(> [data-md-app-bar][data-md-sticky]:is([data-md-variant='medium'], [data-md-variant='large'])) [data-md-drawer][data-md-standard] { + --drawer-top: max(var(--material-safe-top, env(safe-area-inset-top)), 64px); + } + /* A start sheet sits at the start edge, with the gap after it. */ [data-md-drawer][data-md-standard]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) { justify-content: flex-start; diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index 4f062730..a78a5a06 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -22,7 +22,9 @@ (material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp, coplanar"), `surface` rather than surface-container-low, no corner, and an outline-variant divider down its inner edge in place of the scrim. It sits in the page flow beside the - content, spans the window's height and scrolls on its own; below `expanded` (840px) it is the + content, sticks under the top safe area — and under ``'s sticky `top` app bar — + as tall as the window below that, with the bottom safe area inside its padding, and scrolls on + its own; below `expanded` (840px) it is the modal sheet — M3 calls the standard sheet "supplementary surfaces mainly for medium to expanded breakpoints" and the modal one "preferred at compact breakpoints", and the switch sits at `expanded` rather than `medium` because M3 also caps a side sheet at 400dp and a diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index c49a77b2..248a8bc8 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -783,6 +783,55 @@ it('draws a standard side sheet that starts open standing open, without growing $page->assertScript("{$root}.getAnimations({ subtree: true }).length > 0"); }); +it('sticks a standard side sheet under the top safe area and a scaffold\'s sticky app bar, clear of the bottom one', function () { + Route::middleware('web')->get('/standard-sheet-safe-probe/{bar}', fn (string $bar) => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + + @if ($bar !== 'none') + + @endif + + +

The plan

+ Filter the list +
+
+ @livewireScripts + + + BLADE, ['bar' => $bar])); + + $sheet = "document.getElementById('safe-sheet')"; + $top = fn (string $element): string => "Math.round({$element}.getBoundingClientRect().top)"; + + // Where the sheet sticks once the page has scrolled: below the status bar alone, below a small + // bar's 64px row and the safe area it pads itself with, and below the 64px a large bar + // collapses to; its foot at the window's, with the bottom inset inside its padding. + foreach (['none' => 47, 'small' => 111, 'large' => 64] as $bar => $expected) { + $page = visit("/standard-sheet-safe-probe/{$bar}")->resize(1000, 800)->waitForEvent('networkidle') + ->assertScript("typeof window.Alpine !== 'undefined' && {$sheet}.parentElement.hasAttribute('data-md-drawer-settled')"); + + $page->script('window.scrollTo(0, 600)'); + + $page->assertScript("{$top($sheet.'.parentElement')} === {$expected}") + ->assertScript("Math.round({$sheet}.getBoundingClientRect().bottom) === 800") + ->assertScript("{$top($sheet.".querySelector('[data-md-drawer-close]')")} >= {$expected}") + ->assertScript("getComputedStyle({$sheet}).paddingBottom === '58px'"); + + if ($bar !== 'none') { + $page->assertScript("Math.round(document.querySelector('[data-md-app-bar-row]').getBoundingClientRect().bottom) <= {$expected}"); + } + } +}); + it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () { $page = containment();