diff --git a/resources/js/menu.js b/resources/js/menu.js
index 1140c641..4e8df277 100644
--- a/resources/js/menu.js
+++ b/resources/js/menu.js
@@ -183,10 +183,16 @@ document.addEventListener('alpine:init', () => {
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) {
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) {
@@ -195,7 +201,7 @@ document.addEventListener('alpine:init', () => {
const move = (index) => {
event.preventDefault()
- items[(index + items.length) % items.length]?.focus()
+ this.reach(items[(index + items.length) % items.length])
}
switch (event.key) {
@@ -226,7 +232,7 @@ document.addEventListener('alpine:init', () => {
if (match) {
event.preventDefault()
- match.focus()
+ this.reach(match)
}
}
},
diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php
index a7ed03c6..a228ce73 100644
--- a/resources/views/components/menu.blade.php
+++ b/resources/views/components/menu.blade.php
@@ -32,6 +32,10 @@
of the loop iteration around it, which would give every child component after the menu the
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
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0). --}}
@@ -67,7 +71,7 @@
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@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]',
'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,
diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php
index cf58b0f8..a75ec3ea 100644
--- a/tests/Feature/Components/MenuTest.php
+++ b/tests/Feature/Components/MenuTest.php
@@ -23,6 +23,12 @@ it('opens a popover menu from its trigger', function () {
->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('');