Plan step 36 (navigation group, third batch): <x-theme-toggle>'s icon button (mode="toggle"|"cycle") moves into resources/css/components/ theme-toggle.css, keyed on data-md-theme-toggle="toggle"|"cycle" and still carrying data-md-icon-button, the hook toolbar.css matches generically so the toggle takes a toolbar's own icon colour when placed in one (N-13). The button renders the shared md-state-layer/ md-touch-target/md-focus-ring classes (N-01's 48px target); its corner morphs from full to sm while pressed on the fast spatial spring, the same motion <x-button>'s own icon buttons take. mode="picker"|"contrast" already drew a connected group over native radios (N-04, N-17's fix), so only its wrapper's hook needed renaming. Hook renamed: data-theme-toggle to data-md-theme-toggle, updated in AppBarTest.php, which carries theme-toggle's render tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
305 lines
16 KiB
PHP
305 lines
16 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-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([aria-pressed=\'true\'])'))
|
|
->toBe(['color' => 'var(--md-sys-color-primary)']);
|
|
});
|
|
|
|
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');
|
|
});
|