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:
Andreas Reinhold / reini
2026-09-13 08:54:51 +02:00
co-authored by Claude Opus 5
parent f4be9848e2
commit ab692b66bb
24 changed files with 1995 additions and 5 deletions
@@ -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>