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:
Andreas Reinhold / reini
2026-09-13 18:03:20 +02:00
co-authored by Claude Opus 5
parent bcf0d4f4e3
commit 53bb000425
13 changed files with 91 additions and 24 deletions
+28
View File
@@ -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");
});
+10 -1
View File
@@ -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">
+21
View File
@@ -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();