Files
livewire-material/tests/Feature/Components/AppBarTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 092100c6ae 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
2026-09-14 10:57:50 +02:00

264 lines
14 KiB
PHP

<?php
it('draws a small, sticky app bar with a heading, navigation and actions', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-app-bar title="Shares" subtitle="12 active">
<x-slot:navigation><button>menu</button></x-slot:navigation>
<x-slot:actions><button>search</button></x-slot:actions>
</x-app-bar>
BLADE);
expect($html)
->toContain('<header')
->toContain('x-data="materialAppBar"')
->toContain('data-variant="small"')
->toContain('data-sticky')
->toContain('data-subtitled')
->toContain('<h1 data-app-bar-title>Shares</h1>')
->toContain('<p data-app-bar-subtitle>12 active</p>')
->toContain('<div data-app-bar-leading><button>menu</button></div>')
->toContain('<div data-app-bar-trailing><button>search</button></div>');
});
it('gives a medium or large bar a collapsed title in its row and the heading under it', function () {
$html = (string) $this->blade('<x-app-bar variant="large" title="Settings" heading="h2" :sticky="false" />');
expect($html)
->toContain('data-variant="large"')
->not->toContain('data-sticky')
->toContain('<div data-app-bar-headline aria-hidden="true" >')
->toContain('<span data-app-bar-title>Settings</span>')
->toContain('<div data-app-bar-expanded>')
->toContain('<h2 data-app-bar-title>Settings</h2>');
});
it('puts a search bar in the row of a search app bar', function () {
expect((string) $this->blade('<x-app-bar variant="search"><x-search placeholder="Search shares" /></x-app-bar>'))
->toContain('data-app-bar-search')
->toContain('placeholder="Search shares"');
});
it('falls back to a small bar and an h1 for unknown values', function () {
expect((string) $this->blade('<x-app-bar variant="huge" heading="section" title="Shares" />'))
->toContain('data-variant="small"')
->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">
<button>B</button>
<x-slot:fab><button>+</button></x-slot:fab>
</x-toolbar>
BLADE);
expect($html)
->toContain('<div data-toolbar-group data-vertical data-toolbar-place="end" >')
->toContain('role="toolbar"')
->toContain('materialToolbar(true)')
->toContain('data-variant="floating"')
->toContain('aria-orientation="vertical"')
->toContain('data-vibrant')
->toContain('aria-label="Formatting"')
->toContain('<button>+</button>')
->and((string) $this->blade('<x-toolbar variant="docked" vibrant vertical place="bottom"><button>B</button></x-toolbar>'))
->toContain('data-variant="docked"')
->not->toContain('data-vibrant')
->not->toContain('data-vertical')
->toContain('data-toolbar-place="bottom"')
->not->toContain('data-toolbar-group');
});
it('sets a FAB at the end of a docked toolbar, resting flat on it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-toolbar variant="docked" label="Editing">
<button>Draw</button>
<x-slot:fab><button>+</button></x-slot:fab>
</x-toolbar>
BLADE);
expect($html)
// Inside the toolbar, after its controls, where the arrow keys reach it too.
->toMatch('/role="toolbar".*<button>Draw<\/button>\s*<div data-toolbar-fab><button>\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-toolbar-group')
->and((string) $this->blade('<x-toolbar variant="docked"><button>Draw</button></x-toolbar>'))
->not->toContain('data-toolbar-fab');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css'))
->toMatch('/\[data-toolbar-fab\] \{\s+display: flex;\s+margin-inline-start: auto;/')
// A nested FAB rests at elevation 0.
->toMatch('/\[data-toolbar-fab\] \[data-fab\],\s+\[data-toolbar-fab\] \[data-fab\]:hover \{\s+box-shadow: none;/');
});
it('centres a headline on a three-column row and curves the search container', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css');
expect($css)
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
->not->toContain('inset-inline: 3.5rem;')
->toMatch('/\[data-variant="center"\] \[data-app-bar-row\] \{\s+display: grid;\s+grid-template-columns: 1fr auto 1fr;/')
// 312dp, then half of what is left (N-09).
->toContain('max-width: calc(19.5rem + (100% - 19.5rem) / 2);');
});
it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css');
expect($css)
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
->toContain('bottom: calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + 1rem);')
->toContain('bottom: var(--material-bottom-bar, 0px);')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
->toContain('inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));')
// A standard toolbar's standard buttons are primary (N-13).
->toContain('[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {');
});
it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-theme-toggle="contrast"')
->toContain('aria-label="Contrast"')
// A connected button group over native radios, not the deprecated segmented button (N-04).
->toContain('data-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
->toContain('value="medium"')
->toContain('value="high"')
->toContain('$store.theme.setContrast(value)')
// The row follows the resolved level, so the operating system's shows under `system`.
->toContain('return this.$store.theme.resolvedContrast');
});
it('names a theme row for a screen reader and legends it on request', function () {
expect((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('aria-label="Theme"')
->not->toContain('<legend')
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
->toContain('aria-label="Appearance"')
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade('<x-theme-toggle />'))
->toContain('data-theme-toggle="toggle"')
->toContain('aria-label="Dark theme"')
// 40px drawn, 48px reached: M3's minimum target (N-01).
->toContain('touch-target')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
->toContain('data-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('data-theme-toggle="picker"')
->toContain('x-model="chosen"')
->toContain('name="material-theme"')
->toContain('value="system"')
->toContain('$store.theme.set(value)');
});
it('opens an account menu from the initials of a name', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-account-menu name="Anna Maria Keller" email="anna@example.com">
<x-menu-item label="Settings" />
<x-slot:footer><x-menu-item label="Sign out" /></x-slot:footer>
</x-account-menu>
BLADE);
expect($html)
->toContain('aria-label="Account"')
->toContain('data-account-menu')
// The 40px avatar reaches 48px, and nothing clips the pseudo-target (N-01); the trigger
// has a hover and a pressed state like every other trigger in the library (N-15).
->toContain('state-layer touch-target focus-ring')
->not->toContain('overflow-hidden')
->toMatch('/>\s*AM\s*<\/button>/')
->toContain('anna@example.com')
->toContain('Settings')
->toContain('data-account-theme')
->toContain('Sign out')
->and((string) $this->blade('<x-account-menu avatar="/anna.jpg" :theme="false" />'))
->toContain('<img src="/anna.jpg" alt="" class="relative -z-20 size-full rounded-corner-full object-cover" />')
->not->toContain('data-account-theme');
});