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'