From fdb13313f7a97334d6a9ac1ee5112c783f5db4c9 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 17:52:33 +0200 Subject: [PATCH] Keep the current section lit while a Livewire component updates and mark an item current when its URL is the request's. While a Livewire component on the page re-renders, the request is Livewire's update endpoint, so no section or destination stayed lit after the morph. Both now compare with the page's URL from Livewire::originalUrl() during a Livewire update request; a normal request compares exactly as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 4 +-- .../views/components/app-shell.blade.php | 5 ++-- .../views/components/section-nav.blade.php | 8 ++++-- tests/Feature/Components/AppShellTest.php | 25 +++++++++++++++++++ tests/Feature/Components/TabsTest.php | 25 +++++++++++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 2103aafb..0c730a80 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -684,7 +684,7 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co ``` -- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). +- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the page's, also during a Livewire update request), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`). - Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`). - The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`). - Below `sm` the shell sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, a `fab` button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas. @@ -773,7 +773,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip ### `` -Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`. +Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`. ### `` diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index eb348ca9..66620b0c 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -20,7 +20,8 @@ remembered and applied before the first paint (`$store.rail`, ). `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), + `active` (by default: the URL is the page's; during a Livewire update request, the page the + component was rendered on rather than the update endpoint), `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`). @@ -56,7 +57,7 @@ @php $label ??= __('Main'); - $current = request()->url(); + $current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url(); $items = collect($destinations) ->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null)) diff --git a/resources/views/components/section-nav.blade.php b/resources/views/components/section-nav.blade.php index b5c83f65..0ecf1362 100644 --- a/resources/views/components/section-nav.blade.php +++ b/resources/views/components/section-nav.blade.php @@ -2,11 +2,14 @@ single destination in the app's own navigation. `items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge` - (an item is current when `active` is true, or when its `url` is the request's). From `sm` they + (an item is current when `active` is true, or when its `url` is the page's). From `sm` they are M3's secondary tabs as links, the current one underlined; below `sm`, where a row of them never fits, a button naming the current section opens a menu of all of them. The same list is rendered for both, and CSS shows one. + The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the + request is Livewire's update endpoint, and comparing with it left no section lit. + A row too long for its column wraps onto a grid rather than scrolling: below `xl` five or six sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless @@ -20,7 +23,8 @@ @php $label ??= __('Sections'); - $isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && url()->current() === url($item['url'])); + $page = \Livewire\Livewire::originalUrl(); + $isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url'])); $current = collect($items)->first($isCurrent) ?? ($items[0] ?? null); $layout = match (true) { diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index 0d3630e8..fb313f1f 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -2,6 +2,8 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Route; +use Livewire\Component; +use Livewire\Livewire; function shellDestinations(): array { @@ -54,6 +56,29 @@ it('marks the destination at the current URL when none says it is active', funct ->and(substr_count($html, 'aria-current="page"'))->toBe(2); }); +it('keeps the destination at the page\'s URL current while a Livewire component on it updates', function () { + Livewire::component('app-shell-probe', new class extends Component + { + public string $page = ''; + + public function mount(): void + { + $this->page = url()->current(); + } + + public function render(): string + { + return '
'; + } + }); + + $probe = Livewire::test('app-shell-probe'); + + expect(substr_count($probe->html(), 'aria-current="page"'))->toBe(2) + ->and(substr_count($probe->call('$refresh')->html(), 'aria-current="page"'))->toBe(2) + ->and($probe->html())->toMatch('/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"[^>]*aria-current="page"/'); +}); + it('lifts the snackbar above the bar only when there is a bar', function () { expect((string) $this->blade('', ['destinations' => shellDestinations()])) ->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]') diff --git a/tests/Feature/Components/TabsTest.php b/tests/Feature/Components/TabsTest.php index d3845637..5b6c3838 100644 --- a/tests/Feature/Components/TabsTest.php +++ b/tests/Feature/Components/TabsTest.php @@ -80,3 +80,28 @@ it('marks the section whose url is the request\'s, and wraps many sections onto ->toMatch('/data-tab\s+aria-current="page"\s*>\s*\s*S3/') ->not->toContain('wire:navigate'); }); + +it('keeps the page\'s section current while a Livewire component on it updates', function () { + Livewire::component('section-nav-probe', new class extends Component + { + public string $page = ''; + + public function mount(): void + { + $this->page = url()->current(); + } + + public function render(): string + { + return '
'; + } + }); + + $current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-tab\s+aria-current="page"/'; + + $probe = Livewire::test('section-nav-probe'); + + expect($probe->html())->toMatch($current) + ->and($probe->call('$refresh')->html())->toMatch($current) + ->and(substr_count($probe->html(), 'aria-current="page"'))->toBe(1); +});