Add the navigation bar, navigation rail and app shell
M3 Expressive's flexible navigation bar, the collapsed, expanded and modal navigation rail with its state applied before the first paint, and an adaptive app shell composing them. The head script now restores the theme and rail attributes that wire:navigate strips from <html>. Completes Phase 8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
f4be9848e2
commit
ab692b66bb
@@ -0,0 +1,146 @@
|
||||
{{-- 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-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-navigation-rail-section :label="$group->first()['section']">
|
||||
@foreach ($group as $item)
|
||||
<x-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-navigation-rail-section>
|
||||
@else
|
||||
@foreach ($group as $item)
|
||||
<x-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-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-navigation-bar :label="$label">
|
||||
@foreach ($barItems as $item)
|
||||
<x-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-navigation-bar>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-toast />
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
{{-- One destination in an `<x-navigation-bar>`.
|
||||
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
|
||||
|
||||
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
|
||||
without one it is a button, for a destination a Livewire action switches to. `active` marks
|
||||
the current destination: `aria-current="page"`, the filled icon in on-secondary-container on
|
||||
the secondary-container indicator, and the label in secondary (on-secondary-container inside
|
||||
the medium bar's pill).
|
||||
|
||||
`badge` puts M3's badge on the icon: `true` for the small dot, a number or a few characters
|
||||
for the large one (999+ at most). Screen readers hear a count after the label (", 12");
|
||||
`badge-label` replaces it with words ("12 unread") and gives a dot something to say.
|
||||
|
||||
Values from NavigationBarTokens.kt and NavigationBarVerticalItemTokens.kt (androidx Compose
|
||||
Material 3, Apache-2.0); see `<x-navigation-bar>`. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'active' => false,
|
||||
'badge' => null,
|
||||
'badgeLabel' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
$dot = $badge === true;
|
||||
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
|
||||
$spoken = $badgeLabel ?? ($count ? $badge : null);
|
||||
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : 'button',
|
||||
'aria-current' => $active ? 'page' : null,
|
||||
'data-active' => $active ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} data-navigation-bar-item {{ $attributes }}>
|
||||
<span data-navigation-pill>
|
||||
<span data-navigation-indicator>
|
||||
<span class="relative inline-flex">
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$active" class="size-6" />
|
||||
@endif
|
||||
|
||||
@if ($dot)
|
||||
<x-badge floating />
|
||||
@elseif ($count)
|
||||
<x-badge :value="$badge" max="999" floating />
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-navigation-label>{{ $label ?? $slot }}</span>
|
||||
</span>
|
||||
|
||||
@if ($spoken !== null)
|
||||
<span class="sr-only">, {{ $spoken }}</span>
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,40 @@
|
||||
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
|
||||
compact window.
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
||||
<x-navigation-bar-item label="Starred" icon="star" link="/starred" />
|
||||
<x-navigation-bar-item label="Sent" icon="send" link="/sent" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
|
||||
The short bar, 64px tall in surface-container, with the bottom safe area added under it. On a
|
||||
bar narrower than 600px (M3's compact window) each item is the icon in a 56×32 indicator over a
|
||||
label-medium label, and the items share the width equally. From 600px (medium) icon and label
|
||||
sit side by side in a 40px pill and the items gather in the middle, with the padding Compose's
|
||||
Centered arrangement gives three to six items. Both follow the bar's own width (a container
|
||||
query), so a bar in a narrow column keeps the compact items.
|
||||
|
||||
It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
|
||||
and hides it where a rail takes over. `<x-app-shell>` does both, and lifts the snackbar and a
|
||||
`fab` button above it through `--material-bottom-bar`.
|
||||
|
||||
`label` names the landmark ("Main" by default).
|
||||
|
||||
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
|
||||
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
|
||||
NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt and
|
||||
ShortNavigationBar.kt, all under
|
||||
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
|
||||
— the styles are resources/css/components/navigation.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}>
|
||||
<div data-navigation-bar-items>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,74 @@
|
||||
{{-- One destination in an `<x-navigation-rail>`, drawn in whichever shape the rail has.
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
|
||||
|
||||
Expanded, it is a 56px full-width pill: the icon, the label-large label beside it and a count
|
||||
at its end. Collapsed, the icon sits in a 56×32 indicator over a label-medium label (two lines
|
||||
at most), with the count on the icon. The current destination (`active`) is
|
||||
`aria-current="page"` with the filled icon on secondary-container, its label in secondary when
|
||||
collapsed.
|
||||
|
||||
`link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
|
||||
without one it is a button. `badge`: `true` for M3's small dot on the icon, a number or a few
|
||||
characters for the large badge (999+ at most). Screen readers hear a count after the label
|
||||
(", 12"); `badge-label` replaces it with words ("12 unread") and gives a dot something to say.
|
||||
|
||||
Values from NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
|
||||
NavigationRailBaselineItemTokens.kt and NavigationRailColorTokens.kt (androidx Compose
|
||||
Material 3, Apache-2.0); see `<x-navigation-rail>`. Compose's expanded item hugs its label; the
|
||||
package draws the full-width pill, which leaves room for the count at the end. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'active' => false,
|
||||
'badge' => null,
|
||||
'badgeLabel' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
$dot = $badge === true;
|
||||
$count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
|
||||
$spoken = $badgeLabel ?? ($count ? $badge : null);
|
||||
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'type' => $isLink ? null : 'button',
|
||||
'aria-current' => $active ? 'page' : null,
|
||||
'data-active' => $active ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} data-navigation-rail-item {{ $attributes }}>
|
||||
<span data-navigation-indicator>
|
||||
<span class="relative inline-flex">
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" :filled="$active" class="size-6" />
|
||||
@endif
|
||||
|
||||
@if ($dot)
|
||||
<x-badge floating />
|
||||
@elseif ($count)
|
||||
<span class="hidden rail-collapsed:contents"><x-badge :value="$badge" max="999" floating /></span>
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-navigation-label>{{ $label ?? $slot }}</span>
|
||||
|
||||
@if ($count)
|
||||
<span class="flex shrink-0 rail-collapsed:hidden"><x-badge :value="$badge" max="999" /></span>
|
||||
@endif
|
||||
|
||||
@if ($spoken !== null)
|
||||
<span class="sr-only">, {{ $spoken }}</span>
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
@@ -0,0 +1,24 @@
|
||||
{{-- A group of destinations in an `<x-navigation-rail>` under a heading.
|
||||
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
|
||||
<x-navigation-rail-item label="Receipts" icon="label" link="/labels/receipts" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
The heading (title-small, on-surface-variant, in line with the icons) shows only while the
|
||||
rail is expanded, as M3 draws section headers; collapsed, a little space sets the group apart.
|
||||
It names the group for screen readers either way. --}}
|
||||
|
||||
@props([
|
||||
'label',
|
||||
])
|
||||
|
||||
@php
|
||||
$headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10);
|
||||
@endphp
|
||||
|
||||
<div role="group" aria-labelledby="{{ $headingId }}" data-navigation-rail-section {{ $attributes }}>
|
||||
<p id="{{ $headingId }}" data-navigation-rail-heading>{{ $label }}</p>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider
|
||||
window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
|
||||
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</div>
|
||||
|
||||
`mode` says what decides its width:
|
||||
- `collapsed` — always collapsed; `expanded` — always expanded.
|
||||
- `collapsible` (the default) — the visitor's choice: expanded until the menu button collapses
|
||||
it. The choice is `$store.rail`, remembered in localStorage and applied by <x-theme-script>
|
||||
before the first paint (<html data-rail>), so the rail never paints wide and snaps shut.
|
||||
- `modal` — collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
|
||||
opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
|
||||
leaving the page closes it (Compose's ModalWideNavigationRail).
|
||||
- `adaptive` — what `<x-app-shell>` uses: below `sm` nothing until `$store.rail.show()` slides
|
||||
it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
|
||||
|
||||
Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
|
||||
as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
|
||||
slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
|
||||
footer never scroll, so nothing in them is cut off by the scroller's edge.
|
||||
|
||||
Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
|
||||
the rail is drawn collapsed for whatever reason:
|
||||
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
|
||||
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
|
||||
Nothing that shows while collapsed may be wider than 96px.
|
||||
|
||||
Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
|
||||
between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
|
||||
`modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
|
||||
the top of the viewport, as tall as the viewport at most.
|
||||
|
||||
Values from androidx Compose Material 3 (Apache-2.0), androidx-main
|
||||
27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
|
||||
NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
|
||||
WideNavigationRail.kt under
|
||||
https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
|
||||
— surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
|
||||
the header and 40px under it, 4px between collapsed items. The styles are
|
||||
resources/css/components/navigation.css; the behaviour resources/js/navigation.js. --}}
|
||||
|
||||
@props([
|
||||
'mode' => 'collapsible',
|
||||
'label' => null,
|
||||
'width' => '16rem',
|
||||
'menu' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
|
||||
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
|
||||
$canOpen = in_array($mode, ['modal', 'adaptive'], true);
|
||||
$menu ??= $interactive;
|
||||
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-navigation-rail="{{ $mode }}"
|
||||
@if ($interactive)
|
||||
x-data="materialNavigationRail('{{ $mode }}')"
|
||||
x-bind:data-open="open"
|
||||
@endif
|
||||
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
|
||||
>
|
||||
@if ($canOpen)
|
||||
<div data-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
|
||||
@endif
|
||||
|
||||
<nav
|
||||
data-navigation-rail-panel
|
||||
aria-label="{{ $label ?? __('Main') }}"
|
||||
@if ($canOpen)
|
||||
x-trap.inert.noscroll="open"
|
||||
x-on:keydown.escape.window="open && $store.rail.hide()"
|
||||
@endif
|
||||
>
|
||||
@if ($menu || isset($brand) || isset($header))
|
||||
<div data-navigation-rail-header>
|
||||
@if ($menu || isset($brand))
|
||||
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'ps-5' => ! $menu])>
|
||||
@if ($menu)
|
||||
<button
|
||||
type="button"
|
||||
data-navigation-rail-menu
|
||||
aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}"
|
||||
aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}"
|
||||
x-on:click="menu()"
|
||||
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
|
||||
x-bind:aria-expanded="expanded.toString()"
|
||||
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
|
||||
>
|
||||
<span class="contents rail-collapsed:hidden"><x-icon name="menu_open" class="size-6" /></span>
|
||||
<span class="hidden rail-collapsed:contents"><x-icon name="menu" class="size-6" /></span>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@isset($brand)
|
||||
<div class="min-w-0 flex-1 rail-collapsed:hidden">{{ $brand }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@isset($header)
|
||||
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div data-navigation-rail-destinations>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@isset($footer)
|
||||
<div data-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
|
||||
@endisset
|
||||
</nav>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite.
|
||||
{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
|
||||
<head>, ahead of @vite.
|
||||
|
||||
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
|
||||
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
|
||||
@@ -10,15 +11,29 @@
|
||||
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
|
||||
visitor who never chose, so changing `theme.default` later reaches them too.
|
||||
|
||||
<html> survives wire:navigate, so this only has to run on a full load. --}}
|
||||
The rail rides along for the theme's reason: <html data-rail> is `expanded` or `collapsed`
|
||||
(`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
|
||||
rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
|
||||
rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
|
||||
changes it.
|
||||
|
||||
wire:navigate swaps the body, merges the head without running this again, and gives <html>
|
||||
the next page's attributes — which the server rendered without any of these, so Livewire
|
||||
removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
|
||||
before anything paints), so this only has to run on a full load. --}}
|
||||
|
||||
@php
|
||||
$theme = config('livewire-material.theme');
|
||||
$rail = config('livewire-material.rail');
|
||||
|
||||
$settings = [
|
||||
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
|
||||
'key' => $theme['storage_key'] ?? 'material-theme',
|
||||
'legacy' => array_values($theme['legacy_keys'] ?? []),
|
||||
'rail' => [
|
||||
'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
|
||||
'key' => $rail['storage_key'] ?? 'material-rail',
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@@ -28,8 +43,15 @@
|
||||
var media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
|
||||
var choice = settings.default;
|
||||
var rail = settings.rail.default;
|
||||
|
||||
try {
|
||||
var storedRail = localStorage.getItem(settings.rail.key);
|
||||
|
||||
if (storedRail === 'collapsed' || storedRail === 'expanded') {
|
||||
rail = storedRail;
|
||||
}
|
||||
|
||||
var stored = localStorage.getItem(settings.key);
|
||||
|
||||
if (valid(stored)) {
|
||||
@@ -58,8 +80,24 @@
|
||||
|
||||
root.setAttribute('data-theme-key', settings.key);
|
||||
root.setAttribute('data-theme-choice', choice);
|
||||
root.setAttribute('data-rail-key', settings.rail.key);
|
||||
root.setAttribute('data-rail', rail);
|
||||
apply();
|
||||
|
||||
media.addEventListener('change', apply);
|
||||
|
||||
document.addEventListener('livewire:navigating', function (event) {
|
||||
var kept = ['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
|
||||
return [name, root.getAttribute(name)];
|
||||
});
|
||||
|
||||
event.detail.onSwap(function () {
|
||||
kept.forEach(function (attribute) {
|
||||
if (attribute[1] !== null) {
|
||||
root.setAttribute(attribute[0], attribute[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})(@json($settings));
|
||||
</script>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
@include('livewire-material::showcase.sections.pickers')
|
||||
@include('livewire-material::showcase.sections.timepickers')
|
||||
@include('livewire-material::showcase.sections.bars')
|
||||
@include('livewire-material::showcase.sections.navigation')
|
||||
@include('livewire-material::showcase.sections.data')
|
||||
</main>
|
||||
@endsection
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||
|
||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Navigation bar' => <<<'BLADE'
|
||||
<div class="w-full space-y-6">
|
||||
<div class="max-w-[25rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">Compact: icon over label</p>
|
||||
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate :badge="true" badge-label="Needs attention" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-[50rem] space-y-2">
|
||||
<p class="type-label-lg text-on-surface-variant">From 600px wide: icon beside label, centred</p>
|
||||
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
|
||||
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
|
||||
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
|
||||
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapsed and expanded rails' => <<<'BLADE'
|
||||
<div class="flex w-full flex-wrap items-start gap-6">
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="collapsed" label="Collapsed example">
|
||||
<x-slot:header>
|
||||
<x-fab icon="edit" tooltip-right="Compose" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
|
||||
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
</div>
|
||||
|
||||
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail mode="expanded" label="Expanded example">
|
||||
<x-slot:header>
|
||||
<x-fab label="Compose" icon="edit" />
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-section label="Labels">
|
||||
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
|
||||
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Collapsible rail' => <<<'BLADE'
|
||||
<div class="flex h-[30rem] w-full overflow-hidden rounded-corner-lg border border-outline-variant">
|
||||
<x-navigation-rail label="Collapsible example">
|
||||
<x-slot:brand>
|
||||
<span class="block truncate type-title-lg">Mail</span>
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
|
||||
</x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
|
||||
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
|
||||
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
|
||||
</x-navigation-rail>
|
||||
|
||||
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
|
||||
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <code><x-app-shell></code>'s from <code>lg</code> too.
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="navigation" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Navigation</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-navigation-bar></code> and <code><x-navigation-bar-item></code>, <code><x-navigation-rail></code>, <code><x-navigation-rail-item></code> and <code><x-navigation-rail-section></code>,
|
||||
and <code><x-app-shell></code>, which puts them together: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
|
||||
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,87 @@
|
||||
{{-- The app shell, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the
|
||||
modal rail and wire:navigate between pages can be tried at any window width. --}}
|
||||
|
||||
@php
|
||||
$page ??= 'inbox';
|
||||
|
||||
$pages = [
|
||||
'inbox' => ['title' => 'Inbox', 'icon' => 'inbox', 'badge' => 12],
|
||||
'starred' => ['title' => 'Starred', 'icon' => 'star'],
|
||||
'sent' => ['title' => 'Sent', 'icon' => 'send'],
|
||||
'drafts' => ['title' => 'Drafts', 'icon' => 'draft', 'badge' => true],
|
||||
'travel' => ['title' => 'Travel', 'icon' => 'flight', 'section' => 'Labels', 'bar' => false],
|
||||
'receipts' => ['title' => 'Receipts', 'icon' => 'receipt_long', 'section' => 'Labels', 'bar' => false],
|
||||
];
|
||||
|
||||
$destinations = collect($pages)
|
||||
->map(fn (array $destination, string $key): array => $destination + [
|
||||
'url' => route('livewire-material.shell', $key === 'inbox' ? [] : ['page' => $key]),
|
||||
'active' => $key === $page,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$current = $pages[$page];
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
|
||||
<title>{{ $current['title'] }} · App shell · Livewire Material</title>
|
||||
|
||||
<x-theme-script />
|
||||
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface font-sans text-on-surface antialiased">
|
||||
<x-app-shell :destinations="$destinations">
|
||||
<x-slot:brand>
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
|
||||
</x-slot:brand>
|
||||
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
|
||||
</x-slot:rail-header>
|
||||
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
|
||||
</x-slot:rail-footer>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button icon="dark_mode" tooltip-right="Switch theme" x-data x-on:click="$store.theme.toggle()" />
|
||||
<x-button icon="help" tooltip-right="Help" x-on:click="materialToast('Help opens here')" />
|
||||
</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">
|
||||
<span class="sm:hidden"><x-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-button icon="search" tooltip="Search" />
|
||||
</header>
|
||||
</x-slot:top>
|
||||
|
||||
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6">
|
||||
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
|
||||
|
||||
<x-list segmented>
|
||||
@foreach (range(1, 14) as $index)
|
||||
<x-list-item :title="$current['title'].' item '.$index" description="A line of supporting text for this item" :icon="$current['icon']" trailing="{{ $index }}h" wire:key="item-{{ $index }}" />
|
||||
@endforeach
|
||||
</x-list>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
|
||||
<x-button label="Back to the showcase" link="{{ route('livewire-material.showcase') }}#navigation" no-wire-navigate />
|
||||
</div>
|
||||
</div>
|
||||
</x-app-shell>
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user