From a4bc046d1d0e5896e626cc5ca73abeea308447a3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 22:20:37 +0200 Subject: [PATCH] Speak a shell destination's badge in words, and stop a null offset on PHP 8.5 passed no badge label to its rail and bar items, so a badge like 0/3 reached a screen reader bare; a destination's badgeLabel now does. The head script indexed the profiles with a null scheme when none are configured, which PHP 8.5 reports as a deprecated null array offset on every page with theme.meta on. It reads the default scheme without the lookup. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 2 +- .../views/components/app-shell.blade.php | 10 ++++++---- .../views/components/theme-script.blade.php | 3 ++- tests/Feature/Components/AppShellTest.php | 10 ++++++++++ tests/Feature/Components/ThemeScriptTest.php | 20 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index a90f6c44..9774cdf6 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -687,7 +687,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 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`). +- `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), `badgeLabel` (what a screen reader hears for the badge: "3 unread"), `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. diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php index 66620b0c..be0c2900 100644 --- a/resources/views/components/app-shell.blade.php +++ b/resources/views/components/app-shell.blade.php @@ -21,7 +21,8 @@ `destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally `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), + component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for + the badge instead: "3 unread"), `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`). @@ -67,6 +68,7 @@ 'url' => $item['url'] ?? null, 'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))), 'badge' => $item['badge'] ?? null, + 'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : null, 'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null, 'bar' => ($item['bar'] ?? true) !== false, 'navigate' => ($item['navigate'] ?? true) !== false, @@ -105,12 +107,12 @@ @if ($group->first()['section'] !== null) @foreach ($group as $item) - + @endforeach @else @foreach ($group as $item) - + @endforeach @endif @endforeach @@ -140,7 +142,7 @@
@foreach ($barItems as $item) - + @endforeach
diff --git a/resources/views/components/theme-script.blade.php b/resources/views/components/theme-script.blade.php index 4a36efc6..41fa9ecc 100644 --- a/resources/views/components/theme-script.blade.php +++ b/resources/views/components/theme-script.blade.php @@ -58,7 +58,8 @@ $schemeProfiles = \NoNameWeb\LivewireMaterial\Support\Scheme::profiles(); $settings['meta'] = [ - ...$surfaces($schemeProfiles[$settings['scheme']] ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()), + // PHP 8.5 deprecates a null array offset: without profiles the scheme is null. + ...$surfaces(($settings['scheme'] !== null ? ($schemeProfiles[$settings['scheme']] ?? null) : null) ?? \NoNameWeb\LivewireMaterial\Support\Scheme::load()), 'profiles' => (object) collect($schemeProfiles)->map($surfaces)->all(), ]; } diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php index fb313f1f..b38a0f44 100644 --- a/tests/Feature/Components/AppShellTest.php +++ b/tests/Feature/Components/AppShellTest.php @@ -44,6 +44,16 @@ it('puts every destination in the rail and only those marked for the bar in the ->and(substr_count($html, 'aria-current="page"'))->toBe(2); }); +it('speaks a destination\'s badge in its own words when it has them', function () { + $html = (string) $this->blade('', ['destinations' => [ + ['title' => 'Get started', 'icon' => 'rocket_launch', 'url' => '/start', 'badge' => '0/3', 'badgeLabel' => '0 of 3 done'], + ['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4], + ]]); + + expect($html)->toContain('0 of 3 done') + ->and(substr_count($html, '0 of 3 done'))->toBeGreaterThanOrEqual(2); +}); + it('marks the destination at the current URL when none says it is active', function () { Route::get('/shell-probe/inbox', fn () => Blade::render('', ['destinations' => [ ['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')], diff --git a/tests/Feature/Components/ThemeScriptTest.php b/tests/Feature/Components/ThemeScriptTest.php index 0ce96b93..a982fc31 100644 --- a/tests/Feature/Components/ThemeScriptTest.php +++ b/tests/Feature/Components/ThemeScriptTest.php @@ -107,3 +107,23 @@ it('gives the theme-color meta every profile\'s surfaces, the active one\'s firs File::delete($path); } }); + +it('paints the meta without profiles and without a deprecation on PHP 8.5', function () { + config(['livewire-material.theme.meta' => true, 'livewire-material.profiles' => []]); + + $deprecations = []; + set_error_handler(function (int $level, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_DEPRECATED | E_USER_DEPRECATED); + + try { + $html = (string) $this->blade(''); + } finally { + restore_error_handler(); + } + + expect($html)->toContain('"meta":{') + ->and($deprecations)->toBe([]); +});