wrapper that swallowed
+ // fallthrough attrs while data-state/aria stayed on the inner button.
+ expect(el.tagName).toBe('BUTTON');
+ expect(el.classList.contains('demo-trigger')).toBe(true);
+ expect(el.getAttribute('aria-haspopup')).toBe('menu');
+ expect(el.getAttribute('data-state')).toBe('closed');
+ expect(el.querySelector('button')).toBeNull();
+ });
+
+ it('flips data-state/aria-expanded on the attr-bearing element when opened', async () => {
+ mountMenu({ modal: false });
+ pointerDown(trigger());
+ await flush();
+ expect(menu()).toBeTruthy();
+ expect(trigger().getAttribute('data-state')).toBe('open');
+ expect(trigger().getAttribute('aria-expanded')).toBe('true');
+ });
+});
+
+describe('dropdownMenu — trigger pointerdown toggling (non-modal)', () => {
+ it('closes on trigger pointerdown while open and does not reopen from the dismiss race', async () => {
+ mountMenu({ modal: false });
+
+ pointerDown(trigger());
+ await flush();
+ expect(menu()).toBeTruthy();
+
+ // The outside-pointerdown dismiss (window capture) runs before the
+ // trigger's own handler — without the content-side guard the menu would
+ // close via dismiss and instantly reopen via the trigger toggle.
+ pointerDown(trigger());
+ await flush();
+ expect(menu()).toBeNull();
+ expect(trigger().getAttribute('data-state')).toBe('closed');
+
+ await flush();
+ expect(menu()).toBeNull();
+ });
+
+ it('reopens on the next trigger pointerdown after a toggle-close', async () => {
+ mountMenu({ modal: false });
+
+ pointerDown(trigger());
+ await flush();
+ pointerDown(trigger());
+ await flush();
+ expect(menu()).toBeNull();
+
+ pointerDown(trigger());
+ await flush();
+ expect(menu()).toBeTruthy();
+ expect(trigger().getAttribute('data-state')).toBe('open');
+ });
+});
+
+describe('dropdownMenu — trigger keyboard open', () => {
+ it('opens the menu on Enter', async () => {
+ mountMenu({ modal: false });
+ trigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ await flush();
+ expect(menu()).toBeTruthy();
+ expect(trigger().getAttribute('data-state')).toBe('open');
+ });
+});
diff --git a/vue/primitives/src/editable/EditableCancelTrigger.vue b/vue/primitives/src/editable/EditableCancelTrigger.vue
index a6565ff..199a27a 100644
--- a/vue/primitives/src/editable/EditableCancelTrigger.vue
+++ b/vue/primitives/src/editable/EditableCancelTrigger.vue
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
+ :style="ctx.isEditing.value ? undefined : { display: 'none' }"
@click="ctx.cancel"
>
Cancel
diff --git a/vue/primitives/src/editable/EditableEditTrigger.vue b/vue/primitives/src/editable/EditableEditTrigger.vue
index 8ff62c9..e287c75 100644
--- a/vue/primitives/src/editable/EditableEditTrigger.vue
+++ b/vue/primitives/src/editable/EditableEditTrigger.vue
@@ -28,6 +28,7 @@ const { forwardRef } = useForwardExpose();
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? '' : undefined"
+ :style="ctx.isEditing.value ? { display: 'none' } : undefined"
@click="ctx.edit"
>
Edit
diff --git a/vue/primitives/src/editable/EditableInput.vue b/vue/primitives/src/editable/EditableInput.vue
index 58f9f5b..6b14ae1 100644
--- a/vue/primitives/src/editable/EditableInput.vue
+++ b/vue/primitives/src/editable/EditableInput.vue
@@ -80,7 +80,7 @@ function onKeyDown(event: KeyboardEvent): void {
all: 'unset',
gridArea: '1 / 1 / auto / auto',
visibility: !ctx.isEditing.value ? 'hidden' : undefined,
- } : undefined"
+ } : (!ctx.isEditing.value ? { display: 'none' } : undefined)"
aria-label="editable input"
@input="onInput"
@keydown="onKeyDown"
diff --git a/vue/primitives/src/editable/EditablePreview.vue b/vue/primitives/src/editable/EditablePreview.vue
index f20d060..24644c9 100644
--- a/vue/primitives/src/editable/EditablePreview.vue
+++ b/vue/primitives/src/editable/EditablePreview.vue
@@ -49,7 +49,7 @@ function onDoubleClick(): void {
visibility: ctx.isEditing.value ? 'hidden' : undefined,
overflow: 'hidden',
textOverflow: 'ellipsis',
- } : undefined"
+ } : (ctx.isEditing.value ? { display: 'none' } : undefined)"
@focusin="onFocus"
@dblclick="onDoubleClick"
>
diff --git a/vue/primitives/src/editable/EditableRoot.vue b/vue/primitives/src/editable/EditableRoot.vue
index 90da246..d61a37c 100644
--- a/vue/primitives/src/editable/EditableRoot.vue
+++ b/vue/primitives/src/editable/EditableRoot.vue
@@ -38,7 +38,7 @@ export interface EditableRootEmits {
diff --git a/vue/primitives/src/menu/MenuContentImpl.vue b/vue/primitives/src/menu/MenuContentImpl.vue
index 2ea96ed..507ea97 100644
--- a/vue/primitives/src/menu/MenuContentImpl.vue
+++ b/vue/primitives/src/menu/MenuContentImpl.vue
@@ -43,7 +43,7 @@ import { PopperContent } from '../popper';
import { RovingFocusGroup } from '../roving-focus';
import { useForwardExpose } from '@robonen/vue';
import { provideMenuContentContext, useMenuContext, useMenuRootContext } from './context';
-import { FIRST_LAST_KEYS, getNextMatch, getOpenState, isPointerInGraceArea } from './utils';
+import { FIRST_LAST_KEYS, LAST_KEYS, focusFirst, getNextMatch, getOpenState, isPointerInGraceArea } from './utils';
const {
loop = false,
@@ -100,9 +100,9 @@ provideMenuContentContext({
function handleMountAutoFocus(event: Event) {
event.preventDefault();
- if (rootCtx.isUsingKeyboardRef.value) {
- contentElement.value?.focus({ preventScroll: true });
- }
+ // Always focus the content so key events reach the menu even after a
+ // pointer-open; entryFocus decides whether the first item gets focus.
+ contentElement.value?.focus({ preventScroll: true });
emit('openAutoFocus', event);
}
@@ -125,6 +125,17 @@ function handleKeyDown(event: KeyboardEvent) {
if (FIRST_LAST_KEYS.includes(event.key)) {
event.stopPropagation();
+ // While the content itself is focused (e.g. right after a pointer-open),
+ // arrow/Home/End must move focus into the items.
+ const content = contentElement.value;
+ if (content && event.target === content) {
+ event.preventDefault();
+ const items = Array.from(
+ content.querySelectorAll
('[data-primitives-menu-item]:not([data-disabled])'),
+ );
+ if (LAST_KEYS.includes(event.key)) items.reverse();
+ focusFirst(items);
+ }
}
}
diff --git a/vue/primitives/src/menu/MenuItem.vue b/vue/primitives/src/menu/MenuItem.vue
index e46abb1..6125abb 100644
--- a/vue/primitives/src/menu/MenuItem.vue
+++ b/vue/primitives/src/menu/MenuItem.vue
@@ -21,9 +21,12 @@ const emit = defineEmits();
const rootCtx = useMenuRootContext();
function handleSelect(event: Event) {
- const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
- ;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
- emit('select', event);
+ const target = event.currentTarget as HTMLElement;
+ const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
+ // The consumer must receive the cancelable ITEM_SELECT event (not the click)
+ // so `event.preventDefault()` in `@select` actually keeps the menu open.
+ target.addEventListener(ITEM_SELECT, e => emit('select', e), { once: true });
+ target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) {
rootCtx.onClose();
}
diff --git a/vue/primitives/src/menu/MenuItemImpl.vue b/vue/primitives/src/menu/MenuItemImpl.vue
index 3e0979a..8e174d1 100644
--- a/vue/primitives/src/menu/MenuItemImpl.vue
+++ b/vue/primitives/src/menu/MenuItemImpl.vue
@@ -75,10 +75,17 @@ function handleKeyDown(event: KeyboardEvent) {
el.click();
}
}
+
+// RovingFocusItem renders as="template" so its tab stop (tabindex, focus and
+// keydown handlers, collection registration) merges onto the menu-item element
+// itself — a real wrapper element would split focus handling across two nodes.
+// NB: the template must stay single-root with no top-level comments; consumers
+// resolve this component's element via `$el`/functional refs, and a dev-mode
+// fragment root would point them at the fragment anchor instead.
-
+
emit('select', e), { once: true });
+ target.dispatchEvent(selectEvent);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
diff --git a/vue/primitives/src/menu/MenuRootContentModal.vue b/vue/primitives/src/menu/MenuRootContentModal.vue
index 1ac212e..5477a06 100644
--- a/vue/primitives/src/menu/MenuRootContentModal.vue
+++ b/vue/primitives/src/menu/MenuRootContentModal.vue
@@ -35,7 +35,7 @@ useHideOthers(contentRef);
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
- @dismiss="emit('dismiss')"
+ @dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
diff --git a/vue/primitives/src/menu/MenuRootContentNonModal.vue b/vue/primitives/src/menu/MenuRootContentNonModal.vue
index c4cf633..4221b74 100644
--- a/vue/primitives/src/menu/MenuRootContentNonModal.vue
+++ b/vue/primitives/src/menu/MenuRootContentNonModal.vue
@@ -29,7 +29,7 @@ watchEffect(() => menuCtx.onContentChange(contentRef.value));
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
- @dismiss="emit('dismiss')"
+ @dismiss="() => { menuCtx.onOpenChange(false); emit('dismiss') }"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
diff --git a/vue/primitives/src/menu/MenuSubTrigger.vue b/vue/primitives/src/menu/MenuSubTrigger.vue
index daf735e..d192bbf 100644
--- a/vue/primitives/src/menu/MenuSubTrigger.vue
+++ b/vue/primitives/src/menu/MenuSubTrigger.vue
@@ -65,10 +65,22 @@ function handleKeyDown(event: KeyboardEvent) {
close();
}
}
+
+function handleSelect(event: Event) {
+ // Sub triggers open their submenu instead of closing the menu tree —
+ // this is also the only open path for touch pointers.
+ event.preventDefault();
+ if (!menuCtx.open.value) open();
+}
+
+// PopperAnchor renders as="template" so the item element itself becomes the
+// popper anchor and fallthrough attrs land on the element carrying
+// data-state/highlight (a wrapper div would swallow them). The template must
+// stay single-root without top-level comments — see MenuItemImpl.
-
+
diff --git a/vue/primitives/src/menu/__test__/Menu.test.ts b/vue/primitives/src/menu/__test__/Menu.test.ts
new file mode 100644
index 0000000..39298d9
--- /dev/null
+++ b/vue/primitives/src/menu/__test__/Menu.test.ts
@@ -0,0 +1,277 @@
+import type { VueWrapper } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
+import { afterEach, describe, expect, it } from 'vitest';
+import { defineComponent, h, nextTick, ref } from 'vue';
+
+import {
+ MenuAnchor,
+ MenuContent,
+ MenuItem,
+ MenuRoot,
+ MenuSub,
+ MenuSubContent,
+ MenuSubTrigger,
+} from '../index';
+import { ITEM_SELECT } from '../utils';
+
+const wrappers: Array> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+ document.body.style.pointerEvents = '';
+});
+
+function track>(w: T): T {
+ wrappers.push(w);
+ return w;
+}
+
+interface MountMenuOptions {
+ modal?: boolean;
+ onSelect?: (event: Event) => void;
+ items?: () => unknown;
+}
+
+function mountMenu(options: MountMenuOptions = {}) {
+ const open = ref(false);
+ const Harness = defineComponent({
+ setup() {
+ return () => h(
+ MenuRoot,
+ {
+ open: open.value,
+ 'onUpdate:open': (v: boolean) => { open.value = v; },
+ modal: options.modal,
+ },
+ {
+ default: () => [
+ h(MenuAnchor, null, { default: () => h('button', { type: 'button' }, 'Anchor') }),
+ h(MenuContent, null, {
+ default: () => options.items?.() ?? [
+ h(MenuItem, { class: 'consumer-item', onSelect: options.onSelect }, { default: () => 'Alpha' }),
+ h(MenuItem, null, { default: () => 'Bravo' }),
+ h(MenuItem, null, { default: () => 'Charlie' }),
+ ],
+ }),
+ ],
+ },
+ );
+ },
+ });
+ track(mount(Harness, { attachTo: document.body }));
+ return { open };
+}
+
+async function openMenu(open: { value: boolean }) {
+ open.value = true;
+ await nextTick();
+ await nextTick();
+}
+
+function content(): HTMLElement {
+ return document.querySelector('[role="menu"]')!;
+}
+
+function items(): HTMLElement[] {
+ return Array.from(document.querySelectorAll('[role="menuitem"]'));
+}
+
+function usePointer() {
+ // Flip the shared isUsingKeyboard ref into "pointer" mode.
+ document.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }));
+}
+
+function keydown(el: HTMLElement, key: string) {
+ el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
+}
+
+describe('menu — item rendering (roving focus merged onto the item element)', () => {
+ it('puts consumer class, roving tab stop, and collection registration on the menuitem itself', async () => {
+ const { open } = mountMenu();
+ await openMenu(open);
+
+ const [alpha] = items();
+ expect(alpha).toBeTruthy();
+ expect(alpha!.classList.contains('consumer-item')).toBe(true);
+ expect(alpha!.hasAttribute('data-collection-item')).toBe(true);
+ expect(alpha!.hasAttribute('tabindex')).toBe(true);
+ // No wrapper span between the content and the item.
+ expect(alpha!.parentElement?.getAttribute('role')).toBe('menu');
+ });
+
+ it('sets data-highlighted on the same element that carries consumer attrs on hover', async () => {
+ const { open } = mountMenu();
+ await openMenu(open);
+
+ const [alpha] = items();
+ alpha!.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }));
+ await nextTick();
+
+ expect(document.activeElement).toBe(alpha);
+ expect(alpha!.hasAttribute('data-highlighted')).toBe(true);
+ expect(alpha!.classList.contains('consumer-item')).toBe(true);
+ });
+});
+
+describe('menu — keyboard navigation after a pointer-open', () => {
+ it('focuses the content on mount so key events reach the menu', async () => {
+ usePointer();
+ const { open } = mountMenu();
+ await openMenu(open);
+
+ expect(document.activeElement).toBe(content());
+ });
+
+ it('ArrowDown from the content focuses the first item, then roves to the next', async () => {
+ usePointer();
+ const { open } = mountMenu();
+ await openMenu(open);
+
+ keydown(content(), 'ArrowDown');
+ await nextTick();
+ expect(document.activeElement).toBe(items()[0]);
+
+ keydown(items()[0]!, 'ArrowDown');
+ await nextTick();
+ expect(document.activeElement).toBe(items()[1]);
+ });
+
+ it('End from the content focuses the last item', async () => {
+ usePointer();
+ const { open } = mountMenu();
+ await openMenu(open);
+
+ keydown(content(), 'End');
+ await nextTick();
+ expect(document.activeElement).toBe(items().at(-1));
+ });
+
+ it('Enter on the focused item selects it and closes the menu', async () => {
+ usePointer();
+ const selected: Event[] = [];
+ const { open } = mountMenu({ onSelect: e => selected.push(e) });
+ await openMenu(open);
+
+ keydown(content(), 'ArrowDown');
+ keydown(items()[0]!, 'Enter');
+ await nextTick();
+
+ expect(selected).toHaveLength(1);
+ expect(open.value).toBe(false);
+ });
+});
+
+describe('menu — dismissal', () => {
+ it('closes on Escape and releases the modal body pointer-events lock', async () => {
+ const { open } = mountMenu();
+ await openMenu(open);
+ expect(document.body.style.pointerEvents).toBe('none');
+
+ keydown(document.body, 'Escape');
+ await nextTick();
+ await nextTick();
+
+ expect(open.value).toBe(false);
+ expect(content()).toBeNull();
+ expect(document.body.style.pointerEvents).not.toBe('none');
+ });
+
+ it('closes on pointerdown outside the content', async () => {
+ const { open } = mountMenu();
+ await openMenu(open);
+
+ document.documentElement.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
+ await nextTick();
+ await nextTick();
+
+ expect(open.value).toBe(false);
+ expect(content()).toBeNull();
+ });
+
+ it('closes a non-modal menu on Escape too', async () => {
+ const { open } = mountMenu({ modal: false });
+ await openMenu(open);
+ expect(document.body.style.pointerEvents).not.toBe('none');
+
+ keydown(document.body, 'Escape');
+ await nextTick();
+
+ expect(open.value).toBe(false);
+ });
+});
+
+describe('menu — @select contract', () => {
+ it('emits the cancelable ITEM_SELECT event to the consumer', async () => {
+ const selected: Event[] = [];
+ const { open } = mountMenu({ onSelect: e => selected.push(e) });
+ await openMenu(open);
+
+ items()[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await nextTick();
+
+ expect(selected).toHaveLength(1);
+ expect(selected[0]!.type).toBe(ITEM_SELECT);
+ expect(open.value).toBe(false);
+ });
+
+ it('keeps the menu open when the consumer calls event.preventDefault() in @select', async () => {
+ const { open } = mountMenu({ onSelect: e => e.preventDefault() });
+ await openMenu(open);
+
+ items()[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await nextTick();
+
+ expect(open.value).toBe(true);
+ expect(content()).toBeTruthy();
+ });
+});
+
+describe('menu — submenu trigger', () => {
+ function mountWithSub() {
+ const subOpen = ref(false);
+ const menu = mountMenu({
+ items: () => [
+ h(MenuItem, null, { default: () => 'Alpha' }),
+ h(MenuSub, {
+ open: subOpen.value,
+ 'onUpdate:open': (v: boolean) => { subOpen.value = v; },
+ }, {
+ default: () => [
+ h(MenuSubTrigger, { class: 'sub-trigger' }, { default: () => 'More' }),
+ h(MenuSubContent, null, {
+ default: () => h(MenuItem, null, { default: () => 'Nested' }),
+ }),
+ ],
+ }),
+ ],
+ });
+ return { ...menu, subOpen };
+ }
+
+ it('renders as a single element: consumer class and data-state on the menuitem, no anchor wrapper', async () => {
+ const { open } = mountWithSub();
+ await openMenu(open);
+
+ const trigger = document.querySelector('.sub-trigger')!;
+ expect(trigger.getAttribute('role')).toBe('menuitem');
+ expect(trigger.getAttribute('aria-haspopup')).toBe('menu');
+ expect(trigger.getAttribute('data-state')).toBe('closed');
+ expect(trigger.parentElement?.getAttribute('role')).toBe('menu');
+ });
+
+ it('opens the submenu on click', async () => {
+ const { open, subOpen } = mountWithSub();
+ await openMenu(open);
+
+ const trigger = document.querySelector('.sub-trigger')!;
+ trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await nextTick();
+ await nextTick();
+
+ expect(subOpen.value).toBe(true);
+ expect(trigger.getAttribute('data-state')).toBe('open');
+ const menus = document.querySelectorAll('[role="menu"]');
+ expect(menus.length).toBe(2);
+ });
+});
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuContent.vue b/vue/primitives/src/navigation-menu/NavigationMenuContent.vue
index ec48fda..10ebcab 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuContent.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuContent.vue
@@ -25,7 +25,6 @@ import NavigationMenuContentImpl from './NavigationMenuContentImpl.vue';
defineOptions({ inheritAttrs: false });
const { forceMount = false, ...rest } = defineProps();
-void rest;
const emit = defineEmits();
@@ -45,7 +44,19 @@ watch(
},
);
-const present = computed(() => open.value || isLastActiveValue.value);
+// The latch never resets when the whole menu closes, so gate it on the viewport
+// still existing — otherwise the Teleport falls back to disabled (inline) and
+// the closed panel would stay mounted in the nav forever.
+watch(
+ () => menuContext.viewport.value,
+ (viewport) => {
+ if (!viewport) isLastActiveValue.value = false;
+ },
+);
+
+const present = computed(
+ () => open.value || (isLastActiveValue.value && !!menuContext.viewport.value),
+);
function handlePointerEnter() {
menuContext.onContentEnter(itemContext.value);
@@ -62,7 +73,7 @@ function handlePointerLeave() {
();
+const { as } = defineProps();
const emit = defineEmits();
@@ -133,6 +133,20 @@ function handlePointerDownOutside(ev: PointerEvent | MouseEvent) {
if (isTrigger || isRootViewport || !menuContext.isRootMenu) ev.preventDefault();
}
+function handleDismiss() {
+ emit('dismiss');
+ const el = currentElement.value;
+ if (menuContext.isRootMenu && el) {
+ // Bubbles up to NavigationMenuRoot's listener (closes the menu) and hits
+ // our own EVENT_ROOT_CONTENT_DISMISS listener (restores content tab order).
+ el.dispatchEvent(new CustomEvent(EVENT_ROOT_CONTENT_DISMISS, { bubbles: true, cancelable: true }));
+ }
+ else {
+ // Submenus: the root listener isn't on an ancestor of this element.
+ menuContext.onItemDismiss();
+ }
+}
+
// Listen for sibling/global EVENT_ROOT_CONTENT_DISMISS for root menus so links
// inside content can request the whole root close.
watchEffect((onCleanup) => {
@@ -155,6 +169,7 @@ watchEffect((onCleanup) => {
{
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@interact-outside="emit('interactOutside', $event)"
- @dismiss="emit('dismiss')"
+ @dismiss="handleDismiss"
@pointerenter="emit('pointerEnterContent')"
@pointerleave="emit('pointerLeaveContent')"
>
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue b/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
index 724ca1e..19cb04b 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
@@ -52,7 +52,7 @@ import { useCollectionProvider } from '../collection';
import { useConfig } from '../config-provider';
import { Primitive } from '../primitive';
import { provideNavigationMenuContext } from './context';
-import { EVENT_ROOT_CONTENT_DISMISS } from './utils';
+import { EVENT_ROOT_CONTENT_DISMISS, NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
@@ -98,7 +98,7 @@ const indicatorTrack = shallowRef(undefined);
const viewport = shallowRef(undefined);
const activeTrigger = shallowRef(undefined);
-const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>();
+const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
// Manual debounce — open delay shrinks to 150ms once the menu is open or while
// the skip window is active (so moving between triggers feels instantaneous).
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuSub.vue b/vue/primitives/src/navigation-menu/NavigationMenuSub.vue
index 0b59360..9b801be 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuSub.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuSub.vue
@@ -28,6 +28,7 @@ import { useForwardExpose, useId } from '@robonen/vue';
import { useCollectionProvider } from '../collection';
import { Primitive } from '../primitive';
import { provideNavigationMenuContext, useNavigationMenuContext } from './context';
+import { NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
@@ -60,7 +61,7 @@ const indicatorTrack = shallowRef(undefined);
const viewport = shallowRef(undefined);
const activeTrigger = shallowRef(undefined);
-const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>();
+const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
const baseId = useId(undefined, 'primitives-navigation-menu-sub');
diff --git a/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue b/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue
index bfaaaa1..2c33935 100644
--- a/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue
+++ b/vue/primitives/src/navigation-menu/NavigationMenuTrigger.vue
@@ -22,7 +22,7 @@ import { Primitive } from '../primitive';
import { RovingFocusItem } from '../roving-focus';
import { VisuallyHidden } from '../visually-hidden';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
-import { getOpenState } from './utils';
+import { NAVIGATION_MENU_COLLECTION_KEY, getOpenState } from './utils';
defineOptions({ inheritAttrs: false });
@@ -31,20 +31,12 @@ const { disabled = false } = defineProps();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
-const { CollectionItem } = useCollectionInjector<{ value: string }>();
+const { CollectionItem } = useCollectionInjector<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
-// Auto-reset flag that suppresses click→toggle right after a pointermove open.
+// Set after a pointermove open so further pointermoves don't re-fire
+// onTriggerEnter; reset on pointerleave.
const hasPointerMoveOpened = ref(false);
-let pointerMoveResetTimer: ReturnType | undefined;
-function markPointerMoveOpened() {
- hasPointerMoveOpened.value = true;
- if (pointerMoveResetTimer !== undefined) clearTimeout(pointerMoveResetTimer);
- pointerMoveResetTimer = setTimeout(() => {
- hasPointerMoveOpened.value = false;
- pointerMoveResetTimer = undefined;
- }, 300);
-}
const wasClickClose = ref(false);
@@ -69,7 +61,7 @@ function handlePointerMove(ev: PointerEvent) {
if (ev.pointerType !== 'mouse') return;
if (disabled || wasClickClose.value || itemContext.wasEscapeCloseRef.value || hasPointerMoveOpened.value) return;
menuContext.onTriggerEnter(itemContext.value);
- markPointerMoveOpened();
+ hasPointerMoveOpened.value = true;
}
function handlePointerLeave(ev: PointerEvent) {
@@ -83,11 +75,11 @@ function handlePointerLeave(ev: PointerEvent) {
function handleClick(event: MouseEvent | PointerEvent) {
const isMouse = !('pointerType' in event) || (event as PointerEvent).pointerType === 'mouse';
if (isMouse && menuContext.disableClickTrigger.value) return;
- // If pointermove already opened the menu, ignore the resulting click.
- if (hasPointerMoveOpened.value) return;
- if (open.value) menuContext.onItemSelect('');
- else menuContext.onItemSelect(itemContext.value);
- wasClickClose.value = open.value;
+ // Capture before onItemSelect mutates modelValue — `open` is a computed over
+ // it, so reading it afterwards would be inverted.
+ const wasOpen = open.value;
+ menuContext.onItemSelect(wasOpen ? '' : itemContext.value);
+ wasClickClose.value = wasOpen;
}
function handleKeydown(ev: KeyboardEvent) {
@@ -120,8 +112,11 @@ function handleVisuallyHiddenFocus(ev: FocusEvent) {
-
-
+
+
+
-
-
+
+
> = [];
+
+afterEach(() => {
+ while (wrappers.length) wrappers.pop()!.unmount();
+ document.body.innerHTML = '';
+});
+
+function track>(w: T): T {
+ wrappers.push(w);
+ return w;
+}
+
+function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+interface MountOptions {
+ withViewport?: boolean;
+ contentProps?: Record;
+}
+
+function mountMenu(opts: MountOptions = {}) {
+ const { withViewport = true, contentProps = {} } = opts;
+ const items = ['products', 'company'];
+ const Harness = defineComponent({
+ setup() {
+ return () => h(NavigationMenuRoot, null, {
+ default: () => [
+ h(NavigationMenuList, null, {
+ default: () => items.map(value =>
+ h(NavigationMenuItem, { value }, {
+ default: () => [
+ h(NavigationMenuTrigger, { 'data-testid': `trigger-${value}` }, { default: () => value }),
+ h(NavigationMenuContent, contentProps, {
+ default: () => h(NavigationMenuLink, { href: '#' }, { default: () => `${value} link` }),
+ }),
+ ],
+ }),
+ ),
+ }),
+ withViewport ? h(NavigationMenuViewport) : null,
+ ],
+ });
+ },
+ });
+ return track(mount(Harness, { attachTo: document.body }));
+}
+
+function trigger(value = 'products'): HTMLElement {
+ return document.querySelector(`[data-testid="trigger-${value}"]`)!;
+}
+
+function content(): HTMLElement | null {
+ return document.querySelector('[data-primitives-navigation-menu-content]');
+}
+
+function viewport(): HTMLElement | null {
+ return document.querySelector('[data-primitives-navigation-menu-viewport]');
+}
+
+describe('navigation-menu — active trigger collection (context shadowing)', () => {
+ it('registers the trigger button (not the roving-focus span) in the nav collection', async () => {
+ mountMenu();
+ trigger().click();
+ await nextTick();
+ // The viewport position vars are derived from `activeTrigger`, which is
+ // resolved by matching collection item ids against the trigger id pattern.
+ await sleep(50);
+ const vp = viewport()!;
+ expect(vp).toBeTruthy();
+ expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-left')).not.toBe('');
+ expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-top')).not.toBe('');
+ expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-width')).not.toBe('');
+ expect(vp.style.getPropertyValue('--primitives-navigation-menu-viewport-height')).not.toBe('');
+ });
+});
+
+describe('navigation-menu — close lifecycle (content leak)', () => {
+ it('unmounts the content after a full open/close cycle instead of leaking it inline', async () => {
+ mountMenu();
+ trigger().click();
+ await nextTick();
+ await sleep(50);
+ expect(content()).toBeTruthy();
+
+ trigger().click();
+ await nextTick();
+ await sleep(50);
+ expect(trigger().getAttribute('data-state')).toBe('closed');
+ expect(viewport()).toBeNull();
+ // Regression: the isLastActiveValue latch used to keep the panel mounted
+ // forever; with the viewport gone, Teleport rendered it inline in the nav.
+ expect(content()).toBeNull();
+ });
+
+ it('keeps the previous content mounted during an item-to-item switch (crossfade)', async () => {
+ mountMenu();
+ trigger('products').click();
+ await nextTick();
+ await sleep(50);
+ trigger('company').click();
+ await nextTick();
+ const all = document.querySelectorAll('[data-primitives-navigation-menu-content]');
+ // Old panel is latched while the viewport is still mounted.
+ expect(all.length).toBe(2);
+ });
+});
+
+describe('navigation-menu — outside interaction dismiss', () => {
+ it('closes the menu on pointerdown outside', async () => {
+ mountMenu();
+ trigger().click();
+ await nextTick();
+ await sleep(50);
+ expect(trigger().getAttribute('data-state')).toBe('open');
+
+ document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
+ await nextTick();
+ await sleep(50);
+ expect(trigger().getAttribute('data-state')).toBe('closed');
+ expect(content()).toBeNull();
+ });
+
+ it('does not dismiss when the pointerdown is on the active trigger', async () => {
+ mountMenu();
+ trigger().click();
+ await nextTick();
+ await sleep(50);
+
+ trigger().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
+ await nextTick();
+ expect(trigger().getAttribute('data-state')).toBe('open');
+ });
+});
+
+describe('navigation-menu — trigger click handling', () => {
+ it('click toggles open then closed', async () => {
+ mountMenu();
+ const btn = trigger();
+ btn.click();
+ await nextTick();
+ expect(btn.getAttribute('data-state')).toBe('open');
+ btn.click();
+ await nextTick();
+ expect(btn.getAttribute('data-state')).toBe('closed');
+ });
+
+ it('stays closed after a click-close even if the pointer keeps moving over the trigger', async () => {
+ mountMenu();
+ const btn = trigger();
+ btn.click();
+ await nextTick();
+ btn.click();
+ await nextTick();
+ expect(btn.getAttribute('data-state')).toBe('closed');
+
+ // Pointer is still hovering: a pointermove must not re-open the menu
+ // (wasClickClose must reflect the pre-click open state).
+ btn.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true }));
+ await sleep(400); // > delayDuration (200ms)
+ expect(btn.getAttribute('data-state')).toBe('closed');
+ });
+
+ it('opens immediately on click even right after a pointermove', async () => {
+ mountMenu();
+ const btn = trigger();
+ btn.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
+ btn.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true }));
+ // Click before the 200ms hover debounce fires — must not be swallowed.
+ btn.click();
+ await nextTick();
+ expect(btn.getAttribute('data-state')).toBe('open');
+ });
+});
+
+describe('navigation-menu — content prop forwarding', () => {
+ it('forwards `as` from NavigationMenuContent down to the rendered element', async () => {
+ mountMenu({ contentProps: { as: 'section' } });
+ trigger().click();
+ await nextTick();
+ await sleep(50);
+ expect(content()).toBeTruthy();
+ expect(content()!.tagName).toBe('SECTION');
+ });
+});
diff --git a/vue/primitives/src/navigation-menu/utils.ts b/vue/primitives/src/navigation-menu/utils.ts
index cbfe734..d8f371f 100644
--- a/vue/primitives/src/navigation-menu/utils.ts
+++ b/vue/primitives/src/navigation-menu/utils.ts
@@ -45,6 +45,14 @@ export function clamp(value: number, min: number, max: number): number {
/** Selector identifying the link/item nodes for arrow navigation inside content. */
export const COLLECTION_ITEM_ATTR = 'data-primitives-collection-item';
+/**
+ * Namespaced collection key for the trigger collection owned by Root/Sub.
+ * `NavigationMenuList` renders a `RovingFocusGroup` (itself a collection
+ * provider) between Root/Sub and the triggers, so the default key would be
+ * shadowed and the triggers would register into the wrong collection.
+ */
+export const NAVIGATION_MENU_COLLECTION_KEY = 'NavigationMenuCollection';
+
/** Custom event dispatched by a `NavigationMenuLink` selection. */
export const LINK_SELECT_EVENT = 'navigationMenu.linkSelect';
/** Custom event bubbled to the root content when an item dismisses the menu. */
diff --git a/vue/primitives/src/tags-input/TagsInputInput.vue b/vue/primitives/src/tags-input/TagsInputInput.vue
index a968d18..f2480ed 100644
--- a/vue/primitives/src/tags-input/TagsInputInput.vue
+++ b/vue/primitives/src/tags-input/TagsInputInput.vue
@@ -49,12 +49,13 @@ function commitCurrent(target: HTMLInputElement): void {
if (ok) target.value = '';
}
-async function onEnter(event: KeyboardEvent): Promise {
+function onEnter(event: KeyboardEvent): void {
if (isComposing.value) return;
- await nextTick();
if (event.defaultPrevented) return;
const target = event.target as HTMLInputElement;
if (!target.value) return;
+ // Must run synchronously: after an await the dispatch is over and Enter's
+ // implicit form submission has already happened.
event.preventDefault();
commitCurrent(target);
}
diff --git a/vue/primitives/src/tags-input/TagsInputItem.vue b/vue/primitives/src/tags-input/TagsInputItem.vue
index 9e1c08c..61ea167 100644
--- a/vue/primitives/src/tags-input/TagsInputItem.vue
+++ b/vue/primitives/src/tags-input/TagsInputItem.vue
@@ -47,7 +47,7 @@ provideTagsInputItemContext({
{
expect((input.element as HTMLInputElement).disabled).toBe(true);
w.unmount();
});
+
+ it('tag item is labelled by its ItemText id', async () => {
+ const w = createTagsInput({ defaultValue: ['a'] });
+ // ItemText assigns the shared textId during its own setup, one tick after
+ // the item's first render.
+ await nextTick();
+ const item = w.findComponent(TagsInputItem as Component).element as HTMLElement;
+ const text = w.findComponent(TagsInputItemText as Component).element as HTMLElement;
+ expect(text.id).toBeTruthy();
+ expect(item.getAttribute('aria-labelledby')).toBe(text.id);
+ w.unmount();
+ });
+
+ it('Enter with pending text prevents default synchronously (blocks implicit form submit)', () => {
+ const w = createTagsInput();
+ const input = w.find('input').element as HTMLInputElement;
+ input.value = 'hello';
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
+ input.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ w.unmount();
+ });
+
+ it('Enter on an empty input leaves the default action alone', () => {
+ const w = createTagsInput();
+ const input = w.find('input').element as HTMLInputElement;
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
+ input.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ w.unmount();
+ });
+
+ // Mirrors demo.vue: Clear lives in a footer wrapper that is a deep
+ // descendant of Root, not a direct slot child.
+ it('Clear injects context when nested deeper inside Root (demo layout)', async () => {
+ const w = mount(
+ defineComponent({
+ setup() {
+ const tags = ref(['a', 'b']);
+ return () => h(
+ TagsInputRoot,
+ {
+ modelValue: tags.value,
+ 'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as string[]),
+ },
+ {
+ default: () => [
+ h('div', [
+ ...tags.value.map(tag =>
+ h(TagsInputItem, { key: tag, value: tag }, {
+ default: () => [h(TagsInputItemText, null, { default: () => tag })],
+ }),
+ ),
+ h(TagsInputInput),
+ ]),
+ h('div', [h(TagsInputClear, null, { default: () => 'Clear all' })]),
+ ],
+ },
+ );
+ },
+ }),
+ { attachTo: document.body },
+ );
+ const clearBtn = w.findAll('button').find(b => b.text() === 'Clear all')!;
+ await clearBtn.trigger('click');
+ await nextTick();
+ expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(0);
+ w.unmount();
+ });
});
diff --git a/vue/primitives/src/tags-input/demo.vue b/vue/primitives/src/tags-input/demo.vue
index 5f62ced..5fabf5e 100644
--- a/vue/primitives/src/tags-input/demo.vue
+++ b/vue/primitives/src/tags-input/demo.vue
@@ -29,52 +29,55 @@ function onInvalid() {
Type an address and press Enter, comma, or paste a list.
+
-
-
-
+
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+ Duplicate or limit reached
+ {{ recipients.length }} / 5 recipients
+
+
+
+ Clear all
+
+
-
-
-
- Duplicate or limit reached
- {{ recipients.length }} / 5 recipients
-
-
-
- Clear all
-
-