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('') ->toContain('popover="auto"') ->toContain('role="menu"') ->toContain('aria-label="Share actions"') ->toContain("id=\"material-menu-{$anchor[2]}\"") ->toContain("position-anchor: {$anchor[1]}") ->toContain('bg-surface-container-low') ->toContain('[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('max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto') ->not->toContain('overflow-visible'); }); it('opens at the position asked for, in the vibrant colours on request', function () { $html = (string) $this->blade(''); expect($html)->toContain('[position-area:top_span-left]')->toContain('bg-tertiary-container text-on-tertiary-container'); }); it('draws an item as a menuitem button', function () { $html = (string) $this->blade(''); expect($html) ->toContain('role="menuitem"') ->toContain('tabindex="-1"') ->toContain('type="button"') ->toContain('wire:click="download"') ->toContain('Download') ->toContain('⌘D') ->toContain('As a ZIP') ->toContain('size-5 text-on-surface-variant'); }); 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('bg-tertiary-container') ->toContain('data-keep-open') ->toContain($check) ->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('min-h-12')->toContain('px-4') ->and((string) $this->blade(''))->toContain('mx-4 my-2') ->and((string) $this->blade(''))->toContain('px-4 pt-2 pb-1'); }); 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('bg-secondary-container text-on-secondary-container') ->toContain('>3<') ->and((string) $this->blade('')) ->not->toContain('aria-current') ->not->toContain('secondary-container'); }); 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('pointer-events-none', false); }); 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(''); // 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('not-first:mt-2') ->toContain('space-y-0.5') ->not->toContain('aria-label') ->not->toContain('py-1 first:pt-0') ->and((string) $this->blade('')) ->toContain('aria-label="Then"') ->toContain('space-y-0.5') ->and((string) $this->blade('')) ->toContain('py-1 first:pt-0 last:pb-0') ->not->toContain('space-y-0.5'); }); 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('shrink-0 size-5 [:where(&)]:text-on-surface-variant text-sport-run') ->and($leading((string) $this->blade(''))) ->toBe('shrink-0 size-5 [:where(&)]:text-on-tertiary-container text-sport-run') ->and($leading((string) $this->blade(''))) ->toBe('shrink-0 size-5 text-sport-run text-on-surface/38!') ->and($leading((string) $this->blade(''))) ->toBe('shrink-0 size-5 text-on-surface-variant') ->and((string) $this->blade('')) ->not->toContain('text-sport-run'); }); 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-submenu') ->toContain('aria-label="Send to"') ->toContain('[position-area:inline-end_span-block-end]') ->toContain('[position-try-fallbacks:flip-inline]') // Opening a submenu chooses nothing, so the menu around it stays where it was. ->toContain('data-keep-open') ->toContain('A person'); }); 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('rtl:-scale-x-100') ->and((string) $this->blade('')) ->not->toContain($chevron); }); it('tells a vibrant menu apart, so the submenus inside it take the same container', function () { expect((string) $this->blade('')) ->toContain('data-menu') ->toContain('data-vibrant') ->and((string) $this->blade('')) ->toContain('data-menu') ->not->toContain('data-vibrant'); });