Draw the tabs without Tailwind

Plan step 36 (navigation group, third and last stream): <x-tabs>'s
and <x-tab>'s class lists move into resources/css/components/tabs.css,
one commit for both since they share it. Root data-md-tabs; the bar
data-md-tabs-bar with data-md-variant/-stacked/-scrollable, holding
data-md-tab buttons (data-md-tab-content, the new data-md-tab-label
wrapping the label and badge, data-md-tab-indicator); the panel
data-md-tab-panel. The server-rendered hidden panel (N-05), the 52dp
scrollable offset (N-10), the indicator's 2dp inset (N-18), the ring
moved 2px out (N-20) and aria-current sharing the active colour
(N-21) already matched the audit; only hooks, units and layer
changed. Sizes (48/64px tab height, 90px minimum width) stay px;
spacing goes through the measurement tokens where the value matches
one (the icon-label and label-badge gaps, the panel's top padding).
Imports icon.css and badge.css for what the view renders.

Hooks renamed: data-tabs-bar, data-tab, data-tab-content,
data-tab-indicator, data-scrollable, data-stacked and the bar's
data-variant to data-md-*, updated in tabs.js (dataset.tab ->
dataset.mdTab), section-nav.blade.php (which reuses the tab bar's
hooks under its own, still-Tailwind, markup),
tests/Feature/Components/TabsTest.php and tests/Browser/BarsTest.php.

Added the owed browser test: a tab panel never flashes before Alpine
boots, read from an inline script that runs while the page is still
parsing, well before Alpine's own script (docs/plans/material-3-browser-tests.md).

This closes plan step 36: every navigation-group component is now
data-md-* and Tailwind-free; tailwind.css's Navigation block keeps
only navigation.css (the bar and rail, out of this batch's scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 23:31:27 +02:00
co-authored by Claude Sonnet 5
parent 9c134b2dc7
commit 1f9384efdf
10 changed files with 212 additions and 111 deletions
@@ -62,18 +62,18 @@
@endif
<nav aria-label="{{ $label }}" class="max-medium:hidden">
<ul data-tabs-bar data-variant="secondary" @if ($scrollable) data-scrollable @endif>
<ul data-md-tabs-bar data-md-variant="secondary" @if ($scrollable) data-md-scrollable @endif>
@foreach ($items as $item)
@php($on = $isCurrent($item))
<li class="flex min-w-0">
<a
href="{{ $item['url'] }}"
data-tab
data-md-tab
@if ($on) aria-current="page" @endif
@unless ($noWireNavigate) wire:navigate @endunless
>
<span data-tab-content>
<span data-md-tab-content>
@isset($item['icon'])
<x-livewire-material::icon :name="$item['icon']" :filled="$on" optical="20" class="size-5" />
@endisset
@@ -81,7 +81,7 @@
@if (filled($item['badge'] ?? null))
<x-livewire-material::badge :value="$item['badge']" />
@endif
<span data-tab-indicator aria-hidden="true"></span>
<span data-md-tab-indicator aria-hidden="true"></span>
</span>
</a>
</li>
+8 -6
View File
@@ -1,13 +1,14 @@
{{-- 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.
`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. --}}
to walk — and the tab buttons' `aria-controls` would point at nothing (N-05). `<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.
The panel renders `data-md-tab-panel`, drawn by resources/css/components/tabs.css. --}}
@props([
'name',
@@ -37,8 +38,9 @@
id="{{ $tabsId }}-{{ $name }}-panel"
aria-labelledby="{{ $tabsId }}-{{ $name }}"
tabindex="0"
data-md-tab-panel
@if ((string) $name !== (string) $initial) style="display: none" @endif
{{ $attributes->class(['pt-6 outline-none']) }}
{{ $attributes }}
>
{{ $slot }}
</div>
+20 -14
View File
@@ -4,15 +4,20 @@
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.
hidden, so nothing flashes and no screen reader meets four panels at once (N-05).
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). --}}
own width and scroll sideways instead of sharing the width equally, offset 52dp from the
leading edge so it reads as scrollable (N-10, resources/css/components/tabs.css).
The root renders `data-md-tabs`; the bar is `data-md-tabs-bar` with `data-md-variant`,
`data-md-stacked` and `data-md-scrollable`, holding `data-md-tab` buttons (each
`data-md-tab-content`, `data-md-tab-label` and `data-md-tab-indicator`). Imports icon.css and
badge.css for what the view renders. --}}
@props([
'tabs' => [],
@@ -37,20 +42,21 @@
<div
x-data="{
@if ($model !== null) selected: @entangle($attributes->wire('model')), @else selected: @js($initial), @endif
@if ($model !== null) selected: @entangle($attributes->wire('model')).live, @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']) }}
data-md-tabs
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
>
<div
x-ref="bar"
role="tablist"
data-tabs-bar
data-variant="{{ $variant }}"
@if ($stacked && $variant === 'primary') data-stacked @endif
@if ($scrollable) data-scrollable @endif
data-md-tabs-bar
data-md-variant="{{ $variant }}"
@if ($stacked && $variant === 'primary') data-md-stacked @endif
@if ($scrollable) data-md-scrollable @endif
style="--tabs-indicator: {{ $id }}-indicator"
x-on:keydown.arrow-right.prevent="step(1)"
x-on:keydown.arrow-left.prevent="step(-1)"
@@ -64,7 +70,7 @@
type="button"
role="tab"
id="{{ $id }}-{{ $tab['name'] }}"
data-tab="{{ $tab['name'] }}"
data-md-tab="{{ $tab['name'] }}"
aria-controls="{{ $id }}-{{ $tab['name'] }}-panel"
aria-selected="{{ $isInitial ? 'true' : 'false' }}"
tabindex="{{ $isInitial ? '0' : '-1' }}"
@@ -73,17 +79,17 @@
x-on:click="choose(@js($tab['name']))"
@disabled($tab['disabled'] ?? false)
>
<span data-tab-content>
<span data-md-tab-content>
@isset($tab['icon'])
<x-livewire-material::icon :name="$tab['icon']" class="size-6" />
<x-livewire-material::icon :name="$tab['icon']" />
@endisset
<span class="inline-flex items-center gap-1.5">
<span data-md-tab-label>
{{ $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 data-md-tab-indicator aria-hidden="true"></span>
</span>
</button>
@endforeach