The popover was fit-content with overflow visible, so a long menu ran past the edge of the top layer, where the page's own scrolling cannot reach it. It now caps at 18rem — less on a short window — and scrolls, as M3's menu behaviour asks, and the keyboard brings the item it moves to into view. Plan step 11, actions.md ACT-04. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
107 lines
5.0 KiB
PHP
107 lines
5.0 KiB
PHP
<?php
|
|
|
|
it('opens a popover menu from its trigger', function () {
|
|
$html = (string) $this->blade(<<<'BLADE'
|
|
<x-menu label="Share actions">
|
|
<x-slot:trigger><button>More</button></x-slot:trigger>
|
|
<x-menu-item label="Copy link" icon="content_copy" />
|
|
</x-menu>
|
|
BLADE);
|
|
|
|
preg_match('/anchor-name: (--material-menu-([a-z0-9]+))/', $html, $anchor);
|
|
|
|
expect($anchor)->not->toBeEmpty()
|
|
->and($html)
|
|
->toContain('x-data="materialMenu"')
|
|
->toContain('<button>More</button>')
|
|
->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('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
|
->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('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');
|
|
|
|
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('<x-menu-item label="Download" icon="download" shortcut="⌘D" description="As a ZIP" wire:click="download" />');
|
|
|
|
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', function () {
|
|
expect((string) $this->blade('<x-menu-item label="Newest" :selected="true" keep-open />'))
|
|
->toContain('role="menuitemcheckbox"')
|
|
->toContain('aria-checked="true"')
|
|
->toContain('bg-tertiary-container')
|
|
->toContain('data-keep-open')
|
|
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
|
|
->toContain('aria-checked="false"');
|
|
});
|
|
|
|
it('marks the page an item leads to, and carries a badge', function () {
|
|
$html = (string) $this->blade('<x-menu-item label="Support" icon="support" link="/admin/support" current badge="3" />');
|
|
|
|
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('<x-menu-item label="Users" link="/admin/users" />'))
|
|
->not->toContain('aria-current')
|
|
->not->toContain('secondary-container');
|
|
});
|
|
|
|
it('links an item, and keeps a disabled one out of reach', function () {
|
|
$this->blade('<x-menu-item label="Settings" link="/settings" />')
|
|
->assertSee('href="/settings"', false)
|
|
->assertSee('wire:navigate', false);
|
|
|
|
$this->blade('<x-menu-item label="Reset" disabled />')
|
|
->assertSee('aria-disabled="true"', false)
|
|
->assertSee('pointer-events-none', false);
|
|
});
|
|
|
|
it('separates and labels groups', function () {
|
|
$this->blade('<x-menu-separator />')->assertSee('role="separator"', false);
|
|
|
|
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
|
|
->assertSee('role="group" aria-label="Sort by"', false);
|
|
});
|
|
|
|
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
|
|
$leading = fn (string $html): string => preg_match('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
|
|
|
|
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
|
|
->toBe('shrink-0 size-5 [:where(&)]:text-on-surface-variant text-sport-run')
|
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" :selected="true" />')))
|
|
->toBe('shrink-0 size-5 [:where(&)]:text-on-tertiary-container text-sport-run')
|
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
|
|
->toBe('shrink-0 size-5 text-sport-run text-on-surface/38!')
|
|
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
|
|
->toBe('shrink-0 size-5 text-on-surface-variant')
|
|
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
|
|
->not->toContain('text-sport-run');
|
|
});
|