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 `<x-scaffold>` a sticky
`<x-app-bar>` 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) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 06:06:23 +02:00
co-authored by Claude Opus 5
parent 94a376a4fd
commit 207270a23d
5 changed files with 79 additions and 5 deletions
+5
View File
@@ -49,6 +49,11 @@
sheet opened from a modal sheet but rendered elsewhere on the page was also hidden from screen 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. 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. An application's own guard around a sheet's Escape can go.
- **A standard `<x-drawer>` 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 `<x-scaffold>` it also sticks
under a sticky `<x-app-bar>` 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 ## From 2.0.0 to 2.1.0
@@ -585,7 +585,7 @@ An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `s
Modal surfaces stack: a `<x-modal>`, 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. Modal surfaces stack: a `<x-modal>`, 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` (`<x-supporting-pane>`, 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 `<x-scaffold>`'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` (`<x-supporting-pane>`, or a caller's own row).
### `<x-bottom-sheet>` ### `<x-bottom-sheet>`
+21 -3
View File
@@ -89,6 +89,7 @@
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding: var(--md-sys-measurement-space300); 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); background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-1); 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 /* 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 `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) { @media (width >= 840px) {
[data-md-drawer][data-md-standard] { [data-md-drawer][data-md-standard] {
--drawer-top: var(--material-safe-top, env(safe-area-inset-top));
position: sticky; position: sticky;
top: 0; top: var(--drawer-top);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
height: 100dvh; height: calc(100dvh - var(--drawer-top));
flex-shrink: 0; flex-shrink: 0;
align-self: flex-start; align-self: flex-start;
overflow: clip; overflow: clip;
@@ -165,6 +170,19 @@
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate); transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
} }
/* In <x-scaffold>, a sticky `<x-app-bar>` 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. */ /* 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'])) { [data-md-drawer][data-md-standard]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
justify-content: flex-start; justify-content: flex-start;
+3 -1
View File
@@ -22,7 +22,9 @@
(material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp, (material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp,
coplanar"), `surface` rather than surface-container-low, no corner, and an outline-variant 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 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 `<x-scaffold>`'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 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 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 sits at `expanded` rather than `medium` because M3 also caps a side sheet at 400dp and a
+49
View File
@@ -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"); $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'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>:root { --material-safe-top: 47px; --material-safe-bottom: 34px; }</style>
</head>
<body>
<x-scaffold :destinations="[['title' => 'Plans', 'icon' => 'event', 'url' => '/standard-sheet-safe-probe/none', 'active' => true]]">
@if ($bar !== 'none')
<x-slot:top><x-app-bar title="Plan" :variant="$bar" /></x-slot:top>
@endif
<x-row align="stretch" gap="space300" x-data="{ open: true }">
<x-stack style="flex: 1 1 0%; min-width: 0;"><p style="block-size: 3000px">The plan</p></x-stack>
<x-drawer standard title="Filters" id="safe-sheet">Filter the list</x-drawer>
</x-row>
</x-scaffold>
@livewireScripts
</body>
</html>
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 () { it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment(); $page = containment();