Let an application replace the safe-area insets and dock on the bottom bar
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
co-authored by
Claude Opus 5
parent
bcf0d4f4e3
commit
53bb000425
@@ -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 `<meta name="theme-color">` without a `media` attribute to the resolved theme's `surface` — of the profile in `<html data-scheme>` — before the first paint, adding one to `<head>` 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 `<x-theme-script />` (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 `<html>` 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 `<x-app-shell>` (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.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
|
||||
The page is `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
|
||||
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) 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(),
|
||||
])
|
||||
>
|
||||
<a
|
||||
href="#content"
|
||||
data-skip-link
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
|
||||
>{{ __('Skip to content') }}</a>
|
||||
|
||||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
|
||||
|
||||
@@ -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'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -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'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
>
|
||||
<div @class([
|
||||
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $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)
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</x-slot:actions>
|
||||
|
||||
<x-slot:top>
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[var(--material-safe-top,env(safe-area-inset-top))] sm:px-4">
|
||||
<span class="sm:hidden"><x-livewire-material::button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
|
||||
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
|
||||
<x-livewire-material::button icon="search" tooltip="Search" />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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('<x-app-shell :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['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('/(?<!,)env\(safe-area-inset/');
|
||||
});
|
||||
|
||||
it('places each slot once', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-app-shell :destinations="$destinations">
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user