diff --git a/src/components/dropdown-menu/DropdownMenuSubContent.vue b/src/components/dropdown-menu/DropdownMenuSubContent.vue index a52d660..7b08df1 100644 --- a/src/components/dropdown-menu/DropdownMenuSubContent.vue +++ b/src/components/dropdown-menu/DropdownMenuSubContent.vue @@ -12,14 +12,20 @@ import MenuScrollArea from '../menu-scroll-area/MenuScrollArea.vue' import { useSubmenuAlignment } from '../../lib/useSubmenuAlignment' import { cn } from '#/lib/utils' -const props = defineProps() +const props = withDefaults(defineProps(), { scrollable: true }) const emits = defineEmits() -const delegatedProps = reactiveOmit(props, 'class') +const delegatedProps = reactiveOmit(props, 'class', 'scrollable') const forwarded = useForwardPropsEmits(delegatedProps, emits) const menuArea = ref>() -const currentElement = computed(() => menuArea.value?.viewportElement?.closest('[data-slot="dropdown-menu-sub-content"]') ?? undefined) +const customBody = ref() +const currentElement = computed(() => (menuArea.value?.viewportElement ?? customBody.value) + ?.closest('[data-slot="dropdown-menu-sub-content"]') ?? undefined) const { alignOffset, ready } = useSubmenuAlignment(currentElement) // Start the entrance on a fresh frame after mounting and placement have settled. // Otherwise a long list can consume the animation before its first visible paint. @@ -43,9 +49,19 @@ watch(ready, (value, _, cleanup) => { :style="{ visibility: ready ? undefined : 'hidden' }" :class="cn(menuWidthClass, menuContentClass, menuAnchoredMotionClass, 'flex min-h-0 flex-col overflow-hidden max-h-(--reka-dropdown-menu-content-available-height) origin-(--reka-dropdown-menu-content-transform-origin)', props.class, !motionReady && 'animate-none! opacity-0')" > - + +
+ +
diff --git a/src/components/menu-scroll-area/MenuScrollArea.vue b/src/components/menu-scroll-area/MenuScrollArea.vue index 8de53b0..06f071f 100644 --- a/src/components/menu-scroll-area/MenuScrollArea.vue +++ b/src/components/menu-scroll-area/MenuScrollArea.vue @@ -40,7 +40,7 @@ const { fadeTop, fadeBottom } = useScrollFade(element) '--scroll-fade-top': fadeTop ? '16px' : '0px', '--scroll-fade-bottom': fadeBottom ? '16px' : '0px', }" - :tabindex="-1" + :tabindex="props.viewportAttrs?.tabindex ?? -1" >
diff --git a/src/lib/useSubmenuAlignment.ts b/src/lib/useSubmenuAlignment.ts index 13b5bb5..739eb26 100644 --- a/src/lib/useSubmenuAlignment.ts +++ b/src/lib/useSubmenuAlignment.ts @@ -11,7 +11,11 @@ export function useSubmenuAlignment(content: Readonly { - const first = element.querySelector('[role^="menuitem"]') + // Virtual listboxes may unmount their first option while scrolling. Only + // anchor to logical row one, never to the first currently mounted row. + const first = element.querySelector( + '[role^="menuitem"], [role="option"][aria-posinset="1"], [role="option"]:not([aria-posinset])', + ) if (first && element.offsetWidth) { const bounds = element.getBoundingClientRect() // Opening scale animation must not influence layout measurements.