Files
livewire-material/resources/views/components/tabs.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 94e2a900d2 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
2026-09-14 06:01:37 +02:00

94 lines
4.3 KiB
PHP

{{-- M3's tabs: the sections of one thing, with the chosen one marked by an active indicator.
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.
The panels are `<x-tab name="…">` in the slot; every panel is rendered, so switching never waits
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
server at once), or, without it, `selected` (the first tab by default) and `x-model`. The bar is
a real tablist (resources/js/tabs.js). `variant` is `primary` (the default: the indicator under
the label, in primary) or `secondary` (under the whole tab, for tabs inside a section);
`stacked` puts a primary tab's icon over its label (64px); `scrollable` lets tabs keep their
own width and scroll sideways instead of sharing the width equally
(resources/css/components/tabs.css). --}}
@props([
'tabs' => [],
'variant' => 'primary',
'selected' => null,
'stacked' => false,
'scrollable' => false,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$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);
$initial = $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
x-data="{
@if ($model !== null) selected: @entangle($attributes->wire('model')), @else selected: @js($initial), @endif
tabsId: @js($id),
...materialTabs(),
}"
@if ($model === null) x-modelable="selected" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->class(['min-w-0']) }}
>
<div
x-ref="bar"
role="tablist"
data-tabs-bar
data-variant="{{ $variant }}"
@if ($stacked && $variant === 'primary') data-stacked @endif
@if ($scrollable) data-scrollable @endif
style="--tabs-indicator: {{ $id }}-indicator"
x-on:keydown.arrow-right.prevent="step(1)"
x-on:keydown.arrow-left.prevent="step(-1)"
x-on:keydown.home.prevent="edge(false)"
x-on:keydown.end.prevent="edge(true)"
>
@foreach ($tabs as $tab)
@php($isInitial = (string) $tab['name'] === (string) $initial)
<button
type="button"
role="tab"
id="{{ $id }}-{{ $tab['name'] }}"
data-tab="{{ $tab['name'] }}"
aria-controls="{{ $id }}-{{ $tab['name'] }}-panel"
aria-selected="{{ $isInitial ? 'true' : 'false' }}"
tabindex="{{ $isInitial ? '0' : '-1' }}"
x-bind:aria-selected="(selected === @js($tab['name'])).toString()"
x-bind:tabindex="selected === @js($tab['name']) ? 0 : -1"
x-on:click="choose(@js($tab['name']))"
@disabled($tab['disabled'] ?? false)
>
<span data-tab-content>
@isset($tab['icon'])
<x-livewire-material::icon :name="$tab['icon']" class="size-6" />
@endisset
<span class="inline-flex items-center gap-1.5">
{{ $tab['label'] }}
@if (filled($tab['badge'] ?? null))
<x-livewire-material::badge :value="$tab['badge']" />
@endif
</span>
<span data-tab-indicator aria-hidden="true"></span>
</span>
</button>
@endforeach
</div>
{{ $slot }}
</div>