From a9b5efb4b2db230d18a9b28fb123f23121971ed9 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:04:23 +0200 Subject: [PATCH] Put the tab bar's offsets, indicator inset and ring on M3's numbers A scrollable set now starts 52dp in, so it reads as scrollable; a primary indicator is inset 2dp at each side while a secondary one still spans the tab; a link marked aria-current takes the active label colour the indicator already followed; and the focus ring is 2px outside the tab, as everywhere else in the package, paid for with 5px of room around the bar that the state layer and the indicator reach back into. Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-10, N-18, N-20, N-21). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/tabs.css | 39 ++++++++++++++++++++++----- tests/Feature/Components/TabsTest.php | 16 +++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/resources/css/components/tabs.css b/resources/css/components/tabs.css index 9afcef52..7037bbf6 100644 --- a/resources/css/components/tabs.css +++ b/resources/css/components/tabs.css @@ -6,20 +6,38 @@ * * 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), 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. + * (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), + * 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 + * 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. */ @layer components { [data-tabs-bar] { + --tabs-ring: 5px; position: relative; display: flex; overflow-x: auto; overscroll-behavior-x: contain; + padding: var(--tabs-ring); + scroll-padding-inline: var(--tabs-ring); border-bottom: 1px solid var(--md-sys-color-outline-variant); scrollbar-width: thin; } + /* "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; + } + /* A section nav's links sit in list items that share the width. */ [data-tabs-bar] > li { flex: 1 1 0%; @@ -55,10 +73,12 @@ height: 4rem; } + /* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */ [data-tab]::before { content: ""; position: absolute; - inset: 0; + 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); } @@ -81,10 +101,12 @@ [data-tab]:focus-visible { color: var(--md-sys-color-on-surface); outline: 3px solid var(--md-sys-color-secondary); - outline-offset: -3px; + outline-offset: 2px; } - [data-tab][aria-selected="true"] { + /* 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"]) { color: var(--md-sys-color-primary); } @@ -116,10 +138,12 @@ gap: 0.125rem; } + /* 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] { position: absolute; - inset-inline: 0; - bottom: 0; + inset-inline: 2px; + bottom: calc(-1 * var(--tabs-ring)); height: 3px; border-radius: 3px 3px 0 0; background-color: var(--md-sys-color-primary); @@ -134,6 +158,7 @@ } [data-tabs-bar][data-variant="secondary"] [data-tab-indicator] { + inset-inline: 0; height: 2px; border-radius: 0; } diff --git a/tests/Feature/Components/TabsTest.php b/tests/Feature/Components/TabsTest.php index 100453ea..b8d06460 100644 --- a/tests/Feature/Components/TabsTest.php +++ b/tests/Feature/Components/TabsTest.php @@ -72,6 +72,22 @@ it('entangles the chosen tab with Livewire and renders the one the property name ->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'); + + expect($css) + // 52dp before the first of a scrollable set (N-10). + ->toContain("[data-tabs-bar][data-scrollable] {\n padding-inline-start: 3.25rem;") + // 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;/') + // The ring is 2px outside, as everywhere else in the package (N-20). + ->toContain('outline-offset: 2px;') + ->not->toContain('outline-offset: -3px;') + // A link marked as the page is the chosen tab too (N-21). + ->toContain('[data-tab]:is([aria-selected="true"], [aria-current="page"]) {'); +}); + it('draws section navigation as secondary tabs and a picker', function () { $html = (string) $this->blade(<<<'BLADE'