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('/]*class="([^"]*)"/', $html, $icon) ? $icon[1] : ''; expect($leading((string) $this->blade(''))) ->toBe('text-sport-run') ->and($leading((string) $this->blade(''))) ->toBe('text-sport-run') ->and($leading((string) $this->blade(''))) ->toBe('text-sport-run') ->and($leading((string) $this->blade(''))) ->toBe('') ->and((string) $this->blade('')) ->not->toContain('text-sport-run') // icon-class is a caller class, unlayered, so it always outranks the item's own layered // ink — except for a disabled item, whose icon rule is !important. ->and(ComponentStylesheet::read('menu-item')->declarations('[data-md-menu-item] [data-md-icon]'))->toBe(['color' => 'var(--md-menu-item-ink)']) ->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true'] [data-md-icon]"))->toBe(['color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent) !important']); }); it('opens a submenu beside the item that holds it', function () { $html = (string) $this->blade(<<<'BLADE' BLADE); preg_match('/anchor-name: (--material-submenu-([a-z0-9]+))/', $html, $anchor); expect($anchor)->not->toBeEmpty() ->and($html) ->toContain('x-data="materialSubmenu"') ->toContain('x-on:keydown.right.prevent.stop="open(\'first\')"') ->toContain('x-on:pointerenter="hover($event)"') ->toContain('aria-haspopup="menu"') ->toContain('aria-expanded="false"') ->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"") ->toContain("id=\"material-submenu-{$anchor[2]}\"") ->toContain("position-anchor: {$anchor[1]}") ->toContain('data-md-submenu') ->toContain('aria-label="Send to"') // Opening a submenu chooses nothing, so the menu around it stays where it was. ->toContain('data-md-keep-open') ->toContain('A person') ->and(ComponentStylesheet::read('menu-item')->declarations('[data-md-submenu]'))->toMatchArray([ 'position-area' => 'inline-end span-block-end', 'position-try-fallbacks' => 'flip-inline', ]); }); it('marks a submenu item with a chevron instead of a tick', function () { $chevron = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/chevron_right.svg')); $chevron = substr($chevron, (int) strpos($chevron, '><') + 1); expect((string) $this->blade('')) ->toContain($chevron) ->toContain('data-md-submenu-chevron') ->toContain('data-md-mirror-rtl') ->and((string) $this->blade('')) ->not->toContain($chevron); }); it('embeds a text field that filters the list, as a combobox over the menu', function () { $html = (string) $this->blade(''); preg_match('/id="(material-menu-[a-z0-9]+)"/', $html, $id); expect($id)->not->toBeEmpty() ->and($html) ->toContain('data-md-menu-filter') ->toContain('role="combobox"') ->toContain('aria-autocomplete="list"') ->toContain("aria-controls=\"{$id[1]}-list\"") ->toContain('aria-label="Find a person"') ->toContain('placeholder="Find a person"') ->toContain('x-on:input="refine()"') ->toContain('x-on:keydown.stop="search($event)"') ->toContain('

toContain('Nothing matches') // A text field is not something a `role="menu"` may hold, so the list moves inside it. ->toContain("

toMatch('/]*popover="auto"(?![^>]*role="menu")/'); }); it('names a bare filter field, and leaves a plain menu alone', function () { expect((string) $this->blade('')) ->toContain('aria-label="Filter"') ->and((string) $this->blade('')) ->not->toContain('data-md-menu-filter') ->not->toContain('role="combobox"') ->toMatch('/]*popover="auto"[^>]*role="menu"/'); }); it('tells a vibrant menu apart, so the submenus inside it take the same container', function () { expect((string) $this->blade('')) ->toContain('data-md-menu') ->toContain('data-md-vibrant') ->and((string) $this->blade('')) ->toContain('data-md-menu') ->not->toContain('data-md-vibrant'); }); it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as the popover', function () { $html = (string) $this->blade(<<<'BLADE' BLADE); preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key); expect($key)->not->toBeEmpty(); [$popover, $sheet] = explode('