Scroll a menu that is too long for the window

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
This commit is contained in:
Andreas Reinhold
2026-09-14 05:55:38 +02:00
co-authored by Claude Opus 5
parent 6e24ff2cf8
commit c1ca157341
3 changed files with 20 additions and 4 deletions
+9 -3
View File
@@ -183,10 +183,16 @@ document.addEventListener('alpine:init', () => {
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true') return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true')
}, },
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
focusItem(which) { focusItem(which) {
const items = this.items() const items = this.items()
;(which === 'last' ? items.at(-1) : items[0])?.focus() this.reach(which === 'last' ? items.at(-1) : items[0])
},
reach(item) {
item?.focus()
item?.scrollIntoView({ block: 'nearest' })
}, },
navigate(event) { navigate(event) {
@@ -195,7 +201,7 @@ document.addEventListener('alpine:init', () => {
const move = (index) => { const move = (index) => {
event.preventDefault() event.preventDefault()
items[(index + items.length) % items.length]?.focus() this.reach(items[(index + items.length) % items.length])
} }
switch (event.key) { switch (event.key) {
@@ -226,7 +232,7 @@ document.addEventListener('alpine:init', () => {
if (match) { if (match) {
event.preventDefault() event.preventDefault()
match.focus() this.reach(match)
} }
} }
}, },
+5 -1
View File
@@ -32,6 +32,10 @@
of the loop iteration around it, which would give every child component after the menu the of the loop iteration around it, which would give every child component after the menu the
same key. same key.
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow
keys, Home, End and typeahead bring the item they move to into view.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from 2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0). --}} androidx Compose Material 3 (Apache-2.0). --}}
@@ -67,7 +71,7 @@
x-on:keydown="navigate($event)" x-on:keydown="navigate($event)"
x-on:click="activate($event)" x-on:click="activate($event)"
@class([ @class([
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]', 'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]', 'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0', 'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'bg-surface-container-low text-on-surface' => ! $vibrant, 'bg-surface-container-low text-on-surface' => ! $vibrant,
+6
View File
@@ -23,6 +23,12 @@ it('opens a popover menu from its trigger', function () {
->toContain('[position-area:bottom_span-right]'); ->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 () { 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>'); $html = (string) $this->blade('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');