Package views now write <x-livewire-material::button>, so a configured prefix (which Blade spells <x-m::button>) and an application component of the same name can no longer break or shadow them. The showcase rewrites its examples to the configured prefix. Adds the README, and waits for the adaptive rail to hear a resize before the navigation tests press its menu button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
147 lines
7.4 KiB
PHP
147 lines
7.4 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`.
|
|
|
|
`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+env(safe-area-inset-bottom))]' => $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"
|
|
>{{ __('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>
|