From c1ca1573414071667a015d76ee1b474f02ca4d81 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 05:55:38 +0200 Subject: [PATCH] Scroll a menu that is too long for the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/js/menu.js | 12 +++++++++--- resources/views/components/menu.blade.php | 6 +++++- tests/Feature/Components/MenuTest.php | 6 ++++++ 3 files changed, 20 insertions(+), 4 deletions(-) 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('');