blade(<<<'BLADE'
BLADE);
preg_match('/anchor-name: (--material-menu-([a-z0-9]+))/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialMenu"')
->toContain('data-md-menu')
->toContain('')
->toContain('popover="auto"')
->toContain('role="menu"')
->toContain('data-md-menu-popover')
->toContain('aria-label="Share actions"')
->toContain("id=\"material-menu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}")
->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='bottom-start']"))->toBe([
'transform-origin' => 'top',
'position-area' => 'bottom span-right',
]);
});
it('scrolls a menu too long for the window instead of running off it', function () {
expect((string) $this->blade(''))
->toContain('data-md-menu-popover')
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover]'))->toMatchArray([
'max-block-size' => 'min(288px, calc(100dvh - 32px))',
'overflow-y' => 'auto',
]);
});
it('opens at the position asked for, in the vibrant colours on request', function () {
$html = (string) $this->blade('');
expect($html)->toContain('data-md-position="top-end"')->toContain('data-md-vibrant')
->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='top-end']"))->toBe([
'transform-origin' => 'bottom',
'position-area' => 'top span-left',
])
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover][data-md-vibrant]'))->toBe([
'--material-menu-surface' => 'var(--md-sys-color-tertiary-container)',
'--material-menu-ink' => 'var(--md-sys-color-on-tertiary-container)',
]);
});
it('draws an item as a menuitem button', function () {
$html = (string) $this->blade('');
expect($html)
->toContain('data-md-menu-item')
->toContain('role="menuitem"')
->toContain('tabindex="-1"')
->toContain('type="button"')
->toContain('wire:click="download"')
->toContain('Download')
->toContain('⌘D')
->toContain('As a ZIP')
->toContain('data-md-menu-item-description')
->toContain('data-md-menu-item-shortcut');
});
it('makes a selectable item a menuitemcheckbox, ticked at its end', function () {
$check = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/check.svg'));
$check = substr($check, (int) strpos($check, '><') + 1);
expect((string) $this->blade(''))
->toContain('role="menuitemcheckbox"')
->toContain('aria-checked="true"')
->toContain('data-md-keep-open')
->toContain($check)
->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-checked='true']"))->toMatchArray([
'background-color' => 'var(--md-sys-color-tertiary-container)',
'color' => 'var(--md-sys-color-on-tertiary-container)',
])
->and((string) $this->blade(''))
->toContain('aria-checked="false"')
->not->toContain($check)
->and((string) $this->blade(''))
->not->toContain($check);
});
it('gives an item M3\'s 48px row and 16px sides, and the separator its 8px', function () {
expect((string) $this->blade(''))->toContain('data-md-menu-item')
->and(ComponentStylesheet::read('menu-item')->declarations('[data-md-menu-item]'))->toMatchArray([
'min-block-size' => 'var(--md-sys-measurement-space600)',
'padding-inline' => 'var(--md-sys-measurement-space200)',
])
->and(trim((string) $this->blade('')))->toBe('
')
->and(ComponentStylesheet::read('menu-separator')->declarations('[data-md-menu-separator]'))->toBe(['block-size' => '1px', 'margin-block' => 'var(--md-sys-measurement-space100)', 'margin-inline' => 'var(--md-sys-measurement-space200)', 'border-width' => '0', 'background-color' => 'var(--md-sys-color-outline-variant)'])
->and((string) $this->blade(''))->toContain('data-md-menu-group-label')
->and(ComponentStylesheet::read('menu-group')->declarations('[data-md-menu-group-label]'))->toMatchArray([
'padding-inline' => 'var(--md-sys-measurement-space200)',
'padding-block' => 'var(--md-sys-measurement-space100) var(--md-sys-measurement-space50)',
]);
});
it('marks the page an item leads to, and carries a badge', function () {
$html = (string) $this->blade('');
expect($html)
->toContain('role="menuitem"')
->toContain('aria-current="page"')
->not->toContain('aria-checked')
->toContain('data-md-badge')
->toContain('>3<')
->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-current='page']:not([aria-checked='true'])"))->toMatchArray([
'background-color' => 'var(--md-sys-color-secondary-container)',
'color' => 'var(--md-sys-color-on-secondary-container)',
])
->and((string) $this->blade(''))
->not->toContain('aria-current')
->not->toContain('data-md-badge');
});
it('links an item, and keeps a disabled one out of reach', function () {
$this->blade('')
->assertSee('href="/settings"', false)
->assertSee('wire:navigate', false);
$this->blade('')
->assertSee('aria-disabled="true"', false)
->assertSee('data-md-menu-item', false);
// A disabled item stays focusable and keeps its ring, but the foundation's md-state-layer class
// (which the item renders, not a copy of its own) already withholds the layer on aria-disabled.
expect(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true']"))->toMatchArray(['pointer-events' => 'none'])
->and(ComponentStylesheet::read('menu-item')->has('[data-md-menu-item]::before'))->toBeFalse()
->and((string) $this->blade(''))->toContain('class="md-state-layer md-focus-ring"');
});
it('separates and labels groups', function () {
$this->blade('')->assertSee('role="separator"', false);
$this->blade('')
->assertSee('role="group" aria-label="Sort by"', false);
});
it('separates clusters by a gap instead of a divider when asked', function () {
$html = (string) $this->blade('');
$css = ComponentStylesheet::read('menu-group');
// M3 Expressive's grouped layout: 2px between the items of a cluster, 8px between clusters —
// the same 8px the divider keeps above and below its line.
expect($html)
->toContain('role="group"')
->toContain('data-md-gap')
->toContain('data-md-menu-group-items')
->not->toContain('aria-label')
->and($css->declarations('[data-md-menu-group][data-md-gap]:not(:first-child)'))->toBe(['margin-block-start' => 'var(--md-sys-measurement-space100)'])
->and($css->declarations('[data-md-menu-group-items]'))->toMatchArray(['gap' => 'var(--md-sys-measurement-space25)'])
->and((string) $this->blade(''))
->toContain('aria-label="Then"')
->toContain('data-md-menu-group-items')
->and((string) $this->blade(''))
->not->toContain('data-md-gap')
->not->toContain('data-md-menu-group-items')
->and($css->declarations('[data-md-menu-group]:not([data-md-gap])'))->toBe(['padding-block' => 'var(--md-sys-measurement-space50)']);
});
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
$leading = fn (string $html): string => preg_match('/