Files
livewire-material/tests/Feature/Components/AppBarTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 7db522826b Fade and shrink popovers out in every engine, Firefox included
Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:23 +02:00

341 lines
20 KiB
PHP

<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
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-md-variant="small"')
->toContain('data-md-sticky')
->toContain('data-md-subtitled')
->toContain('<h1 data-md-app-bar-title>Shares</h1>')
->toContain('<p data-md-app-bar-subtitle>12 active</p>')
->toContain('<div data-md-app-bar-leading><button>menu</button></div>')
->toContain('<div data-md-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-md-variant="large"')
->not->toContain('data-md-sticky')
->toContain('<div data-md-app-bar-headline aria-hidden="true" >')
->toContain('<span data-md-app-bar-title>Settings</span>')
->toContain('<div data-md-app-bar-expanded>')
->toContain('<h2 data-md-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-md-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-md-variant="small"')
->toContain('<h1 data-md-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-md-app-bar-overflow="compact">(.*?)data-md-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-md-app-bar-action'))->toBe(3)
->and(substr_count($html, 'data-md-overflows-compact'))->toBe(2)
->and($html)->toMatch('/<span data-md-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
->toMatch('/<span data-md-app-bar-action\s+data-md-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[^>]*data-md-size="sm"[^>]*aria-label="Search"/')
->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[^>]*data-md-size="sm"[^>]*aria-label="More options"/', $html))->toBe(2)
->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-popover-exit\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2)
// Below medium: all but the first.
->and($compact)
->toMatch('/<a class="md-state-layer md-focus-ring" data-md-menu-item[^>]*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-md-app-bar-overflow')
->not->toContain('data-md-overflows-compact')
->not->toContain('More options')
->and(substr_count($two, '<span data-md-app-bar-action'))->toBe(2)
->and($four)
->toContain('data-md-app-bar-overflow="compact"')
->not->toContain('data-md-app-bar-overflow="medium"')
->and(substr_count($four, '<span data-md-app-bar-action'))->toBe(4)
->and(substr_count($four, 'data-md-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-md-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-md-app-bar-trailing><button>a</button><button>b</button><button>c</button></div>')
->not->toContain('data-md-app-bar-overflow');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
->toMatch('/@media \(width < 600px\) \{\s+\[data-md-app-bar-action\]\[data-md-overflows-compact\],\s+\[data-md-app-bar-overflow=\'medium\'\] \{\s+display: none;/')
->toMatch('/@media \(width >= 600px\) \{\s+\[data-md-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-md-toolbar-group data-md-vertical data-md-toolbar-place="end" >')
->toContain('role="toolbar"')
->toContain('materialToolbar(true)')
->toContain('data-md-variant="floating"')
->toContain('aria-orientation="vertical"')
->toContain('data-md-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-md-variant="docked"')
->not->toContain('data-md-vibrant')
->not->toContain('data-md-vertical')
->toContain('data-md-toolbar-place="bottom"')
->not->toContain('data-md-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-md-toolbar-fab><button>\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-md-toolbar-group')
->and((string) $this->blade('<x-toolbar variant="docked"><button>Draw</button></x-toolbar>'))
->not->toContain('data-md-toolbar-fab');
$css = ComponentStylesheet::read('toolbar');
expect($css->declarations('[data-md-toolbar-fab]'))->toBe(['display' => 'flex', 'margin-inline-start' => 'auto'])
// A nested FAB rests at elevation 0.
->and($css->declarations('[data-md-toolbar-fab] [data-md-fab], [data-md-toolbar-fab] [data-md-fab]:hover'))
->toBe(['box-shadow' => 'none']);
});
it('centres a headline on a three-column row and curves the search container', function () {
$css = ComponentStylesheet::read('app-bar');
expect(file_get_contents(__DIR__.'/../../../resources/css/components/app-bar.css'))
// Not a fixed 56px inset, which fits exactly one of M3's two trailing buttons (N-11).
->not->toContain('inset-inline: 56px;')
->and($css->declarations('[data-md-app-bar][data-md-variant=\'center\'] [data-md-app-bar-row]'))
->toBe(['display' => 'grid', 'grid-template-columns' => '1fr auto 1fr'])
// 312dp, then half of what is left (N-09).
->and($css->declarations('[data-md-app-bar-search]'))
->toHaveKey('max-width', 'calc(312px + (100% - 312px) / 2)');
});
it('collapses a medium or large bar into its row at the breakpoint its title collapses to', function () {
$css = ComponentStylesheet::read('app-bar');
// Sticks at a negative top (the bar minus its measured height) so the row alone remains.
expect($css->declarations('[data-md-app-bar][data-md-sticky]:is([data-md-variant=\'medium\'], [data-md-variant=\'large\'])'))
->toBe(['top' => 'calc(var(--app-bar-row) - var(--app-bar-measured, var(--app-bar-height)))'])
->and($css->declarations('[data-md-app-bar-action][data-md-overflows-compact], [data-md-app-bar-overflow=\'medium\']', ['@media (width < 600px)']))
->toBe(['display' => 'none'])
->and($css->declarations('[data-md-app-bar-overflow=\'compact\']', ['@media (width >= 600px)']))
->toBe(['display' => 'none']);
});
it('keeps a toolbar at the bottom clear of the navigation bar', function () {
$css = ComponentStylesheet::read('toolbar');
expect($css->declarations('[data-md-toolbar-place=\'bottom\']'))
// Never a sum: --material-bottom-bar already swallows the bottom safe area (N-02).
->toHaveKey('bottom', 'calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200))')
->and($css->declarations('[data-md-toolbar][data-md-variant=\'docked\'][data-md-toolbar-place=\'bottom\']'))
->toHaveKey('bottom', 'var(--material-bottom-bar, 0px)')
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
->and($css->declarations('[data-md-toolbar][data-md-vertical][data-md-toolbar-place=\'end\'], [data-md-toolbar-group][data-md-vertical][data-md-toolbar-place=\'end\']'))
->toBe(['inset-inline-end' => 'calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)))'])
// A standard toolbar's standard buttons are primary (N-13).
->and($css->declarations('[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]:not([data-md-variant=\'filled\'], [data-md-variant=\'tonal\'], [data-md-selected=\'true\'], [aria-pressed=\'true\'], :disabled, [aria-disabled=\'true\'])'))
->toBe(['color' => 'var(--md-sys-color-primary)']);
// N-13 names the *standard* button, so a button that fills its own container keeps the label
// colour that goes with that fill: a filled button painted primary put a primary icon on a
// primary fill, and the current page's button in a navigation toolbar came out a blank circle.
foreach (['[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]', '[data-md-toolbar][data-md-vibrant] [data-md-icon-button]'] as $scope) {
preg_match_all('/'.preg_quote($scope, '/').':not\([^{]*\{[^}]*\bcolor:/', $css->css, $rules);
expect($rules[0])->not->toBeEmpty()
->each->toContain("[data-md-variant='filled']", "[data-md-variant='tonal']", "[data-md-selected='true']");
}
// A disabled icon button keeps button.css's disabled ink in a toolbar of either colour.
foreach (['[data-md-toolbar]:not([data-md-vibrant]) [data-md-icon-button]', '[data-md-toolbar][data-md-vibrant] [data-md-icon-button]'] as $scope) {
preg_match_all('/'.preg_quote($scope, '/').'[^{]*\{[^}]*\bcolor:/', $css->css, $rules);
expect($rules[0])->not->toBeEmpty()
->each->toContain(":disabled, [aria-disabled='true']");
}
});
it('publishes what a toolbar at the bottom covers', function () {
$toolbar = ComponentStylesheet::read('toolbar');
$rule = fn (string $has): string => ":root:has({$has}), [data-md-scaffold]:has({$has})";
$above = fn (string $height): array => ['--material-bottom-toolbar' => "calc(max(var(--material-bottom-bar, 0px), var(--material-safe-bottom, env(safe-area-inset-bottom))) + var(--md-sys-measurement-space200) + {$height})"];
$group = "[data-md-toolbar-group][data-md-toolbar-place='bottom']:not([data-md-vertical])";
// A floating toolbar (or its group) sits 16px above the bar or the safe area and is 64px tall;
// beside a medium or large FAB, the group is as tall as the FAB. Declared on the scaffold's root
// too, the one place the formula sees --material-bottom-bar.
expect($toolbar->declarations($rule("[data-md-toolbar-place='bottom']:is([data-md-toolbar], [data-md-toolbar-group]):not([data-md-vertical])")))->toBe($above('64px'))
->and($toolbar->declarations($rule("{$group} [data-md-fab][data-md-size='md']")))->toBe($above('80px'))
->and($toolbar->declarations($rule("{$group} [data-md-fab][data-md-size='lg']")))->toBe($above('96px'))
// A docked one rests on the bar and pads the safe area; rounded, it floats from 840px.
->and($toolbar->declarations($rule("[data-md-toolbar][data-md-variant='docked'][data-md-toolbar-place='bottom']")))
->toBe(['--material-bottom-toolbar' => 'calc(var(--material-bottom-bar, 0px) + 64px + var(--material-safe-bottom, env(safe-area-inset-bottom)))'])
->and($toolbar->declarations($rule("[data-md-toolbar][data-md-variant='docked'][data-md-rounded][data-md-toolbar-place='bottom']"), ['@media (width >= 840px)']))->toBe($above('64px'));
});
it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-md-theme-toggle="contrast"')
->toContain('aria-label="Contrast"')
// A connected button group over native radios, not the deprecated segmented button (N-04).
->toContain('data-md-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 data-md-group-legend>Appearance</legend>');
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade('<x-theme-toggle />'))
->toContain('data-md-theme-toggle="toggle"')
->toContain('data-md-icon-button')
->toContain('aria-label="Dark theme"')
// 40px drawn, 48px reached: M3's minimum target (N-01).
->toContain('md-touch-target')
->toContain('$store.theme.toggle()')
->and((string) $this->blade('<x-theme-toggle mode="cycle" />'))
->toContain('data-md-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('data-md-theme-toggle="picker"')
->toContain('x-model="chosen"')
->toContain('name="material-theme"')
->toContain('value="system"')
->toContain('$store.theme.set(value)');
$css = ComponentStylesheet::read('theme-toggle');
// The corner morphs from full to sm while pressed, the same motion <x-button>'s own icon
// buttons take (button.css), on the fast spatial spring.
expect($css->declarations("[data-md-theme-toggle='toggle'], [data-md-theme-toggle='cycle']"))
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
->and($css->declarations("[data-md-theme-toggle='toggle']:active, [data-md-theme-toggle='cycle']:active"))
->toBe(['border-radius' => 'var(--md-sys-shape-corner-sm)']);
});
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-md-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('md-state-layer md-touch-target md-focus-ring')
->not->toContain('overflow-hidden')
->toMatch('/>\s*AM\s*<\/button>/')
->toContain('anna@example.com')
->toContain('Settings')
->toContain('data-md-account-menu-theme')
->toContain('Sign out')
->and((string) $this->blade('<x-account-menu avatar="/anna.jpg" :theme="false" />'))
->toContain('<img src="/anna.jpg" alt="" data-md-account-menu-avatar />')
->not->toContain('data-md-account-menu-theme');
$css = ComponentStylesheet::read('account-menu');
expect($css->declarations('[data-md-account-menu]'))
->toHaveKey('inline-size', 'var(--md-sys-measurement-space500)')
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-full)')
->and($css->declarations('[data-md-account-menu-avatar]'))
// Behind the state layer's ::before (z-index -1), so a hover or a press still washes over
// the picture instead of under it (N-15).
->toHaveKey('z-index', '-2');
});