Overflow an app bar's trailing actions below medium

Plan step 25, item 7 (navigation Missing: "App bar, trailing-action
overflow at small widths"). `<x-app-bar :actions="[...]">` takes the
trailing actions as a list whose entries take <x-menu-item>'s props plus
any action attribute. M3 allows up to two trailing icon buttons, and up
to four on larger screens, and lets trailing actions collapse into an
overflow menu at smaller breakpoints: below medium the bar keeps at most
two icon buttons, from medium four, the "More options" button counted
among them, the rest in its menu. Both forms render and media queries
pick one, so there is no script and no flash; each width has its own
menu so the arrow keys never reach a hidden row. The actions slot still
takes markup and never overflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 10:57:50 +02:00
co-authored by Claude Opus 5
parent 12cdeaaf67
commit 092100c6ae
5 changed files with 223 additions and 3 deletions
+81
View File
@@ -44,6 +44,87 @@ it('falls back to a small bar and an h1 for unknown values', function () {
->toContain('<h1 data-app-bar-title>');
});
it('keeps two trailing icon buttons below medium and four from it, the rest in an overflow menu', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-bar title="Shares" :actions="[
['label' => 'Search', 'icon' => 'search', 'x-on:click' => 'find()'],
['label' => 'Share', 'icon' => 'share', 'link' => '/shares/1/share'],
['label' => 'Star', 'icon' => 'star', 'selected' => true, 'wire:click' => 'star'],
['label' => 'Archive', 'icon' => 'archive', 'wire:click' => 'archive'],
['label' => 'Delete', 'icon' => 'delete', 'disabled' => true],
]" />
BLADE);
preg_match('/data-app-bar-overflow="compact">(.*?)data-app-bar-overflow="medium">(.*)$/s', $html, $menus);
[, $compact, $medium] = $menus + [null, '', ''];
expect($html)
// M3's "up to four trailing icons" on a larger screen, the overflow button one of them: the
// first three are icon buttons, and the first stays one on a compact window too, beside the
// overflow button — M3's "up to two icon buttons" (navigation reference § Top app bar).
->and(substr_count($html, '<span data-app-bar-action'))->toBe(3)
->and(substr_count($html, 'data-overflows-compact'))->toBe(2)
->and($html)->toMatch('/<span data-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
->toMatch('/<span data-app-bar-action\s+data-overflows-compact\s*>\s*<a href="\/shares\/1\/share"[^>]*aria-label="Share"/')
->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/')
// Every icon button is named, tooltipped and reaches 48px.
->toMatch('/<button[^>]*aria-label="Search"[^>]*class="[^"]*touch-target/')
->toMatch('/popover="manual"[^>]*>\s*Search<\/span>/')
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
// One overflow button per width, named and tooltipped.
->and(preg_match_all('/<button[^>]*aria-label="More options"[^>]*class="[^"]*touch-target/', $html))->toBe(2)
->and(preg_match_all('/role="menu"\s+data-menu\s+aria-label="More options"/', $html))->toBe(2)
// Below medium: all but the first.
->and($compact)
->toMatch('/<a role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
->toMatch('/role="menuitemcheckbox" aria-checked="true"[^>]*wire:click="star"/')
->toMatch('/role="menuitem"[^>]*wire:click="archive"/')
->toMatch('/role="menuitem" aria-disabled="true"/')
->not->toContain('find()')
// From medium: only what the four do not hold.
->and($medium)
->toContain('Archive')
->toContain('Delete')
->not->toContain('Star')
->not->toContain('/shares/1/share')
// The action goes with the entry to both of its forms.
->and(substr_count($html, 'wire:click="star"'))->toBe(2)
->and(substr_count($html, 'wire:click="archive"'))->toBe(2)
->and(substr_count($html, 'x-on:click="find()"'))->toBe(1);
});
it('adds an overflow menu only at the widths something overflows', function () {
$actions = fn (int $count): array => collect(['search', 'share', 'star', 'archive', 'delete'])
->take($count)
->map(fn (string $icon): array => ['label' => ucfirst($icon), 'icon' => $icon])
->all();
$two = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(2)]);
$four = (string) $this->blade('<x-app-bar title="Shares" :actions="$actions" />', ['actions' => $actions(4)]);
expect($two)
->not->toContain('data-app-bar-overflow')
->not->toContain('data-overflows-compact')
->not->toContain('More options')
->and(substr_count($two, '<span data-app-bar-action'))->toBe(2)
->and($four)
->toContain('data-app-bar-overflow="compact"')
->not->toContain('data-app-bar-overflow="medium"')
->and(substr_count($four, '<span data-app-bar-action'))->toBe(4)
->and(substr_count($four, 'data-overflows-compact'))->toBe(3)
// An empty list draws no trailing area; markup in the slot is drawn as written, never
// overflowing.
->and((string) $this->blade('<x-app-bar title="Shares" :actions="[]" />'))
->not->toContain('data-app-bar-trailing')
->and((string) $this->blade('<x-app-bar title="Shares"><x-slot:actions><button>a</button><button>b</button><button>c</button></x-slot:actions></x-app-bar>'))
->toContain('<div data-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
->not->toContain('data-app-bar-overflow');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
->toMatch('/@media \(width < 37\.5rem\) \{\s+\[data-app-bar-action\]\[data-overflows-compact\],\s+\[data-app-bar-overflow="medium"\] \{\s+display: none;/')
->toMatch('/@media \(width >= 37\.5rem\) \{\s+\[data-app-bar-overflow="compact"\] \{\s+display: none;/');
});
it('draws floating and docked toolbars', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-toolbar label="Formatting" vibrant vertical place="end">