Scroll a long section nav instead of wrapping it onto a grid
Five or more sections wrapped onto a 3- or 4-column grid, which left the tab bar's divider under the last row only and stranded every upper row's active indicator against nothing. From five they are now what M3 prescribes and its accessibility page defends: a scrollable tab bar, each tab as wide as its label, offset 52dp. Four or fewer still share the row. The 20px icons take the 20 cut. Plan: docs/plans/material-3-alignment.md, step 21 (navigation N-16). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
a9b5efb4b2
commit
0aef7f1f96
@@ -804,7 +804,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px; wrapping onto a grid rather than a single row below `large`, rather than scrolling), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `medium` (600px), a menu picker on a compact window, whose items mark the current section as the page (`current`) and carry each section's badge. Up to four sections share the row; from five it is M3's scrollable tab bar — tabs as wide as their labels, offset 52dp from the leading edge so it reads as scrollable. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). `label`, `no-wire-navigate`.
|
||||
|
||||
### `<x-account-menu>`
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-tabs-bar][data-scrollable] > li,
|
||||
[data-tabs-bar][data-scrollable] [data-tab] {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
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.
|
||||
|
||||
A row too long for its column wraps onto a grid rather than scrolling: below `large` (1200px)
|
||||
five or six
|
||||
sections go 3 + 3 and seven or more go four to a row — tabs that scroll hid the last sections on
|
||||
a tablet. `label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
Up to four sections share the row as fixed tabs. 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
|
||||
because they need to increase in width to respond to label text without affecting the
|
||||
layout"). An earlier version wrapped them onto a grid instead, which left the bar's divider
|
||||
under the last row only and stranded the upper rows' indicators against nothing.
|
||||
|
||||
`label` names the navigation ("Sections"). Links use `wire:navigate` unless
|
||||
`no-wire-navigate`. --}}
|
||||
|
||||
@props([
|
||||
@@ -31,11 +36,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);
|
||||
|
||||
$layout = match (true) {
|
||||
count($items) < 5 => 'medium:flex',
|
||||
count($items) < 7 => 'medium:grid medium:grid-cols-3 large:flex',
|
||||
default => 'medium:grid medium:grid-cols-4 large:flex',
|
||||
};
|
||||
// Four fit a row at the widths this bar is used at; from five they are M3's scrollable tabs.
|
||||
$scrollable = count($items) >= 5;
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->class(['min-w-0']) }} data-section-nav>
|
||||
@@ -45,7 +47,7 @@
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="focus-ring flex h-12 w-[min(20rem,calc(100vw-2rem))] cursor-pointer items-center gap-3 rounded-corner-xs border border-outline px-4 text-start type-body-lg text-on-surface">
|
||||
@isset($current['icon'])
|
||||
<x-livewire-material::icon :name="$current['icon']" class="size-5 text-on-surface-variant" />
|
||||
<x-livewire-material::icon :name="$current['icon']" optical="20" class="size-5 text-on-surface-variant" />
|
||||
@endisset
|
||||
<span class="min-w-0 flex-1 truncate">{{ $current['title'] }}</span>
|
||||
<x-livewire-material::icon name="arrow_drop_down" class="size-6 text-on-surface-variant" />
|
||||
@@ -60,7 +62,7 @@
|
||||
@endif
|
||||
|
||||
<nav aria-label="{{ $label }}" class="max-medium:hidden">
|
||||
<ul data-tabs-bar data-variant="secondary" class="{{ $layout }}">
|
||||
<ul data-tabs-bar data-variant="secondary" @if ($scrollable) data-scrollable @endif>
|
||||
@foreach ($items as $item)
|
||||
@php($on = $isCurrent($item))
|
||||
|
||||
@@ -73,7 +75,7 @@
|
||||
>
|
||||
<span data-tab-content>
|
||||
@isset($item['icon'])
|
||||
<x-livewire-material::icon :name="$item['icon']" :filled="$on" class="size-5" />
|
||||
<x-livewire-material::icon :name="$item['icon']" :filled="$on" optical="20" class="size-5" />
|
||||
@endisset
|
||||
<span class="truncate">{{ $item['title'] }}</span>
|
||||
@if (filled($item['badge'] ?? null))
|
||||
|
||||
@@ -100,7 +100,7 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
||||
->toContain('aria-label="Settings"')
|
||||
->toContain('data-section-picker')
|
||||
->toContain('data-variant="secondary"')
|
||||
->toContain('medium:flex')
|
||||
->toContain('max-medium:hidden')
|
||||
->toMatch('/href="\/settings\/security"\s+data-tab\s+aria-current="page"\s+wire:navigate/')
|
||||
->not->toMatch('/href="\/settings\/profile"\s+data-tab\s+aria-current/')
|
||||
// The picker is a menu of places: the current one is the page, not a checked choice,
|
||||
@@ -110,15 +110,20 @@ it('draws section navigation as secondary tabs and a picker', function () {
|
||||
->toMatch('/data-section-picker.*Security.*>\s*1\s*<.*<nav/s');
|
||||
});
|
||||
|
||||
it('marks the section whose url is the request\'s, and wraps many sections onto a grid', function () {
|
||||
it('marks the section whose url is the request\'s, and scrolls many sections', function () {
|
||||
$this->get('/');
|
||||
|
||||
$items = collect(range(1, 7))->map(fn (int $n): array => ['title' => "S{$n}", 'url' => $n === 3 ? url('/') : "/s/{$n}"])->all();
|
||||
|
||||
expect((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => $items]))
|
||||
->toContain('medium:grid medium:grid-cols-4 large:flex')
|
||||
// Five or more sections are M3's scrollable tabs, not a grid of wrapped rows (N-16).
|
||||
->toMatch('/<ul data-tabs-bar data-variant="secondary"\s+data-scrollable\s*>/')
|
||||
->not->toContain('grid-cols-')
|
||||
->toMatch('/data-tab\s+aria-current="page"\s*>\s*<span data-tab-content>\s*<span class="truncate">S3/')
|
||||
->not->toContain('wire:navigate');
|
||||
->not->toContain('wire:navigate')
|
||||
// Four still share the row.
|
||||
->and((string) $this->blade('<x-section-nav :items="$items" no-wire-navigate />', ['items' => array_slice($items, 0, 4)]))
|
||||
->not->toContain('data-scrollable');
|
||||
});
|
||||
|
||||
it('keeps the page\'s section current while a Livewire component on it updates', function () {
|
||||
|
||||
Reference in New Issue
Block a user