Wrap a fixed tab's long label to a second line

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." In fixed tabs (<x-tabs>, <x-section-nav>
up to four sections) the label's text, now its own data-md-tab-text in
both views, wraps and truncates at two lines. The tab's 48px (64px
stacked) is a minimum, the bar stretches its tabs to one height, and the
content stretches with them, so the indicator stays on the divider.
Scrollable tabs keep one row. SealShare's "Two-Factor Auth" and
"Appearance" showed an ellipsis at 600px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 16:10:13 +02:00
co-authored by Claude Opus 5
parent 0a028b158f
commit 5b05e52e1c
6 changed files with 152 additions and 20 deletions
+5 -2
View File
@@ -16,8 +16,11 @@
* `aria-current="page"` takes the same active indicator and colour tabs.css already draws for * `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 * `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 * 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 `<x-tabs>`'s buttons, * applies, and `min-inline-size: 0`, so a long title in a fixed row wraps to its second line and
* direct children of the bar, never need. The picker's trigger is not one of M3's controls * truncates there (tabs.css, M3's "may wrap to a max second line if needed with truncation")
* instead of widening its item — which `<x-tabs>`'s buttons, direct children of the bar, never
* need. Its tabs render `<x-tabs>`'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 * — 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 * 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). * md-ink md-text-start`, since it is prose on a plain button rather than a component's own type).
+31 -5
View File
@@ -1,8 +1,8 @@
/* /*
* `<x-tabs>`, `<x-tab>`: M3's tabs (PrimaryNavigationTabTokens.kt, SecondaryNavigationTabTokens.kt, * `<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 * 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 * § Tabs) — a tablist of tabs at least 48px tall on the surface over an outline-variant divider
* stacks its icon over its label), title-small labels in on-surface-variant, the chosen tab's in * (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). * primary (primary tabs) or on-surface (secondary tabs).
* *
* [data-md-tabs] min-width: 0 * [data-md-tabs] min-width: 0
@@ -11,8 +11,18 @@
* [data-md-tab] role="tab" * [data-md-tab] role="tab"
* [data-md-tab-content] [data-md-icon] (optional), [data-md-tab-label], * [data-md-tab-content] [data-md-icon] (optional), [data-md-tab-label],
* [data-md-tab-indicator] * [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) * [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 * 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 * 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. * (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; display: flex;
flex: 1 1 0%; flex: 1 1 0%;
min-width: 90px; min-width: 90px;
height: 48px; min-height: 48px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-inline: var(--md-sys-measurement-space200); padding-inline: var(--md-sys-measurement-space200);
@@ -94,7 +104,7 @@
} }
[data-md-tabs-bar][data-md-stacked] [data-md-tab] { [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. */ /* 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; 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] { [data-md-tab-content] {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
height: 100%; align-self: stretch;
min-width: 24px; min-width: 24px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -166,10 +178,24 @@
[data-md-tab-label] { [data-md-tab-label] {
display: inline-flex; display: inline-flex;
min-width: 0;
align-items: center; align-items: center;
gap: var(--md-sys-measurement-space75); 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 /* 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. */ the ring's room has pushed below the tab. */
[data-md-tab-indicator] { [data-md-tab-indicator] {
@@ -13,7 +13,11 @@
The page's URL is `Livewire::originalUrl()`: while a Livewire component on the page updates, the 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. 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 `<x-tabs>`'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 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 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 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'])); $isCurrent = fn (array $item): bool => ($item['active'] ?? false) || (filled($item['url'] ?? null) && $page === url($item['url']));
$current = collect($items)->first($isCurrent) ?? ($items[0] ?? null); $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; $scrollable = count($items) >= 5;
@endphp @endphp
@@ -77,10 +82,12 @@
@isset($item['icon']) @isset($item['icon'])
<x-livewire-material::icon :name="$item['icon']" :filled="$on" :size="20" /> <x-livewire-material::icon :name="$item['icon']" :filled="$on" :size="20" />
@endisset @endisset
<span class="md-truncate">{{ $item['title'] }}</span> <span data-md-tab-label>
<span data-md-tab-text>{{ $item['title'] }}</span>
@if (filled($item['badge'] ?? null)) @if (filled($item['badge'] ?? null))
<x-livewire-material::badge :value="$item['badge']" /> <x-livewire-material::badge :value="$item['badge']" />
@endif @endif
</span>
<span data-md-tab-indicator aria-hidden="true"></span> <span data-md-tab-indicator aria-hidden="true"></span>
</span> </span>
</a> </a>
+8 -4
View File
@@ -12,12 +12,16 @@
the label, in primary) or `secondary` (under the whole tab, for tabs inside a section); 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 `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 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`, 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-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 `data-md-tab-content`, `data-md-tab-label` around the label's `data-md-tab-text` and its badge,
badge.css for what the view renders. --}} and `data-md-tab-indicator`). Imports icon.css and badge.css for what the view renders. --}}
@props([ @props([
'tabs' => [], 'tabs' => [],
@@ -84,7 +88,7 @@
<x-livewire-material::icon :name="$tab['icon']" /> <x-livewire-material::icon :name="$tab['icon']" />
@endisset @endisset
<span data-md-tab-label> <span data-md-tab-label>
{{ $tab['label'] }} <span data-md-tab-text>{{ $tab['label'] }}</span>
@if (filled($tab['badge'] ?? null)) @if (filled($tab['badge'] ?? null))
<x-livewire-material::badge :value="$tab['badge']" /> <x-livewire-material::badge :value="$tab['badge']" />
@endif @endif
+42 -2
View File
@@ -54,7 +54,7 @@ class BarsProbe extends Component
<x-theme-toggle mode="cycle" id="cycle" /> <x-theme-toggle mode="cycle" id="cycle" />
<x-theme-toggle mode="picker" /> <x-theme-toggle mode="picker" />
<x-section-nav :items="[['title' => 'Profile', 'url' => '#profile', 'active' => true], ['title' => 'Security', 'url' => '#security'], ['title' => 'Advanced notification settings', 'url' => '#notifications']]" no-wire-navigate /> <x-section-nav :items="[['title' => 'Profile', 'url' => '#profile', 'active' => true], ['title' => 'Security', 'url' => '#security'], ['title' => 'Advanced notification settings', 'url' => '#notifications'], ['title' => 'Appearance', 'url' => '#appearance']]" no-wire-navigate />
<div style="height: 200vh"></div> <div style="height: 200vh"></div>
</div> </div>
@@ -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))"; $widths = "[...document.querySelectorAll('[data-md-section-nav] [data-md-tabs-bar] > li')].map((li) => Math.round(li.getBoundingClientRect().width))";
barsProbe() barsProbe()
->assertScript("{$widths}.length === 3") ->assertScript("{$widths}.length === 4")
->assertScript("new Set({$widths}).size === 1") ->assertScript("new Set({$widths}).size === 1")
->assertNoJavaScriptErrors(); ->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 class AppBarOverflowProbe extends Component
{ {
public int $stars = 0; public int $stars = 0;
+53 -1
View File
@@ -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)']); ->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('<x-tabs :tabs="[[\'name\' => \'people\', \'label\' => \'People with access\', \'badge\' => 3]]" />'))
->toMatch('/<span data-md-tab-label>\s*<span data-md-tab-text>People with access<\/span>\s*<span[^>]*data-md-badge/')
->and((string) $this->blade('<x-section-nav :items="[[\'title\' => \'Two-factor authentication\', \'url\' => \'/security\', \'badge\' => 1]]" no-wire-navigate />'))
->toMatch('/<span data-md-tab-label>\s*<span data-md-tab-text>Two-factor authentication<\/span>\s*<span[^>]*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 () { it('hides every panel until Alpine takes over, and stands the panel\'s content clear of the bar', function () {
$css = ComponentStylesheet::read('tabs'); $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). // Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/') ->toMatch('/<ul data-md-tabs-bar data-md-variant="secondary"\s+data-md-scrollable\s*>/')
->not->toContain('grid-cols-') ->not->toContain('grid-cols-')
->toMatch('/data-md-tab\s+aria-current="page"\s*>\s*<span data-md-tab-content>\s*<span class="md-truncate">S3/') // Its label is `<x-tabs>`'s anatomy, drawn by tabs.css, not a one-line `md-truncate`.
->toMatch('/data-md-tab\s+aria-current="page"\s*>\s*<span data-md-tab-content>\s*<span data-md-tab-label>\s*<span data-md-tab-text>S3<\/span>/')
->not->toMatch('/data-md-tab-content>(?:(?!<\/a>).)*md-truncate/s')
->not->toContain('wire:navigate') ->not->toContain('wire:navigate')
// Four still share the row. // Four still share the row.
->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)])) ->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)]))