diff --git a/resources/css/components/section-nav.css b/resources/css/components/section-nav.css index c9a2e508..7a838286 100644 --- a/resources/css/components/section-nav.css +++ b/resources/css/components/section-nav.css @@ -16,8 +16,11 @@ * `aria-current="page"` takes the same active indicator and colour tabs.css already draws for * `aria-selected="true"` (N-21), so nothing here repeats a rule tabs.css owns; the only addition is * this bar's own list items — flex rows, so tabs.css's `li > [data-md-tab]` share of the width - * applies, and `min-inline-size: 0`, so a long title still truncates — which ``'s buttons, - * direct children of the bar, never need. The picker's trigger is not one of M3's controls + * applies, and `min-inline-size: 0`, so a long title in a fixed row wraps to its second line and + * truncates there (tabs.css, M3's "may wrap to a max second line if needed with truncation") + * instead of widening its item — which ``'s buttons, direct children of the bar, never + * need. Its tabs render ``'s label anatomy (`data-md-tab-label` around + * `data-md-tab-text`), so that rule draws both. The picker's trigger is not one of M3's controls * — an outlined field-like button naming the current section, 48px tall to meet the target M3 asks * for everywhere, in body-large type (a text class on the view, `md-type-body-lg * md-ink md-text-start`, since it is prose on a plain button rather than a component's own type). diff --git a/resources/css/components/tabs.css b/resources/css/components/tabs.css index dbbb8b85..27ebaf4b 100644 --- a/resources/css/components/tabs.css +++ b/resources/css/components/tabs.css @@ -1,8 +1,8 @@ /* * ``, ``: 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 + * § 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 @@ -11,8 +11,18 @@ * [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. @@ -73,7 +83,7 @@ display: flex; flex: 1 1 0%; min-width: 90px; - height: 48px; + min-height: 48px; align-items: center; justify-content: center; padding-inline: var(--md-sys-measurement-space200); @@ -94,7 +104,7 @@ } [data-md-tabs-bar][data-md-stacked] [data-md-tab] { - height: 64px; + min-height: 64px; } /* The state layer covers the tab and the ring's room around it, so the wash meets the divider. */ @@ -149,10 +159,12 @@ 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; - height: 100%; + align-self: stretch; min-width: 24px; align-items: center; justify-content: center; @@ -166,10 +178,24 @@ [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] { diff --git a/resources/views/components/section-nav.blade.php b/resources/views/components/section-nav.blade.php index d935045d..4ea496f7 100644 --- a/resources/views/components/section-nav.blade.php +++ b/resources/views/components/section-nav.blade.php @@ -13,7 +13,11 @@ The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the request is Livewire's update endpoint, and comparing with it left no section lit. - Up to four sections share the row as fixed tabs. From five the row is M3's scrollable tab bar + Up to four sections share the row as fixed tabs, a title too long for its share wrapping to + a second line and truncating there, the bar growing to hold it, as M3 lets a fixed tab's label + ("may wrap to a max second line if needed with truncation"); each tab renders ``'s + label anatomy, `data-md-tab-label` around `data-md-tab-text` and the badge, so tabs.css draws + both alike. From five the row is M3's scrollable tab bar — each tab as wide as its own label, the set scrolling sideways, offset 52dp from the leading edge so it reads as scrollable — which is M3's own answer to a row that will not fit, and the one its accessibility page blesses ("horizontal scrolling tabs meet accessibility requirements @@ -36,7 +40,8 @@ $isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url'])); $current = collect($items)->first($isCurrent) ?? ($items[0] ?? null); - // Four fit a row at the widths this bar is used at; from five they are M3's scrollable tabs. + // Up to four share the row as fixed tabs, a long title wrapping to a second line and truncating + // there (tabs.css); from five they are M3's scrollable tabs, which give longer titles room. $scrollable = count($items) >= 5; @endphp @@ -77,10 +82,12 @@ @isset($item['icon']) @endisset - {{ $item['title'] }} - @if (filled($item['badge'] ?? null)) - - @endif + + {{ $item['title'] }} + @if (filled($item['badge'] ?? null)) + + @endif + diff --git a/resources/views/components/tabs.blade.php b/resources/views/components/tabs.blade.php index c14ae8ff..452a5a35 100644 --- a/resources/views/components/tabs.blade.php +++ b/resources/views/components/tabs.blade.php @@ -12,12 +12,16 @@ 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, offset 52dp from the - leading edge so it reads as scrollable (N-10, resources/css/components/tabs.css). + leading edge so it reads as scrollable (N-10, resources/css/components/tabs.css). A fixed + tab's label too long for its share wraps to a second line and truncates there, the whole bar + growing to hold it; a scrollable tab keeps its label on one line (M3: "may wrap to a max + second line if needed with truncation, or use scrollable tabs to give longer titles more + room"). 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. --}} + `data-md-tab-content`, `data-md-tab-label` around the label's `data-md-tab-text` and its badge, + and `data-md-tab-indicator`). Imports icon.css and badge.css for what the view renders. --}} @props([ 'tabs' => [], @@ -84,7 +88,7 @@ @endisset - {{ $tab['label'] }} + {{ $tab['label'] }} @if (filled($tab['badge'] ?? null)) @endif diff --git a/tests/Browser/BarsTest.php b/tests/Browser/BarsTest.php index a4be42bc..75ed0e11 100644 --- a/tests/Browser/BarsTest.php +++ b/tests/Browser/BarsTest.php @@ -54,7 +54,7 @@ class BarsProbe extends Component - +
@@ -192,11 +192,51 @@ it('shares the row width equally between the section nav\'s items, whatever thei $widths = "[...document.querySelectorAll('[data-md-section-nav] [data-md-tabs-bar] > li')].map((li) => Math.round(li.getBoundingClientRect().width))"; barsProbe() - ->assertScript("{$widths}.length === 3") + ->assertScript("{$widths}.length === 4") ->assertScript("new Set({$widths}).size === 1") ->assertNoJavaScriptErrors(); }); +/** + * Plan step 46, M3 § Tabs: "Labels: single row by default; may wrap to a max second line if needed + * with truncation". Four sections are fixed tabs; at a 600px window a long title no longer fits its + * share, so it takes a second line (SealShare's "Two-Factor Auth" showed an ellipsis on one) inside + * its tab, the row's tabs keep one height, and the chosen tab's indicator stays on the divider. + */ +it('wraps a long section title to a second line in a fixed row, keeping the tabs one height and the indicator on the divider', function () { + $tabs = "[...document.querySelectorAll('[data-md-section-nav] [data-md-tab]')]"; + $long = "document.querySelector('[data-md-section-nav] [data-md-tab][href=\"#notifications\"]')"; + + barsProbe() + ->resize(600, 800) + ->assertScript("getComputedStyle(document.querySelector('[data-md-section-nav] nav')).display !== 'none'") + ->assertScript("{$tabs}.length === 4 && ! document.querySelector('[data-md-section-nav] [data-md-tabs-bar]').hasAttribute('data-md-scrollable')") + // Two lines of the label's own line height, not one truncated row. + ->assertScript("(() => { const text = {$long}.querySelector('[data-md-tab-text]'); return Math.round(text.getBoundingClientRect().height / parseFloat(getComputedStyle(text).lineHeight)) === 2; })()") + // Inside its tab, both ways. + ->assertScript("(() => { const tab = {$long}.getBoundingClientRect(); const text = {$long}.querySelector('[data-md-tab-text]').getBoundingClientRect(); return text.left >= tab.left - 0.5 && text.right <= tab.right + 0.5 && text.top >= tab.top - 0.5 && text.bottom <= tab.bottom + 0.5; })()") + // One height for the row, at least M3's 48px. + ->assertScript("new Set({$tabs}.map((tab) => Math.round(tab.getBoundingClientRect().height))).size === 1 && {$tabs}[0].getBoundingClientRect().height >= 48") + // The chosen tab's indicator: shown, under its content, on the divider, within its tab. + ->assertScript(<<<'JS' + (() => { + const tab = document.querySelector('[data-md-section-nav] [data-md-tab][aria-current="page"]'); + const bar = tab.closest('[data-md-tabs-bar]'); + const indicator = tab.querySelector('[data-md-tab-indicator]'); + const box = tab.getBoundingClientRect(); + const content = tab.querySelector('[data-md-tab-content]').getBoundingClientRect(); + const line = indicator.getBoundingClientRect(); + const divider = bar.getBoundingClientRect().bottom - parseFloat(getComputedStyle(bar).borderBottomWidth); + + return getComputedStyle(indicator).opacity === '1' + && line.top >= content.bottom - 0.5 + && Math.abs(line.bottom - divider) <= 1 + && line.left >= box.left - 0.5 && line.right <= box.right + 0.5; + })() + JS) + ->assertNoJavaScriptErrors(); +}); + class AppBarOverflowProbe extends Component { public int $stars = 0; diff --git a/tests/Feature/Components/TabsTest.php b/tests/Feature/Components/TabsTest.php index 3afba96d..544237ad 100644 --- a/tests/Feature/Components/TabsTest.php +++ b/tests/Feature/Components/TabsTest.php @@ -123,6 +123,56 @@ it('keeps the tab bar on M3\'s offsets, indicator inset and focus ring', functio ->toBe(['color' => 'var(--md-sys-color-primary)']); }); +/** + * Plan step 46, M3 § Tabs: "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." Both views render the + * text of a label in its own `data-md-tab-text`, so the one rule in tabs.css clamps it. + */ +it('renders a label\'s text in its own hook, in tabs and section tabs alike', function () { + expect((string) $this->blade('')) + ->toMatch('/\s*People with access<\/span>\s*]*data-md-badge/') + ->and((string) $this->blade('')) + ->toMatch('/\s*Two-factor authentication<\/span>\s*]*data-md-badge/'); +}); + +it('lets a fixed tab\'s label wrap to a second line and truncate there, and a scrollable one keep its row', function () { + $css = ComponentStylesheet::read('tabs'); + + expect($css->declarations('[data-md-tab]')) + // M3's 48px is the tab's minimum: it grows to hold a second line, and the bar stretches the rest. + ->toHaveKey('min-height', '48px') + ->not->toHaveKey('height') + ->toHaveKey('min-width', '90px') + // The single row a scrollable tab's label and every badge keep. + ->toHaveKey('white-space', 'nowrap') + ->and($css->declarations('[data-md-tabs-bar][data-md-stacked] [data-md-tab]')) + ->toBe(['min-height' => '64px']) + // The content fills a grown tab, so a primary indicator at its foot stays on the divider. + ->and($css->declarations('[data-md-tab-content]')) + ->toHaveKey('align-self', 'stretch') + ->not->toHaveKey('height') + ->and($css->declarations('[data-md-tab-label]')) + ->toHaveKey('min-width', '0') + ->and($css->declarations('[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text]')) + ->toBe([ + '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', + ]) + // The ring's room and the state layer still reach past a grown tab to the divider. + ->and($css->declarations('[data-md-tab]::before')) + ->toHaveKey('inset-block', 'calc(-1 * var(--tabs-ring))') + ->and($css->declarations('[data-md-tab-indicator]')) + ->toHaveKey('bottom', 'calc(-1 * var(--tabs-ring))') + ->and(collect($css->rules())->filter(fn (array $rule): bool => str_contains($rule['selector'], 'data-md-tab-text'))->pluck('selector')->all()) + ->toBe(['[data-md-tabs-bar]:not([data-md-scrollable]) [data-md-tab-text]']); +}); + it('hides every panel until Alpine takes over, and stands the panel\'s content clear of the bar', function () { $css = ComponentStylesheet::read('tabs'); @@ -171,7 +221,9 @@ it('marks the section whose url is the request\'s, and scrolls many sections', f // Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16). ->toMatch('/
    /') ->not->toContain('grid-cols-') - ->toMatch('/data-md-tab\s+aria-current="page"\s*>\s*\s*S3/') + // Its label is ``'s anatomy, drawn by tabs.css, not a one-line `md-truncate`. + ->toMatch('/data-md-tab\s+aria-current="page"\s*>\s*\s*\s*S3<\/span>/') + ->not->toMatch('/data-md-tab-content>(?:(?!<\/a>).)*md-truncate/s') ->not->toContain('wire:navigate') // Four still share the row. ->and((string) $this->blade('', ['items' => array_slice($items, 0, 4)]))