Render a tab panel's id and its hidden state on the server

Every panel was visible until Alpine booted — four role="tabpanel" regions at
once for a screen reader, and aria-controls pointing at ids that did not exist
yet. The panel now carries its id, its aria-labelledby and, unless it is the
chosen one, display:none from the server. A slot renders before the component
around it, so the panel reads <x-tabs>'s attributes off the component stack and
repeats its id and initial-tab lines; both files say so.
Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-05).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:01:37 +02:00
co-authored by Claude Opus 5
parent a99fbd3794
commit 94e2a900d2
3 changed files with 56 additions and 7 deletions
+29 -3
View File
@@ -1,17 +1,43 @@
{{-- One tab's panel, inside `<x-tabs>`, which draws the bar from its own `tabs` list. Shown while {{-- One tab's panel, inside `<x-tabs>`, which draws the bar from its own `tabs` list. Shown while
its `name` is the chosen one; every panel is rendered, so switching never waits on the server. its `name` is the chosen one; every panel is rendered, so switching never waits on the server.
`class` lands on the panel (it has `pt-6` above its content by default). --}} `class` lands on the panel (it has `pt-6` above its content by default).
The id and the `hidden` panels come from the server, not from Alpine: until Alpine boots, every
panel would otherwise be on screen at once — four `role="tabpanel"` regions for a screen reader
to walk — and the tab buttons' `aria-controls` would point at nothing. `<x-tabs>` renders this
slot before its own view, so it cannot hand the panel anything; the panel reads the attributes
written on `<x-tabs>` off the component stack (Blade's `@aware` mechanism) and repeats the two
lines tabs.blade.php runs on them. The two must agree, so change them together. --}}
@props([ @props([
'name', 'name',
]) ])
@php
$aware = fn (string $key, $default = null) => $__env->getConsumableComponentData($key, $default);
$tabs = $aware('tabs', []);
// `wire:model` is a key, not a prop, and its modifiers are part of the key: the spellings that
// make sense on a tab set. An exotic one only means the panel falls back to the first tab.
$model = collect(['wire:model', 'wire:model.live', 'wire:model.blur', 'wire:model.change', 'wire:model.lazy'])
->map(fn (string $key) => $aware($key))
->first(fn ($value): bool => filled($value));
$tabsId = $aware('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$initial = $aware('selected') ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null && filled(data_get($component, $model))) {
$initial = data_get($component, $model);
}
@endphp
<div <div
x-show="selected === @js($name)" x-show="selected === @js($name)"
role="tabpanel" role="tabpanel"
x-bind:id="tabsId + '-' + @js($name) + '-panel'" id="{{ $tabsId }}-{{ $name }}-panel"
x-bind:aria-labelledby="tabsId + '-' + @js($name)" aria-labelledby="{{ $tabsId }}-{{ $name }}"
tabindex="0" tabindex="0"
@if ((string) $name !== (string) $initial) style="display: none" @endif
{{ $attributes->class(['pt-6 outline-none']) }} {{ $attributes->class(['pt-6 outline-none']) }}
> >
{{ $slot }} {{ $slot }}
+4 -1
View File
@@ -3,7 +3,8 @@
The bar is rendered on the server from `tabs`, a list of `['name' => …, 'label' => …]` with an The bar is rendered on the server from `tabs`, a list of `['name' => …, 'label' => …]` with an
optional `icon`, `badge` and `disabled` — so it arrives with the page, never a moment after it. optional `icon`, `badge` and `disabled` — so it arrives with the page, never a moment after it.
The panels are `<x-tab name="…">` in the slot; every panel is rendered, so switching never waits The panels are `<x-tab name="…">` in the slot; every panel is rendered, so switching never waits
on the server. on the server, and each arrives with its id and with every panel but the chosen one already
hidden, so nothing flashes and no screen reader meets four panels at once.
The chosen tab is the Livewire property in `wire:model` (entangled; `wire:model.live` tells the The chosen tab is the Livewire property in `wire:model` (entangled; `wire:model.live` tells the
server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is
@@ -24,6 +25,8 @@
@php @php
$model = $attributes->wire('model')->value() ?: null; $model = $attributes->wire('model')->value() ?: null;
$variant = $variant === 'secondary' ? 'secondary' : 'primary'; $variant = $variant === 'secondary' ? 'secondary' : 'primary';
// tab.blade.php repeats these two lines off the component stack, because a slot renders before
// the component around it: change them together, or a panel's id stops matching aria-controls.
$id = $attributes->get('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8); $id = $attributes->get('id') ?? 'tabs-'.substr(md5($model.'|'.implode('|', array_column($tabs, 'name'))), 0, 8);
$initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null); $initial = $selected ?? (collect($tabs)->first(fn (array $tab): bool => ! ($tab['disabled'] ?? false))['name'] ?? null);
+23 -3
View File
@@ -21,10 +21,27 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
->toContain('data-tab-indicator') ->toContain('data-tab-indicator')
->toContain('--tabs-indicator: share-indicator') ->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"') ->toContain('x-modelable="selected"')
->toContain('role="tabpanel"') // The panel's id is the server's, so aria-controls never dangles (N-05).
->toMatch('/role="tabpanel"\s+id="share-people-panel"\s+aria-labelledby="share-people"/')
->toContain('>3</span>'); ->toContain('>3</span>');
}); });
it('renders every panel but the chosen one hidden, before Alpine boots', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-tabs :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People']]" selected="people">
<x-tab name="files">Three files.</x-tab>
<x-tab name="people">Anna and Ben.</x-tab>
</x-tabs>
BLADE);
expect($html)
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/')
// The tab button and its panel agree on the id the slot worked out for itself.
->toMatch('/aria-controls="(tabs-[0-9a-f]{8})-people-panel"/')
->toMatch('/id="(tabs-[0-9a-f]{8})-people-panel"/');
});
it('takes a variant, stacks icons and scrolls on request', function () { it('takes a variant, stacks icons and scrolls on request', function () {
expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />')) expect((string) $this->blade('<x-tabs variant="secondary" stacked scrollable :tabs="[[\'name\' => \'a\', \'label\' => \'A\']]" />'))
->toContain('data-variant="secondary"') ->toContain('data-variant="secondary"')
@@ -42,14 +59,17 @@ it('entangles the chosen tab with Livewire and renders the one the property name
public function render(): string public function render(): string
{ {
return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]" /></div>'; return '<div><x-tabs wire:model.live="tab" :tabs="[[\'name\' => \'files\', \'label\' => \'Files\'], [\'name\' => \'people\', \'label\' => \'People\']]"><x-tab name="files">Three files.</x-tab><x-tab name="people">Anna and Ben.</x-tab></x-tabs></div>';
} }
}); });
expect(Livewire::test('tabs-probe')->html()) expect(Livewire::test('tabs-probe')->html())
->toContain(".entangle('tab').live") ->toContain(".entangle('tab').live")
->not->toContain('x-modelable') ->not->toContain('x-modelable')
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/'); ->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
// The panel the property names is the one drawn, before Alpine entangles anything (N-05).
->toMatch('/id="[^"]+-files-panel"[^>]*style="display: none"/')
->toMatch('/id="[^"]+-people-panel"(?![^>]*style="display: none")/');
}); });
it('draws section navigation as secondary tabs and a picker', function () { it('draws section navigation as secondary tabs and a picker', function () {