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
+1
View File
@@ -71,3 +71,4 @@
/* Navigation */
@import './components/app-bar.css';
@import './components/toolbar.css';
@import './components/tabs.css';
+92 -55
View File
@@ -1,25 +1,47 @@
/*
* M3's tabs (PrimaryNavigationTabTokens, SecondaryNavigationTabTokens, androidx Compose Material 3,
* Apache-2.0): a 48px tablist on the surface over an outline-variant divider (64px when a primary
* tab stacks its icon over its label), title-small labels in on-surface-variant, the chosen tab's
* in primary (primary tabs) or on-surface (secondary tabs).
* `<x-tabs>`, `<x-tab>`: M3's tabs (PrimaryNavigationTabTokens.kt, SecondaryNavigationTabTokens.kt,
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md
* § Tabs) — a 48px tablist on the surface over an outline-variant divider (64px when a primary tab
* stacks its icon over its label), title-small labels in on-surface-variant, the chosen tab's in
* primary (primary tabs) or on-surface (secondary tabs).
*
* [data-md-tabs] min-width: 0
* [data-md-tabs-bar] role="tablist"; data-md-variant, data-md-stacked,
* data-md-scrollable
* [data-md-tab] role="tab"
* [data-md-tab-content] [data-md-icon] (optional), [data-md-tab-label],
* [data-md-tab-indicator]
* [data-md-tab-panel] role="tabpanel", hidden server-side until chosen (N-05)
*
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
* Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab
* (at least 24px wide, inset 2px at each side), 2px across the whole of a secondary one. When the
* choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
* (at least 24px wide, inset 2px at each side — N-18), 2px across the whole of a secondary one.
* When the choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
* as M3's slides.
*
* The bar scrolls sideways, so it clips whatever leaves it — including the focus ring, which the
* rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring`
* is the room that takes: the bar carries it as padding on every side, and the state layer and the
* rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring` is
* the room that takes: the bar carries it as padding on every side, and the state layer and the
* indicator reach back out into it, so the divider still sits against the tabs and the indicator
* still sits on the divider. That makes the bar 10px taller than M3's 48px tab.
* still sits on the divider (N-20). That makes the bar 10px taller than M3's 48px tab. A scrollable
* set's first tab is offset 52dp from the leading edge, so it reads as scrollable (N-10). A link
* marked `aria-current="page"` (`<x-section-nav>`'s tabs) takes the active indicator and colour
* exactly as `aria-selected="true"` does (N-21).
*/
@layer components {
[data-tabs-bar] {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './badge.css';
@layer material.components {
[data-md-tabs] {
min-width: 0;
}
[data-md-tabs-bar] {
--tabs-ring: 5px;
position: relative;
display: flex;
overflow-x: auto;
@@ -31,117 +53,126 @@
}
/* "Offset the first scrollable tab 52dp from the leading edge so it's clear that more content
is available" (docs/reference/m3/components-navigation-selection-inputs.md § Tabs). Logical,
so a right-to-left page mirrors it. */
[data-tabs-bar][data-scrollable] {
padding-inline-start: 3.25rem;
scroll-padding-inline-start: 3.25rem;
is available" (N-10). Logical, so a right-to-left page mirrors it. */
[data-md-tabs-bar][data-md-scrollable] {
padding-inline-start: 52px;
scroll-padding-inline-start: 52px;
}
/* A section nav's links sit in list items that share the width. */
[data-tabs-bar] > li {
[data-md-tabs-bar] > li {
flex: 1 1 0%;
}
[data-tabs-bar] > li > [data-tab] {
[data-md-tabs-bar] > li > [data-md-tab] {
flex: 1 1 0%;
}
[data-tab] {
[data-md-tab] {
position: relative;
display: flex;
flex: 1 1 0%;
min-width: 5.625rem;
height: 3rem;
min-width: 90px;
height: 48px;
align-items: center;
justify-content: center;
padding-inline: 1rem;
padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
outline: none;
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
white-space: nowrap;
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
transition-property: color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-tabs-bar][data-scrollable] > li,
[data-tabs-bar][data-scrollable] [data-tab] {
[data-md-tabs-bar][data-md-scrollable] > li,
[data-md-tabs-bar][data-md-scrollable] [data-md-tab] {
flex: none;
}
[data-tabs-bar][data-stacked] [data-tab] {
height: 4rem;
[data-md-tabs-bar][data-md-stacked] [data-md-tab] {
height: 64px;
}
/* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */
[data-tab]::before {
content: "";
[data-md-tab]::before {
content: '';
position: absolute;
inset-block: calc(-1 * var(--tabs-ring));
inset-inline: 0;
pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
transition-property: background-color;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-tab]:hover {
[data-md-tab]:hover {
color: var(--md-sys-color-on-surface);
}
[data-tab]:hover::before {
[data-md-tab]:hover::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
[data-tab]:focus-visible::before,
[data-tab]:active::before {
[data-md-tab]:focus-visible::before,
[data-md-tab]:active::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-tab]:focus-visible {
[data-md-tab]:focus-visible {
color: var(--md-sys-color-on-surface);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/* A link-based bar marks the page with aria-current, not aria-selected; both are the chosen
tab, and both take the variant's active colour. */
[data-tab]:is([aria-selected="true"], [aria-current="page"]) {
tab, and both take the variant's active colour (N-21). */
[data-md-tab]:is([aria-selected='true'], [aria-current='page']) {
color: var(--md-sys-color-primary);
}
[data-tabs-bar][data-variant="secondary"] [data-tab]:is([aria-selected="true"], [aria-current="page"]) {
[data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab]:is([aria-selected='true'], [aria-current='page']) {
color: var(--md-sys-color-on-surface);
}
[data-tab]:disabled {
[data-md-tab]:disabled {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-tab]:disabled::before {
[data-md-tab]:disabled::before {
background-color: transparent;
}
[data-tab-content] {
[data-md-tab-content] {
position: relative;
display: inline-flex;
height: 100%;
min-width: 1.5rem;
min-width: 24px;
align-items: center;
justify-content: center;
gap: 0.5rem;
gap: var(--md-sys-measurement-space100);
}
[data-tabs-bar][data-stacked] [data-tab-content] {
[data-md-tabs-bar][data-md-stacked] [data-md-tab-content] {
flex-direction: column;
gap: 0.125rem;
gap: var(--md-sys-measurement-space25);
}
/* Primary indicators are inset 2dp at each side; both variants sit on the divider, which the
ring's room has pushed below the tab. */
[data-tab-indicator] {
[data-md-tab-label] {
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space75);
}
/* Primary indicators are inset 2dp at each side (N-18); both variants sit on the divider, which
the ring's room has pushed below the tab. */
[data-md-tab-indicator] {
position: absolute;
inset-inline: 2px;
bottom: calc(-1 * var(--tabs-ring));
@@ -154,23 +185,29 @@
}
/* A secondary tab's indicator spans the tab, not its content. */
[data-tabs-bar][data-variant="secondary"] [data-tab-content] {
[data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab-content] {
position: static;
}
[data-tabs-bar][data-variant="secondary"] [data-tab-indicator] {
[data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab-indicator] {
inset-inline: 0;
height: 2px;
border-radius: 0;
}
[data-tab]:is([aria-selected="true"], [aria-current="page"]) [data-tab-indicator] {
[data-md-tab]:is([aria-selected='true'], [aria-current='page']) [data-md-tab-indicator] {
opacity: 1;
view-transition-name: var(--tabs-indicator);
}
}
::view-transition-group(*.material-tab-indicator) {
animation-duration: var(--md-sys-motion-spatial-default-duration);
animation-timing-function: var(--md-sys-motion-spatial-default);
/* pt-6, before the rewrite: the panel's content stands clear of the bar above it. */
[data-md-tab-panel] {
padding-top: var(--md-sys-measurement-space300);
outline: none;
}
::view-transition-group(*.material-tab-indicator) {
animation-duration: var(--md-sys-motion-spatial-default-duration);
animation-timing-function: var(--md-sys-motion-spatial-default);
}
}
-1
View File
@@ -18,5 +18,4 @@
@import './tokens/utilities.css';
/* Navigation — leaving in step 36 */
@import './components/tabs.css';
@import './components/navigation.css';
+4 -4
View File
@@ -29,18 +29,18 @@ document.addEventListener('alpine:init', () => {
}
if (focus) {
this.$refs.bar.querySelector(`[data-tab="${CSS.escape(name)}"]`)?.focus()
this.$refs.bar.querySelector(`[data-md-tab="${CSS.escape(name)}"]`)?.focus()
}
},
step(by) {
const tabs = this.tabs()
const rtl = getComputedStyle(this.$refs.bar).direction === 'rtl'
const at = tabs.findIndex((tab) => tab.dataset.tab === this.selected)
const at = tabs.findIndex((tab) => tab.dataset.mdTab === this.selected)
const next = tabs[(at + (rtl ? -by : by) + tabs.length) % tabs.length]
if (next) {
this.choose(next.dataset.tab, true)
this.choose(next.dataset.mdTab, true)
}
},
@@ -49,7 +49,7 @@ document.addEventListener('alpine:init', () => {
const tab = last ? tabs.at(-1) : tabs[0]
if (tab) {
this.choose(tab.dataset.tab, true)
this.choose(tab.dataset.mdTab, true)
}
},
}))
@@ -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