')
->toContain('role="toolbar"')
->toContain('materialToolbar(true)')
->toContain('data-md-variant="floating"')
->toContain('aria-orientation="vertical"')
->toContain('data-md-vibrant')
->toContain('aria-label="Formatting"')
->toContain('
+ ')
->and((string) $this->blade('
B '))
->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'
Draw
+
BLADE);
expect($html)
// Inside the toolbar, after its controls, where the arrow keys reach it too.
->toMatch('/role="toolbar".*
Draw<\/button>\s*\+<\/button><\/div>\s*<\/div>/s')
->not->toContain('data-md-toolbar-group')
->and((string) $this->blade('Draw '))
->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(' '))
->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(' '))
->toContain('aria-label="Theme"')
->not->toContain('and((string) $this->blade(' '))
->toContain('aria-label="Appearance"')
->toContain('Appearance ');
});
it('switches the theme through the store in three shapes', function () {
expect((string) $this->blade(' '))
->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(' '))
->toContain('data-md-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade(' '))
->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 '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'
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(' '))
->toContain(' ')
->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');
});