Open a submenu from a menu item

Plan step 22, actions.md § Missing (Submenus): M3's Expressive vertical
menu specifies submenus and the Left/Right keys that open and close them,
and the library had neither.

`<x-menu-item submenu>` puts its slot in a second popover on the item's
end, flipping to the start where there is no room. `materialSubmenu`
reuses the menu button pattern one level in: the item is its own trigger,
so the anchor move and the button lookup are overridden away; Right,
Enter and Space open it, Left and Escape close it and return the focus,
and a fine pointer resting on the item opens it after a moment. `items()`
now stops at the popover it belongs to, so the arrows never walk between
a menu and an open submenu. The menu publishes its container as custom
properties so a vibrant menu's submenus are vibrant too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:31:56 +02:00
co-authored by Claude Opus 5
parent d74fa250ee
commit 25091ebf21
7 changed files with 462 additions and 205 deletions
+48
View File
@@ -117,3 +117,51 @@ it('adds icon-class to the leading icon, over its own colour but not over disabl
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
->not->toContain('text-sport-run');
});
it('opens a submenu beside the item that holds it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu-item label="Send to" icon="send" submenu>
<x-menu-item label="A person" icon="person" />
</x-menu-item>
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('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->toContain($chevron)
->toContain('rtl:-scale-x-100')
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->not->toContain($chevron);
});
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->toContain('data-vibrant')
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->not->toContain('data-vibrant');
});