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
150 lines
7.7 KiB
PHP
150 lines
7.7 KiB
PHP
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
|
|
|
|
<x-app-shell :destinations="[
|
|
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
|
|
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
|
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
|
]">
|
|
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
|
<x-slot:top>…the page's app bar…</x-slot:top>
|
|
|
|
…the page…
|
|
</x-app-shell>
|
|
|
|
- Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
|
|
Everything else is in the modal rail, which slides in when something calls
|
|
`$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
|
|
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
|
|
- `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
|
|
- From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
|
|
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
|
|
|
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
|
`active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
|
|
`section` (a heading the destination is grouped under in the rail; only an expanded rail shows
|
|
it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
|
|
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
|
|
|
Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
|
|
`rail-header` (under it: a FAB — see `<x-navigation-rail>` for its two shapes), `rail-footer`
|
|
(at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
|
|
the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
|
|
bar, above the page at every width) and the page itself. The rail is one element at every
|
|
width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
|
|
landmarks ("Main"); `rail-width` is the expanded rail's width.
|
|
|
|
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`:
|
|
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
|
|
container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
|
|
`lg` only, so a wide window never clips what overhangs on purpose.
|
|
|
|
Nothing application-specific belongs in here: an app's destinations and chrome come in through
|
|
the props and slots. --}}
|
|
|
|
@props([
|
|
'destinations' => [],
|
|
'label' => null,
|
|
'railWidth' => '16rem',
|
|
])
|
|
|
|
@php
|
|
$label ??= __('Main');
|
|
$current = request()->url();
|
|
|
|
$items = collect($destinations)
|
|
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
|
->map(fn (array $item): array => [
|
|
'title' => (string) $item['title'],
|
|
'icon' => $item['icon'] ?? null,
|
|
'url' => $item['url'] ?? null,
|
|
'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
|
|
'badge' => $item['badge'] ?? null,
|
|
'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
|
|
'bar' => ($item['bar'] ?? true) !== false,
|
|
'navigate' => ($item['navigate'] ?? true) !== false,
|
|
])
|
|
->values();
|
|
|
|
$barItems = $items->where('bar', true)->values();
|
|
|
|
// Consecutive destinations under the same heading form one group, in the order given.
|
|
$groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
|
|
@endphp
|
|
|
|
<div
|
|
data-app-shell
|
|
@class([
|
|
'min-h-dvh bg-surface text-on-surface sm:flex',
|
|
'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(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">
|
|
@isset($brand)
|
|
<x-slot:brand>{{ $brand }}</x-slot:brand>
|
|
@endisset
|
|
|
|
@isset($railHeader)
|
|
<x-slot:header>{{ $railHeader }}</x-slot:header>
|
|
@endisset
|
|
|
|
@foreach ($groups as $group)
|
|
@if ($group->first()['section'] !== null)
|
|
<x-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
|
@foreach ($group as $item)
|
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
|
@endforeach
|
|
</x-livewire-material::navigation-rail-section>
|
|
@else
|
|
@foreach ($group as $item)
|
|
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
|
@endforeach
|
|
@endif
|
|
@endforeach
|
|
|
|
@if (isset($railFooter) || isset($actions))
|
|
<x-slot:footer>
|
|
{{ $railFooter ?? '' }}
|
|
|
|
@isset($actions)
|
|
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
|
|
{{ $actions }}
|
|
</div>
|
|
@endisset
|
|
</x-slot:footer>
|
|
@endif
|
|
</x-livewire-material::navigation-rail>
|
|
|
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
{{ $top ?? '' }}
|
|
|
|
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
|
|
{{ $slot }}
|
|
</main>
|
|
</div>
|
|
|
|
@if ($barItems->isNotEmpty())
|
|
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
|
<x-livewire-material::navigation-bar :label="$label">
|
|
@foreach ($barItems as $item)
|
|
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
|
|
@endforeach
|
|
</x-livewire-material::navigation-bar>
|
|
</div>
|
|
@endif
|
|
|
|
<x-livewire-material::toast />
|
|
</div>
|