/* * ``, ``: M3's tabs (PrimaryNavigationTabTokens.kt, SecondaryNavigationTabTokens.kt, * androidx Compose Material 3, Apache-2.0; docs/reference/m3/components-navigation-selection-inputs.md * § Tabs) — a tablist of tabs at least 48px tall 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-label] [data-md-tab-text], [data-md-badge] (optional) * [data-md-tab-panel] role="tabpanel", hidden server-side until chosen (N-05) * * "Labels: single row by default; may wrap to a max second line if needed with truncation, or use * scrollable tabs to give longer titles more room" (§ Tabs, "Behaviour and guidelines"). A fixed * tab shares the row equally, so its label keeps one row while it fits its share, then wraps to a * second line and truncates that one with an ellipsis. 48px (64px stacked) is the tab's minimum, * not its height: the tab grows to hold the second line, and the bar's flex row stretches every * tab to the tallest, so the tabs keep one height, each content box fills its tab, and every * indicator stays on the divider under its tab's content. A scrollable tab is as wide as its label * and keeps it on one row, the room M3 gives longer titles instead. * * 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 — 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 * indicator reach back out into it, so the divider still sits against the tabs and the indicator * 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"` (``'s tabs) takes the active indicator and colour * exactly as `aria-selected="true"` does (N-21). */ @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; 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" (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-md-tabs-bar] > li { flex: 1 1 0%; } [data-md-tabs-bar] > li > [data-md-tab] { flex: 1 1 0%; } [data-md-tab] { position: relative; display: flex; flex: 1 1 0%; min-width: 90px; min-height: 48px; align-items: center; justify-content: center; 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-property: color; transition-duration: var(--md-sys-motion-effects-fast-duration); transition-timing-function: var(--md-sys-motion-effects-fast); } [data-md-tabs-bar][data-md-scrollable] > li, [data-md-tabs-bar][data-md-scrollable] [data-md-tab] { flex: none; } [data-md-tabs-bar][data-md-stacked] [data-md-tab] { min-height: 64px; } /* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */ [data-md-tab]::before { content: ''; position: absolute; inset-block: calc(-1 * var(--tabs-ring)); inset-inline: 0; pointer-events: none; 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-md-tab]:hover { color: var(--md-sys-color-on-surface); } [data-md-tab]:hover::before { background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); } } [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-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 (N-21). */ [data-md-tab]:is([aria-selected='true'], [aria-current='page']) { color: var(--md-sys-color-primary); } [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-md-tab]:disabled { color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); cursor: default; } [data-md-tab]:disabled::before { background-color: transparent; } /* Stretched rather than 100% tall, so it fills a tab that grew past its minimum too, and the primary indicator at its foot lands on the divider in every tab of the bar. */ [data-md-tab-content] { position: relative; display: inline-flex; align-self: stretch; min-width: 24px; align-items: center; justify-content: center; gap: var(--md-sys-measurement-space100); } [data-md-tabs-bar][data-md-stacked] [data-md-tab-content] { flex-direction: column; gap: var(--md-sys-measurement-space25); } [data-md-tab-label] { display: inline-flex; min-width: 0; align-items: center; gap: var(--md-sys-measurement-space75); } /* A fixed tab's label: one row while it fits, then a second, truncated (the M3 line above). The tab's own nowrap still holds the badge and a scrollable tab's label to one row. */ [data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text] { display: -webkit-box; min-width: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; text-align: center; text-overflow: ellipsis; white-space: normal; } /* 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)); height: 3px; border-radius: 3px 3px 0 0; background-color: var(--md-sys-color-primary); opacity: 0; pointer-events: none; view-transition-class: material-tab-indicator; } /* A secondary tab's indicator spans the tab, not its content. */ [data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab-content] { position: static; } [data-md-tabs-bar][data-md-variant='secondary'] [data-md-tab-indicator] { inset-inline: 0; height: 2px; border-radius: 0; } [data-md-tab]:is([aria-selected='true'], [aria-current='page']) [data-md-tab-indicator] { opacity: 1; view-transition-name: var(--tabs-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); } }