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';
+90 -53
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) {
/* 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
+44 -1
View File
@@ -79,7 +79,7 @@ it('moves between tabs with the arrow keys, skipping disabled ones, and tells Li
$page->keys('#share-people', 'ArrowRight')
->assertSeeIn('#tab', 'activity')
->assertScript("document.activeElement.id === 'share-activity'")
->assertScript("getComputedStyle(document.querySelector('#share-activity [data-tab-indicator]')).opacity === '1'");
->assertScript("getComputedStyle(document.querySelector('#share-activity [data-md-tab-indicator]')).opacity === '1'");
$page->keys('#share-activity', 'Home')
->assertSeeIn('#tab', 'files')
@@ -302,3 +302,46 @@ it('rounds a docked toolbar and its divider from 840px, clear of the window\'s e
->assertScript("getComputedStyle(document.querySelector('#toolbar [role=\"separator\"]')).height === '40px'")
->assertNoJavaScriptErrors();
});
function tabsFirstPaintProbe(): void
{
Route::middleware('web')->get('/tabs-first-paint-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<x-tabs selected="people" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People'], ['name' => 'activity', 'label' => 'Activity']]">
<x-tab name="files">Files panel</x-tab>
<x-tab name="people">People panel</x-tab>
<x-tab name="activity">Activity panel</x-tab>
</x-tabs>
<script>
// Runs while the document is still being parsed, before Alpine (further down
// the page) exists — what the first paint shows.
window.firstPaint = [...document.querySelectorAll('[role="tabpanel"]')].map((panel) => getComputedStyle(panel).display)
</script>
@livewireScripts
</body>
</html>
BLADE));
}
it('never flashes a tab panel before Alpine boots', function () {
tabsFirstPaintProbe();
// Only the chosen ("people") panel is on screen at the moment the inline script right after
// the panels runs — the server's own `style="display: none"`, not Alpine's x-show, is what
// kept the other two off screen up to here (N-05).
$page = visit('/tabs-first-paint-probe')
->assertScript("window.eval('window.firstPaint.join(\",\")') === 'none,block,none'");
$page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'")
->assertNoJavaScriptErrors();
});
@@ -20,6 +20,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
dataset('navigation components', [
'app-bar',
'toolbar',
'tabs',
]);
/**
+38 -26
View File
@@ -2,6 +2,7 @@
use Livewire\Component;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('renders the tablist on the server with the first enabled tab chosen', function () {
$html = (string) $this->blade(<<<'BLADE'
@@ -12,13 +13,13 @@ it('renders the tablist on the server with the first enabled tab chosen', functi
expect($html)
->toContain('role="tablist"')
->toContain('data-variant="primary"')
->toContain('data-md-variant="primary"')
->toContain('id="share-people"')
->toContain('aria-controls="share-people-panel"')
->toMatch('/data-tab="people"\s+aria-controls="share-people-panel"\s+aria-selected="true"\s+tabindex="0"/')
->toMatch('/data-tab="files"\s+aria-controls="share-files-panel"\s+aria-selected="false"\s+tabindex="-1"/')
->toMatch('/data-md-tab="people"\s+aria-controls="share-people-panel"\s+aria-selected="true"\s+tabindex="0"/')
->toMatch('/data-md-tab="files"\s+aria-controls="share-files-panel"\s+aria-selected="false"\s+tabindex="-1"/')
->toContain('disabled')
->toContain('data-tab-indicator')
->toContain('data-md-tab-indicator')
->toContain('--tabs-indicator: share-indicator')
->toContain('x-modelable="selected"')
// The panel's id is the server's, so aria-controls never dangles (N-05).
@@ -39,17 +40,18 @@ it('renders every panel but the chosen one hidden, before Alpine boots', functio
->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"/');
->toMatch('/id="(tabs-[0-9a-f]{8})-people-panel"/')
->toContain('data-md-tab-panel');
});
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\']]" />'))
->toContain('data-variant="secondary"')
->not->toContain('data-stacked')
->toContain('data-scrollable')
->toContain('data-md-variant="secondary"')
->not->toContain('data-md-stacked')
->toContain('data-md-scrollable')
->and((string) $this->blade('<x-tabs stacked selected="b" :tabs="[[\'name\' => \'a\', \'label\' => \'A\'], [\'name\' => \'b\', \'label\' => \'B\']]" />'))
->toContain('data-stacked')
->toMatch('/data-tab="b"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
->toContain('data-md-stacked')
->toMatch('/data-md-tab="b"\s+aria-controls="[^"]+"\s+aria-selected="true"/');
});
it('entangles the chosen tab with Livewire and renders the one the property names', function () {
@@ -66,26 +68,36 @@ it('entangles the chosen tab with Livewire and renders the one the property name
expect(Livewire::test('tabs-probe')->html())
->toContain(".entangle('tab').live")
->not->toContain('x-modelable')
->toMatch('/data-tab="people"\s+aria-controls="[^"]+"\s+aria-selected="true"/')
->toMatch('/data-md-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('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/tabs.css');
$css = ComponentStylesheet::read('tabs');
expect($css)
expect($css->declarations('[data-md-tabs-bar][data-md-scrollable]'))
// 52dp before the first of a scrollable set (N-10).
->toContain("[data-tabs-bar][data-scrollable] {\n padding-inline-start: 3.25rem;")
->toHaveKey('padding-inline-start', '52px')
// A primary indicator is inset 2dp each side; a secondary one spans the tab (N-18).
->toMatch('/\[data-tab-indicator\] \{\s+position: absolute;\s+inset-inline: 2px;/')
->toMatch('/\[data-variant="secondary"\] \[data-tab-indicator\] \{\s+inset-inline: 0;/')
->and($css->declarations('[data-md-tab-indicator]'))
->toHaveKey('inset-inline', '2px')
->and($css->declarations('[data-md-tabs-bar][data-md-variant=\'secondary\'] [data-md-tab-indicator]'))
->toHaveKey('inset-inline', '0')
// The ring is 2px outside, as everywhere else in the package (N-20).
->toContain('outline-offset: 2px;')
->not->toContain('outline-offset: -3px;')
->and($css->declarations('[data-md-tab]:focus-visible'))
->toBe(['color' => 'var(--md-sys-color-on-surface)', 'outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
// A link marked as the page is the chosen tab too (N-21).
->toContain('[data-tab]:is([aria-selected="true"], [aria-current="page"]) {');
->and($css->declarations('[data-md-tab]:is([aria-selected=\'true\'], [aria-current=\'page\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
});
it('hides every panel until Alpine takes over, and stands the panel\'s content clear of the bar', function () {
$css = ComponentStylesheet::read('tabs');
expect($css->declarations('[data-md-tab-panel]'))
->toBe(['padding-top' => 'var(--md-sys-measurement-space300)', 'outline' => 'none']);
});
it('draws section navigation as secondary tabs and a picker', function () {
@@ -99,10 +111,10 @@ it('draws section navigation as secondary tabs and a picker', function () {
expect($html)
->toContain('aria-label="Settings"')
->toContain('data-section-picker')
->toContain('data-variant="secondary"')
->toContain('data-md-variant="secondary"')
->toContain('max-medium:hidden')
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
->toMatch('/href="\/settings\/security"\s+data-md-tab\s+aria-current="page"\s+wire:navigate/')
->not->toMatch('/href="\/settings\/profile"\s+data-md-tab\s+aria-current/')
// The picker is a menu of places: the current one is the page, not a checked choice,
// and a section's badge shows there as well as on its tab.
->not->toContain('role="menuitemcheckbox"')
@@ -117,13 +129,13 @@ it('marks the section whose url is the request\'s, and scrolls many sections', f
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
->toMatch('/<ul data-tabs-bar data-variant="secondary"\s+data-scrollable\s*>/')
->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/')
->not->toContain('grid-cols-')
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
->toMatch('/data-md-tab\s+aria-current="page"\s*>\s*<span data-md-tab-content>\s*<span class="truncate">S3/')
->not->toContain('wire:navigate')
// Four still share the row.
->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)]))
->not->toContain('data-scrollable');
->not->toContain('data-md-scrollable');
});
it('keeps the page\'s section current while a Livewire component on it updates', function () {
@@ -142,7 +154,7 @@ it('keeps the page\'s section current while a Livewire component on it updates',
}
});
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-tab\s+aria-current="page"/';
$current = '/href="[^"]*\/livewire-unit-test-endpoint\/[^"]*"\s+data-md-tab\s+aria-current="page"/';
$probe = Livewire::test('section-nav-probe');