Keep the current section lit while a Livewire component updates
<x-section-nav> and <x-app-shell> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
co-authored by
Claude Opus 5
parent
d77ecf5e2a
commit
fdb13313f7
@@ -684,7 +684,7 @@ The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a co
|
|||||||
</x-app-shell>
|
</x-app-shell>
|
||||||
```
|
```
|
||||||
|
|
||||||
- `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`).
|
- 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`).
|
- 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.
|
- 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
|
|||||||
|
|
||||||
### `<x-section-nav>`
|
### `<x-section-nav>`
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
### `<x-account-menu>`
|
### `<x-account-menu>`
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
remembered and applied before the first paint (`$store.rail`, <x-theme-script>).
|
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
|
`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
|
`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
|
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`).
|
`navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$label ??= __('Main');
|
$label ??= __('Main');
|
||||||
$current = request()->url();
|
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : request()->url();
|
||||||
|
|
||||||
$items = collect($destinations)
|
$items = collect($destinations)
|
||||||
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
single destination in the app's own navigation.
|
single destination in the app's own navigation.
|
||||||
|
|
||||||
`items`, a list of `['title' => …, 'url' => …]` with an optional `icon`, `active` and `badge`
|
`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
|
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
|
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.
|
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
|
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
|
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
|
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||||
@@ -20,7 +23,8 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$label ??= __('Sections');
|
$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);
|
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null);
|
||||||
|
|
||||||
$layout = match (true) {
|
$layout = match (true) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
function shellDestinations(): array
|
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);
|
->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 '<div><x-app-shell :destinations="[[\'title\' => \'Inbox\', \'icon\' => \'inbox\', \'url\' => $page], [\'title\' => \'Sent\', \'icon\' => \'send\', \'url\' => \'/sent\']]" /></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$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 () {
|
it('lifts the snackbar above the bar only when there is a bar', function () {
|
||||||
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
expect((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
|
||||||
->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
|
->toContain('max-sm:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]')
|
||||||
|
|||||||
@@ -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*<span data-tab-content>\s*<span class="truncate">S3/')
|
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
||||||
->not->toContain('wire:navigate');
|
->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 '<div><x-section-nav :items="[[\'title\' => \'Here\', \'url\' => $page], [\'title\' => \'Elsewhere\', \'url\' => \'/elsewhere\']]" no-wire-navigate /></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user