From 53bb00042507ac89423d7db6b7868121ec0bdc3b Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 18:03:20 +0200 Subject: [PATCH] Let an application replace the safe-area insets and dock on the bottom bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components read the device's safe-area insets straight from env(), which a browser test cannot fake and an application drawing its own status strip cannot extend. Every inset the package reads, in its CSS and its views, is now var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)): unchanged while the variables are unset. The app shell's --material-bottom-bar also adds var(--material-bottom-extra, 0px), so an application that docks something on the phone's navigation bar (an offline banner) sets its height once and the snackbar, a fab button and the page's bottom padding clear it. A guard test fails on any env(safe-area-inset-*) outside such a variable, and AppShellTest's assertion on the bar height's class follows the new value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 8 +++++- resources/css/components/app-bar.css | 2 +- resources/css/components/navigation.css | 10 +++---- resources/css/components/search.css | 6 ++-- resources/css/components/toolbar.css | 8 +++--- .../views/components/app-shell.blade.php | 9 ++++-- .../views/components/bottom-sheet.blade.php | 2 +- resources/views/components/drawer.blade.php | 6 ++-- resources/views/components/modal.blade.php | 2 +- resources/views/showcase/shell.blade.php | 2 +- tests/Browser/NavigationTest.php | 28 +++++++++++++++++++ tests/Feature/Components/AppShellTest.php | 11 +++++++- tests/Feature/TokensTest.php | 21 ++++++++++++++ 13 files changed, 91 insertions(+), 24 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 58445bb7..141e217f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -95,6 +95,12 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex `theme.meta` (default `false`) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the `content` of every `` without a `media` attribute to the resolved theme's `surface` — of the profile in `` — before the first paint, adding one to `` when there is none. It follows every later change of `data-theme` or `data-scheme` (`$store.theme.set()`/`toggle()`, an OS change while `system`, `previewScheme()`), and paints the next page's meta after `wire:navigate`. A theme-color meta the layout renders itself goes before `` (after it, the script has already added one, and the page ends up with two), or is left out. A `media="(prefers-color-scheme: …)"` pair follows the OS instead of the visitor's choice: drop it when turning this on. +## Safe areas + +Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through `var(--material-safe-top|bottom|left|right, env(safe-area-inset-…))`. The layout needs `viewport-fit=cover` in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on `` or any ancestor: a browser test fakes a notch with `document.documentElement.style.setProperty('--material-safe-top', '47px')`, and an app that draws its own status strip adds its height. + +`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `` (an offline banner): the shell adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at `bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`, below `sm` only. + ## Toasts ```php @@ -650,7 +656,7 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co - `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). - Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`). - The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`). -- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it. +- Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas. - The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside. ### ``, `` diff --git a/resources/css/components/app-bar.css b/resources/css/components/app-bar.css index 9de2c3d0..6afd0149 100644 --- a/resources/css/components/app-bar.css +++ b/resources/css/components/app-bar.css @@ -24,7 +24,7 @@ z-index: 20; display: block; min-height: var(--app-bar-height); - padding-top: env(safe-area-inset-top); + padding-top: var(--material-safe-top, env(safe-area-inset-top)); background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); transition: background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default); diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 3c83990c..1ca4dc5a 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -66,8 +66,8 @@ [data-navigation-bar] { container-type: inline-size; - padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); - padding-bottom: env(safe-area-inset-bottom); + padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right)); + padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom)); background-color: var(--md-sys-color-surface-container); color: var(--md-sys-color-on-surface-variant); } @@ -206,7 +206,7 @@ at once when the rail expands, while the width is still growing; the clip keeps it from spilling over the page for those frames. */ overflow-x: clip; - padding-bottom: env(safe-area-inset-bottom); + padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom)); background-color: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); transition: @@ -307,12 +307,12 @@ flex-direction: column; align-items: flex-start; gap: 0.5rem; - padding-top: calc(env(safe-area-inset-top) + 2.75rem); + padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem); padding-bottom: 2rem; } [data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child { - padding-top: calc(env(safe-area-inset-top) + 2.75rem); + padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem); } [data-navigation-rail-destinations] { diff --git a/resources/css/components/search.css b/resources/css/components/search.css index e87983b3..167c36f3 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -165,8 +165,8 @@ [data-search][data-full-screen] [data-search-bar] { position: fixed; inset: 0 0 auto; - height: calc(4.5rem + env(safe-area-inset-top)); - padding-top: env(safe-area-inset-top); + height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top))); + padding-top: var(--material-safe-top, env(safe-area-inset-top)); padding-inline: 0.25rem; border-radius: 0; } @@ -175,7 +175,7 @@ position: fixed; inset: 0; max-height: none; - padding-top: calc(4.5rem + env(safe-area-inset-top)); + padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top))); border-radius: 0; box-shadow: none; } diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index 0265806f..512e5c42 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -19,11 +19,11 @@ [data-toolbar][data-variant="docked"] { width: 100%; - min-height: calc(4rem + env(safe-area-inset-bottom)); + min-height: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom))); justify-content: center; column-gap: clamp(0.25rem, 4vw, 2rem); padding-inline: 1rem; - padding-bottom: env(safe-area-inset-bottom); + padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom)); background-color: var(--md-sys-color-surface-container); color: var(--md-sys-color-on-surface-variant); } @@ -74,7 +74,7 @@ /* Placed over the page: centred above the bottom edge, or centred against the end edge. */ [data-toolbar-place="bottom"] { position: fixed; - bottom: calc(1rem + env(safe-area-inset-bottom)); + bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom))); left: 50%; z-index: 30; translate: -50% 0; @@ -83,7 +83,7 @@ [data-toolbar-place="end"] { position: fixed; top: 50%; - inset-inline-end: calc(1rem + env(safe-area-inset-right)); + inset-inline-end: calc(1rem + var(--material-safe-right, env(safe-area-inset-right))); z-index: 30; translate: 0 -50%; } diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index 5788a7c6..eb348ca9 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -35,7 +35,10 @@ The page is `
` with `wire:transition.navigate`, behind a skip link that is the first thing a keyboard reaches. The snackbar host (``) is part of the shell; - below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`. + below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`: + the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and + `--material-bottom-extra` (0px unless the application docks something, an offline banner, on + top of the bar). `max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays `clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll @@ -79,13 +82,13 @@ data-app-shell @class([ 'min-h-dvh bg-surface text-on-surface sm:flex', - 'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(), + 'max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty(), ]) > {{ __('Skip to content') }} diff --git a/resources/views/components/bottom-sheet.blade.php b/resources/views/components/bottom-sheet.blade.php index c160b421..c0847572 100644 --- a/resources/views/components/bottom-sheet.blade.php +++ b/resources/views/components/bottom-sheet.blade.php @@ -52,7 +52,7 @@ @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif style="--sheet-max-height: {{ $height }}" {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([ - 'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1', + 'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1', $attributes->get('class'), ]) }} > diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index 33242c77..0a66c90b 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -59,7 +59,7 @@ data-sheet="{{ $id }}" @if ($pane) x-bind:class="! open && 'xl:hidden'" - class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start" + class="xl:sticky xl:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] xl:shrink-0 xl:self-start" data-pane @endif > @@ -90,11 +90,11 @@ @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}" {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([ - 'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1', + 'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1', 'end-0 sm:rounded-s-corner-lg' => ! $start, 'start-0 sm:rounded-e-corner-lg' => $start, 'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]', - 'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane, + 'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane, $attributes->get('class'), ]) }} > diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index f77b271d..1295cc08 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -64,7 +64,7 @@ >
$fullscreen, + 'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen, $boxClass, ])> @if ($fullscreen) diff --git a/resources/views/showcase/shell.blade.php b/resources/views/showcase/shell.blade.php index fba59b7c..8bfe722b 100644 --- a/resources/views/showcase/shell.blade.php +++ b/resources/views/showcase/shell.blade.php @@ -59,7 +59,7 @@ -
+

{{ $current['title'] }}

diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php index 8c313179..fdbfd3e8 100644 --- a/tests/Browser/NavigationTest.php +++ b/tests/Browser/NavigationTest.php @@ -176,3 +176,31 @@ it('skips to the content', function () { ->assertScript("location.hash === '#content'") ->assertScript("document.activeElement === document.getElementById('content')"); }); + +it('moves an app bar\'s content under a safe area an application sets', function () { + $row = "Math.round(document.querySelector('[data-app-bar] [data-app-bar-row]').getBoundingClientRect().top)"; + + $page = navigationReady(visit('/material')); + $top = (int) $page->script($row); + + $page->script("document.documentElement.style.setProperty('--material-safe-top', '47px')"); + + $page->assertScript("{$row} === ".($top + 47)) + ->assertNoJavaScriptErrors(); +}); + +it('lifts the snackbar and the page above something docked on the phone\'s bar', function () { + $snackbarBottom = "Math.round(parseFloat(getComputedStyle(document.querySelector('[x-data=\"materialSnackbar\"]')).bottom))"; + $contentPadding = "Math.round(parseFloat(getComputedStyle(document.getElementById('content')).paddingBottom))"; + + $page = shellPage(400, 860); + $snackbar = (int) $page->script($snackbarBottom); + $content = (int) $page->script($contentPadding); + + expect($content)->toBe(64)->and($snackbar)->toBe(80); + + $page->script("document.documentElement.style.setProperty('--material-bottom-extra', '40px')"); + + $page->assertScript("{$snackbarBottom} === 120") + ->assertScript("{$contentPadding} === 104"); +}); diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index 5c6f9742..0d3630e8 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -56,12 +56,21 @@ it('marks the destination at the current URL when none says it is active', funct it('lifts the snackbar above the bar only when there is a bar', function () { expect((string) $this->blade('', ['destinations' => shellDestinations()])) - ->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]') + ->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]') ->and((string) $this->blade('', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]])) ->not->toContain('data-app-shell-bar') ->not->toContain('--material-bottom-bar:'); }); +it('reads the safe area and anything docked on the bar through variables an application can set', function () { + $html = (string) $this->blade('', ['destinations' => shellDestinations()]); + + expect($html) + ->toContain('+var(--material-bottom-extra,0px))]') + ->toContain('focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)]') + ->not->toMatch('/(?blade(<<<'BLADE' diff --git a/tests/Feature/TokensTest.php b/tests/Feature/TokensTest.php index 8543acfb..4e0c2ade 100644 --- a/tests/Feature/TokensTest.php +++ b/tests/Feature/TokensTest.php @@ -64,6 +64,27 @@ it('leaves the choice of theme to the head script, never to a media query', func } }); +it('reads every safe-area inset through a variable that can replace it', function () { + $files = collect([packageCss(), __DIR__.'/../../resources/views', __DIR__.'/../../resources/js']) + ->flatMap(fn (string $path): array => File::allFiles($path)); + + $insets = 0; + + foreach ($files as $file) { + preg_match_all('/env\(safe-area-inset-(top|bottom|left|right)\)/', $file->getContents(), $matches, PREG_OFFSET_CAPTURE); + + foreach ($matches[1] as [$side, $offset]) { + $insets++; + + expect(substr($file->getContents(), 0, $offset - strlen('env(safe-area-inset-'))) + ->toMatch("/var\\(--material-safe-{$side},\\s?$/", "{$file->getRelativePathname()} reads safe-area-inset-{$side} directly"); + } + } + + expect($insets)->toBeGreaterThan(10) + ->and(File::get(packageCss('components/app-bar.css')))->toContain('padding-top: var(--material-safe-top, env(safe-area-inset-top));'); +}); + it('makes every motion token instant under reduced motion', function () { $motion = File::get(packageCss('tokens/motion.css')); $reduced = Str::of($motion)->after('prefers-reduced-motion: reduce')->toString();