feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,91 @@
<script lang="ts">
import type { NavigationMenuContentImplEmits, NavigationMenuContentImplProps } from './NavigationMenuContentImpl.vue';
/**
* The panel revealed when its item's trigger is active. Handles mount/unmount via
* `Presence`, teleports into the shared `NavigationMenuViewport` when one is present
* (otherwise renders inline), and keeps content alive briefly during viewport
* transitions. Place one per `NavigationMenuItem` that has a trigger.
*/
export interface NavigationMenuContentProps extends NavigationMenuContentImplProps {
/** Keep mounted regardless of `present`. Useful for transition libraries. */
forceMount?: boolean;
}
export type NavigationMenuContentEmits = NavigationMenuContentImplEmits;
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Presence } from '../../utilities/presence';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import NavigationMenuContentImpl from './NavigationMenuContentImpl.vue';
defineOptions({ inheritAttrs: false });
const { forceMount = false, ...rest } = defineProps<NavigationMenuContentProps>();
const emit = defineEmits<NavigationMenuContentEmits>();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
const open = computed(() => itemContext.value === menuContext.modelValue.value);
// Keep content mounted briefly during viewport animation: if this item was the
// previously active one we keep present=true until model changes again.
const isLastActiveValue = ref(false);
watch(
() => menuContext.modelValue.value,
(next, prev) => {
if (prev === itemContext.value && next !== itemContext.value) isLastActiveValue.value = true;
if (next === itemContext.value) isLastActiveValue.value = false;
},
);
// 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);
emit('pointerEnterContent');
}
function handlePointerLeave() {
menuContext.onContentLeave();
emit('pointerLeaveContent');
}
</script>
<template>
<Teleport :to="menuContext.viewport.value ?? 'body'" :disabled="!menuContext.viewport.value">
<Presence v-slot="{ present: isPresent }" :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
<NavigationMenuContentImpl
v-bind="{ ...rest, ...$attrs }"
:hidden="!isPresent"
:style="{ pointerEvents: !open && menuContext.isRootMenu ? 'none' : undefined }"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
>
<slot />
</NavigationMenuContentImpl>
</Presence>
</Teleport>
</template>
@@ -0,0 +1,209 @@
<script lang="ts">
import type { DismissableLayerEmits, DismissableLayerProps } from '../../utilities/dismissable-layer';
/**
* Internal rendering body for `NavigationMenuContent`. Wraps the panel in a
* `FocusScope` and `DismissableLayer`, implementing arrow-key/Tab navigation between
* links, escape-to-dismiss, outside-interaction handling, and the `data-motion`
* transition attribute. Not part of the public anatomy; use `NavigationMenuContent`.
*/
export interface NavigationMenuContentImplProps extends DismissableLayerProps {}
export type NavigationMenuContentImplEmits = DismissableLayerEmits & {
pointerEnterContent: [];
pointerLeaveContent: [];
};
</script>
<script setup lang="ts">
import { computed, onWatcherCleanup, watchEffect } from 'vue';
import { focusFirst, getActiveElement, getTabbableCandidates } from '@robonen/platform/browsers';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { getFocusIntent, wrapArray } from '../../utilities/roving-focus/utils';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import { COLLECTION_ITEM_ATTR, EVENT_ROOT_CONTENT_DISMISS, getOpenState } from './utils';
defineOptions({ inheritAttrs: false });
const { as = 'div', disableOutsidePointerEvents = false } = defineProps<NavigationMenuContentImplProps>();
const emit = defineEmits<NavigationMenuContentImplEmits>();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
const { forwardRef, currentElement } = useForwardExpose();
const motionAttribute = computed<'from-start' | 'from-end' | 'to-start' | 'to-end' | undefined>(() => {
const items = menuContext.rootNavigationMenu.value
? Array.from(menuContext.rootNavigationMenu.value.querySelectorAll('[data-primitives-navigation-menu-trigger]'))
: [];
const values = items.map(el => el.id.split('-trigger-').pop()).filter(Boolean) as string[];
if (menuContext.dir.value === 'rtl') values.reverse();
const index = values.indexOf(itemContext.value);
const prevIndex = values.indexOf(menuContext.previousValue.value);
const isSelected = itemContext.value === menuContext.modelValue.value;
const wasSelected = prevIndex === values.indexOf(menuContext.modelValue.value) && prevIndex !== -1;
if (!isSelected && !wasSelected) return undefined;
if (index === -1) return undefined;
if (isSelected) {
return prevIndex === -1 ? undefined : index > prevIndex ? 'from-end' : 'from-start';
}
// we are leaving
const curIndex = values.indexOf(menuContext.modelValue.value);
if (curIndex === -1) return undefined;
return curIndex > index ? 'to-start' : 'to-end';
});
const IGNORED_ARROW_NAV_ELEMENTS = new Set(['INPUT', 'TEXTAREA']);
function handleKeydown(ev: KeyboardEvent) {
// Don't double-handle keydown bubbling up from a nested submenu's content:
// only the content whose nearest navigation menu is this menu should act.
const target = ev.target as HTMLElement | null;
if (target?.closest('[data-primitives-navigation-menu]') !== menuContext.rootNavigationMenu.value)
return;
const isMetaKey = ev.altKey || ev.ctrlKey || ev.metaKey;
if (ev.key === 'Tab' && !isMetaKey) {
const root = currentElement.value;
if (!root) return;
const candidates = getTabbableCandidates(root);
const focused = getActiveElement(document) as HTMLElement | null;
const idx = focused ? candidates.indexOf(focused) : -1;
const isMovingBackwards = ev.shiftKey;
const nextCandidates = isMovingBackwards
? candidates.slice(0, idx).reverse()
: candidates.slice(idx + 1);
if (focusFirst(nextCandidates)) {
ev.preventDefault();
}
else {
// edge — delegate to focus proxy
itemContext.focusProxyRef.value?.focus();
}
return;
}
const intent = getFocusIntent(ev, menuContext.orientation, menuContext.dir.value);
if (!intent) return;
const root = currentElement.value;
if (!root) return;
const focusedEl = getActiveElement(document) as HTMLElement | null;
// Let text fields keep native caret movement: don't hijack arrows when focus
// is inside an INPUT/TEXTAREA within the panel.
if (focusedEl && IGNORED_ARROW_NAV_ELEMENTS.has(focusedEl.nodeName)) return;
const linkItems = Array.from(root.querySelectorAll<HTMLElement>(`[${COLLECTION_ITEM_ATTR}]`));
const focused = getActiveElement(document) as HTMLElement | null;
const focusedIndex = focused ? linkItems.indexOf(focused) : -1;
if (intent === 'first') {
if (focusFirst(linkItems)) ev.preventDefault();
return;
}
if (intent === 'last') {
if (focusFirst([...linkItems].reverse())) ev.preventDefault();
return;
}
if (focusedIndex === -1) {
if (focusFirst(linkItems)) ev.preventDefault();
return;
}
const rotated = wrapArray(linkItems, focusedIndex);
const candidates = intent === 'prev' ? rotated.slice(1).reverse() : rotated.slice(1);
if (focusFirst(candidates)) ev.preventDefault();
}
function handleEscapeKeyDown(ev: KeyboardEvent) {
emit('escapeKeyDown', ev);
if (ev.defaultPrevented) return;
itemContext.wasEscapeCloseRef.value = true;
menuContext.onItemDismiss();
itemContext.triggerRef.value?.focus();
}
function handleFocusOutside(ev: FocusEvent) {
emit('focusOutside', ev);
if (ev.defaultPrevented) return;
itemContext.onContentFocusOutside();
const target = ev.target as Node | null;
if (menuContext.rootNavigationMenu.value?.contains(target)) ev.preventDefault();
}
function handlePointerDownOutside(ev: PointerEvent | MouseEvent) {
emit('pointerDownOutside', ev);
if (ev.defaultPrevented) return;
const target = ev.target as HTMLElement | null;
const isTrigger = menuContext.activeTrigger.value?.contains(target);
const isRootViewport = menuContext.isRootMenu && menuContext.viewport.value?.contains(target);
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(() => {
const el = currentElement.value;
if (!el || !menuContext.isRootMenu) return;
function onDismiss() {
itemContext.onRootContentClose();
// Return focus to the trigger if it was still inside this content, otherwise
// a link-select-driven close drops focus to <body> after the panel unmounts.
if (el && el.contains(getActiveElement(document)))
itemContext.triggerRef.value?.focus();
}
el.addEventListener(EVENT_ROOT_CONTENT_DISMISS, onDismiss);
onWatcherCleanup(() => el.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, onDismiss));
});
</script>
<template>
<FocusScope
:trapped="false"
@mount-auto-focus.prevent
@unmount-auto-focus.prevent
>
<DismissableLayer
:id="itemContext.contentId"
:ref="forwardRef"
:as="as"
:aria-labelledby="itemContext.triggerId"
:data-motion="motionAttribute"
:data-state="getOpenState(menuContext.modelValue.value, itemContext.value)"
:data-orientation="menuContext.orientation"
:data-primitives-navigation-menu-content="itemContext.value"
:disable-outside-pointer-events="disableOutsidePointerEvents"
v-bind="$attrs"
@keydown="handleKeydown"
@escape-key-down="handleEscapeKeyDown"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@interact-outside="emit('interactOutside', $event)"
@dismiss="handleDismiss"
@pointerenter="emit('pointerEnterContent')"
@pointerleave="emit('pointerLeaveContent')"
>
<slot />
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,85 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An optional visual cue (e.g. an arrow or underline) that tracks the currently active
* trigger. It teleports into the `NavigationMenuList` wrapper and exposes the active
* trigger's size and position as CSS variables for animated highlighting.
*/
export interface NavigationMenuIndicatorProps extends PrimitiveProps {
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useForwardExpose, useResizeObserver } from '@robonen/vue';
import { Presence } from '../../utilities/presence';
import { Primitive } from '../../internal/primitive';
import { useNavigationMenuContext } from './context';
defineOptions({ inheritAttrs: false });
const { forceMount = false, as = 'div' } = defineProps<NavigationMenuIndicatorProps>();
const menuContext = useNavigationMenuContext();
const { forwardRef } = useForwardExpose();
const isVisible = computed(() => menuContext.modelValue.value !== '');
const isHorizontal = computed(() => menuContext.orientation === 'horizontal');
const rect = ref<{ size: number; position: number } | undefined>();
function recompute() {
const trigger = menuContext.activeTrigger.value;
if (!trigger) return;
if (isHorizontal.value) {
rect.value = { size: trigger.offsetWidth, position: trigger.offsetLeft };
}
else {
rect.value = { size: trigger.offsetHeight, position: trigger.offsetTop };
}
}
// Re-measure on resize of the active trigger or the track. The observer
// re-targets automatically as those elements change and tears down on dispose.
useResizeObserver(
[() => menuContext.activeTrigger.value, () => menuContext.indicatorTrack.value],
recompute,
);
// Re-measure on trigger/track swap and orientation change (non-resize triggers).
watch(
() => [menuContext.activeTrigger.value, menuContext.indicatorTrack.value, isHorizontal.value],
recompute,
{ immediate: true },
);
const indicatorStyle = computed(() => {
if (!rect.value) return {};
return {
'--primitives-navigation-menu-indicator-size': `${rect.value.size}px`,
'--primitives-navigation-menu-indicator-position': `${rect.value.position}px`,
};
});
</script>
<template>
<Teleport v-if="menuContext.indicatorTrack.value" :to="menuContext.indicatorTrack.value">
<Presence :present="isVisible" :force-mount="forceMount">
<Primitive
:ref="forwardRef"
:as="as"
aria-hidden="true"
:data-state="isVisible ? 'visible' : 'hidden'"
:data-orientation="menuContext.orientation"
data-primitives-navigation-menu-indicator
:style="indicatorStyle"
v-bind="$attrs"
>
<slot />
</Primitive>
</Presence>
</Teleport>
</template>
@@ -0,0 +1,103 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single entry in a `NavigationMenuList`. Groups one trigger (or link) with its
* associated content panel under a shared `value`, and provides the per-item context
* that wires focus, tab order, and open state between them.
*/
export interface NavigationMenuItemProps extends PrimitiveProps {
/**
* Unique value associating this item with the active state. Generated
* automatically when omitted.
*/
value?: string;
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toValue } from 'vue';
import { focusFirst, getTabbableCandidates } from '@robonen/platform/browsers';
import { useForwardExpose, useId } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { provideNavigationMenuItemContext, useNavigationMenuContext } from './context';
import { makeContentId, makeTriggerId, removeFromTabOrder } from './utils';
const { value: valueProp, as = 'li' } = defineProps<NavigationMenuItemProps>();
useForwardExpose();
const context = useNavigationMenuContext();
const autoId = useId(undefined, 'primitives-navigation-menu-item');
const value = computed<string>(() => valueProp ?? autoId.value);
const triggerRef = shallowRef<HTMLElement | undefined>(undefined);
const focusProxyRef = shallowRef<HTMLElement | undefined>(undefined);
const wasEscapeCloseRef = ref(false);
const triggerId = computed(() => makeTriggerId(toValue(context.baseId), value.value));
const contentId = computed(() => makeContentId(toValue(context.baseId), value.value));
let restoreContentTabOrder: () => void = () => {};
function handleContentEntry(side: 'start' | 'end' = 'start') {
const el = document.getElementById(contentId.value);
if (!el) return;
restoreContentTabOrder();
const candidates = getTabbableCandidates(el);
if (candidates.length) {
focusFirst(side === 'start' ? candidates : [...candidates].reverse());
}
}
function handleContentExit() {
const el = document.getElementById(contentId.value);
if (!el) return;
const candidates = getTabbableCandidates(el);
if (candidates.length) {
restoreContentTabOrder = removeFromTabOrder(candidates);
}
}
function handleClose() {
context.onItemDismiss();
triggerRef.value?.focus();
}
// Enter/Space on the focused trigger: when this item is open, dismiss the menu
// and return focus to the trigger so a keyboard-driven close lands focus
// deterministically (otherwise it can fall to <body> after the panel unmounts).
function handleKeydown(ev: KeyboardEvent) {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
if (context.modelValue.value !== value.value) return;
handleClose();
ev.preventDefault();
}
provideNavigationMenuItemContext({
get value() { return value.value; },
get contentId() { return contentId.value; },
get triggerId() { return triggerId.value; },
triggerRef,
onTriggerChange: (el) => { triggerRef.value = el; },
focusProxyRef,
onFocusProxyChange: (el) => { focusProxyRef.value = el; },
wasEscapeCloseRef,
onEntryKeyDown: () => handleContentEntry('start'),
onFocusProxyEnter: side => handleContentEntry(side),
onContentFocusOutside: handleContentExit,
onRootContentClose: handleContentExit,
});
</script>
<template>
<Primitive
:as="as"
data-primitives-navigation-menu-item
@keydown="handleKeydown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A navigable link, rendered as an `<a>` by default, usable as a top-level menu item
* or inside a content panel. Selecting it dismisses the open menu (unless the `select`
* event is prevented) and marks itself with `aria-current` when `active`.
*/
export interface NavigationMenuLinkProps extends PrimitiveProps {
/** Marks the link as active for styling and aria-current. */
active?: boolean;
}
export interface NavigationMenuLinkEmits {
/**
* Fired when the user selects the link (mouse or keyboard). Call
* `event.preventDefault()` to keep the menu open. The `detail.originalEvent`
* carries the originating click so consumers can inspect modifier keys etc.
*/
select: [event: CustomEvent<{ originalEvent: Event }>];
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { COLLECTION_ITEM_ATTR, EVENT_ROOT_CONTENT_DISMISS, LINK_SELECT_EVENT } from './utils';
const { as = 'a', active = false } = defineProps<NavigationMenuLinkProps>();
const emit = defineEmits<NavigationMenuLinkEmits>();
const { forwardRef } = useForwardExpose();
function handleClick(event: MouseEvent) {
const currentTarget = event.currentTarget as HTMLElement | null;
if (!currentTarget) return;
const linkSelectEvent = new CustomEvent<{ originalEvent: Event }>(LINK_SELECT_EVENT, {
bubbles: true,
cancelable: true,
detail: { originalEvent: event },
});
// Browser event handlers run synchronously; listen once for prevention semantics.
currentTarget.addEventListener(
LINK_SELECT_EVENT,
e => emit('select', e as CustomEvent<{ originalEvent: Event }>),
{ once: true },
);
currentTarget.dispatchEvent(linkSelectEvent);
if (!linkSelectEvent.defaultPrevented && !event.metaKey) {
const rootContentDismissEvent = new CustomEvent(EVENT_ROOT_CONTENT_DISMISS, {
bubbles: true,
cancelable: true,
});
// Dispatch on the actual clicked target (matches APG/nested-link behavior).
(event.target ?? currentTarget).dispatchEvent(rootContentDismissEvent);
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-active="active ? '' : undefined"
:aria-current="active ? 'page' : undefined"
:[COLLECTION_ITEM_ATTR]="''"
@click="handleClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The horizontal (or vertical) list of menu items. Renders a `RovingFocusGroup`
* inside a positioned wrapper that also serves as the track for `NavigationMenuIndicator`.
* Place one directly inside `NavigationMenuRoot` (or `NavigationMenuSub`) to hold its
* `NavigationMenuItem`s.
*/
export interface NavigationMenuListProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { RovingFocusGroup } from '../../utilities/roving-focus';
import { useNavigationMenuContext } from './context';
defineOptions({ inheritAttrs: false });
const { as = 'ul' } = defineProps<NavigationMenuListProps>();
const menuContext = useNavigationMenuContext();
const { forwardRef, currentElement } = useForwardExpose();
onMounted(() => {
menuContext.onIndicatorTrackChange(currentElement.value);
});
watch(currentElement, (el) => {
menuContext.onIndicatorTrackChange(el);
});
</script>
<template>
<div :ref="forwardRef" data-primitives-navigation-menu-list-wrapper style="position: relative">
<RovingFocusGroup
v-bind="$attrs"
:as="as"
:orientation="menuContext.orientation"
:dir="menuContext.dir.value"
:loop="false"
:data-orientation="menuContext.orientation"
data-primitives-navigation-menu-list
>
<slot />
</RovingFocusGroup>
</div>
</template>
@@ -0,0 +1,233 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
import type { PrimitiveProps } from '../../internal/primitive';
import type { Orientation } from '../../utilities/roving-focus';
/**
* A collection of navigation links and disclosure menus, typically used for the
* primary site header. `NavigationMenuRoot` owns the open state and hover/click
* timing for the whole menu, rendering as a `<nav>` landmark and providing context
* to every list, item, trigger, content, viewport, and indicator beneath it. Reach
* for it over a generic dropdown when you need keyboard-accessible, animatable
* mega-menu panels that share a single active state.
*/
export interface NavigationMenuRootProps extends PrimitiveProps {
/** Uncontrolled initial value. */
defaultValue?: string;
/** Reading direction. Falls back to `ConfigProvider`. */
dir?: Direction;
/** Menu orientation. @default 'horizontal' */
orientation?: Orientation;
/**
* Time (ms) between pointer entering a trigger and the menu opening.
* @default 200
*/
delayDuration?: number;
/**
* Window (ms) during which switching triggers skips `delayDuration`.
* @default 300
*/
skipDelayDuration?: number;
/** Disable opening via click. @default false */
disableClickTrigger?: boolean;
/** Disable opening via hover. @default false */
disableHoverTrigger?: boolean;
/** Disable closing when pointer leaves the menu. @default false */
disablePointerLeaveClose?: boolean;
/** Unmount content when hidden. @default true */
unmountOnHide?: boolean;
}
export interface NavigationMenuRootEmits {
'update:modelValue': [value: string];
}
</script>
<script setup lang="ts">
import type { Ref } from 'vue';
import { computed, onScopeDispose, onWatcherCleanup, ref, shallowRef, toRef, watchEffect } from 'vue';
import { useForwardExpose, useId } from '@robonen/vue';
import { useCollectionProvider } from '../../utilities/collection';
import { useConfig } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { provideNavigationMenuContext } from './context';
import { EVENT_ROOT_CONTENT_DISMISS, NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
const {
defaultValue,
dir,
orientation = 'horizontal',
delayDuration = 200,
skipDelayDuration = 300,
disableClickTrigger = false,
disableHoverTrigger = false,
disablePointerLeaveClose = false,
unmountOnHide = true,
as = 'nav',
} = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => unknown;
}>();
const config = useConfig();
const dirRef = computed<Direction>(() => dir ?? config.dir.value);
const localValue = ref<string>(defaultValue ?? '');
/** Controlled active item value. Use `v-model`. */
const modelValue = defineModel<string | undefined>({
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
const next = v ?? '';
localValue.value = next;
return next;
},
}) as unknown as Ref<string>;
const previousValue = ref<string>('');
const baseId = useId(undefined, 'primitives-navigation-menu');
const { forwardRef, currentElement: rootNavigationMenu } = useForwardExpose();
const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
const viewport = shallowRef<HTMLElement | undefined>(undefined);
const activeTrigger = shallowRef<HTMLElement | undefined>(undefined);
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).
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let skipDelayTimer: ReturnType<typeof setTimeout> | undefined;
const isDelaySkipped = ref(false);
function clearDebounce() {
if (debounceTimer !== undefined) {
clearTimeout(debounceTimer);
debounceTimer = undefined;
}
}
function clearSkipDelay() {
if (skipDelayTimer !== undefined) {
clearTimeout(skipDelayTimer);
skipDelayTimer = undefined;
}
}
function triggerSkipDelay() {
clearSkipDelay();
isDelaySkipped.value = true;
skipDelayTimer = setTimeout(() => {
isDelaySkipped.value = false;
skipDelayTimer = undefined;
}, skipDelayDuration);
}
const computedDelay = computed(() => {
const isOpen = modelValue.value !== '';
if (isOpen || isDelaySkipped.value) return 150;
return delayDuration;
});
function debouncedSet(val: string) {
clearDebounce();
debounceTimer = setTimeout(() => {
previousValue.value = modelValue.value;
modelValue.value = val;
debounceTimer = undefined;
}, computedDelay.value);
}
function cancelDebounce() {
clearDebounce();
}
watchEffect(() => {
if (!modelValue.value) return;
const items = getItems().map(i => i.ref);
// Trigger id pattern: `${baseId}-trigger-${value}`
const matched = items.find(item => item.id.includes(`-trigger-${modelValue.value}`));
if (matched) activeTrigger.value = matched;
});
function onItemDismiss() {
previousValue.value = modelValue.value;
modelValue.value = '';
}
// Custom event isn't part of HTMLElementEventMap so wire it up manually.
watchEffect(() => {
const el = rootNavigationMenu.value;
if (!el) return;
el.addEventListener(EVENT_ROOT_CONTENT_DISMISS, onItemDismiss);
onWatcherCleanup(() => el.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, onItemDismiss));
});
onScopeDispose(() => {
clearDebounce();
clearSkipDelay();
});
provideNavigationMenuContext({
isRootMenu: true,
modelValue,
previousValue,
baseId,
dir: dirRef,
orientation,
disableClickTrigger: toRef(() => disableClickTrigger),
disableHoverTrigger: toRef(() => disableHoverTrigger),
disablePointerLeaveClose: toRef(() => disablePointerLeaveClose),
unmountOnHide: toRef(() => unmountOnHide),
rootNavigationMenu,
activeTrigger,
onActiveTriggerChange: (el) => { activeTrigger.value = el; },
indicatorTrack,
onIndicatorTrackChange: (el) => { indicatorTrack.value = el; },
viewport,
onViewportChange: (el) => { viewport.value = el; },
onTriggerEnter: (val) => {
debouncedSet(val);
},
onTriggerLeave: () => {
triggerSkipDelay();
debouncedSet('');
},
onContentEnter: () => {
cancelDebounce();
},
onContentLeave: () => {
if (!disablePointerLeaveClose) debouncedSet('');
},
onItemSelect: (val) => {
previousValue.value = modelValue.value;
modelValue.value = val;
},
onItemDismiss,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:aria-label="($attrs['aria-label'] ?? 'Main') as string"
:data-orientation="orientation"
:dir="dirRef"
data-primitives-navigation-menu
v-bind="$attrs"
>
<slot :model-value="modelValue" />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,125 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { Orientation } from '../../utilities/roving-focus';
/**
* Nests a second navigation menu inside a `NavigationMenuContent` panel, with its
* own independent active value while inheriting the parent's timing and direction.
* Use it to build multi-level menus where a content panel itself contains a list of
* triggers and sub-panels.
*/
export interface NavigationMenuSubProps extends PrimitiveProps {
/** Uncontrolled initial value. */
defaultValue?: string;
/** Submenu orientation. @default 'horizontal' */
orientation?: Orientation;
}
export interface NavigationMenuSubEmits {
'update:modelValue': [value: string];
}
</script>
<script setup lang="ts">
import type { Ref } from 'vue';
import { ref, shallowRef, watchEffect } from 'vue';
import { useForwardExpose, useId } from '@robonen/vue';
import { useCollectionProvider } from '../../utilities/collection';
import { Primitive } from '../../internal/primitive';
import { provideNavigationMenuContext, useNavigationMenuContext } from './context';
import { NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
defineEmits<NavigationMenuSubEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => unknown;
}>();
const localValue = ref<string>(defaultValue ?? '');
/** Controlled active value of the submenu. Use `v-model`. */
const modelValue = defineModel<string | undefined>({
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
const next = v ?? '';
localValue.value = next;
return next;
},
}) as unknown as Ref<string>;
const previousValue = ref<string>('');
const parentContext = useNavigationMenuContext();
const { forwardRef, currentElement } = useForwardExpose();
const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
const viewport = shallowRef<HTMLElement | undefined>(undefined);
const activeTrigger = shallowRef<HTMLElement | undefined>(undefined);
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
const baseId = useId(undefined, 'primitives-navigation-menu-sub');
watchEffect(() => {
if (!modelValue.value) return;
const items = getItems().map(i => i.ref);
const matched = items.find(item => item.id.includes(`-trigger-${modelValue.value}`));
if (matched) activeTrigger.value = matched;
});
provideNavigationMenuContext({
...parentContext,
isRootMenu: false,
modelValue,
previousValue,
baseId,
orientation,
rootNavigationMenu: currentElement,
activeTrigger,
onActiveTriggerChange: (el) => { activeTrigger.value = el; },
indicatorTrack,
onIndicatorTrackChange: (el) => { indicatorTrack.value = el; },
viewport,
onViewportChange: (el) => { viewport.value = el; },
onTriggerEnter: (val) => {
modelValue.value = val;
},
onTriggerLeave: () => {
/* submenus don't auto-close on trigger leave */
},
onContentEnter: () => {
/* no-op for submenus */
},
onContentLeave: () => {
/* no-op for submenus */
},
onItemSelect: (val) => {
previousValue.value = modelValue.value;
modelValue.value = val;
},
onItemDismiss: () => {
previousValue.value = modelValue.value;
modelValue.value = '';
},
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:data-orientation="orientation"
data-primitives-navigation-menu
v-bind="$attrs"
>
<slot :model-value="modelValue" />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,154 @@
<script lang="ts">
import type { ComponentPublicInstance } from 'vue';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that opens its item's `NavigationMenuContent` on hover, click, or keyboard.
* Reflects open state via `data-state` and `aria-expanded`, and manages the hover/click
* timing handshake with the root. Use it inside a `NavigationMenuItem` for entries that
* reveal a panel; use `NavigationMenuLink` instead for plain navigation links.
*/
export interface NavigationMenuTriggerProps extends PrimitiveProps {
/** Disables interaction with this trigger. */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { unrefElement, useForwardExpose } from '@robonen/vue';
import { useCollectionInjector } from '../../utilities/collection';
import { Primitive } from '../../internal/primitive';
import { RovingFocusItem } from '../../utilities/roving-focus';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import { NAVIGATION_MENU_COLLECTION_KEY, getOpenState } from './utils';
defineOptions({ inheritAttrs: false });
const { disabled = false, as = 'button' } = defineProps<NavigationMenuTriggerProps>();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
const { CollectionItem } = useCollectionInjector<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
// Set after a pointermove open so further pointermoves don't re-fire
// onTriggerEnter; reset on pointerleave.
const hasPointerMoveOpened = ref(false);
const wasClickClose = ref(false);
const open = computed(() => itemContext.value === menuContext.modelValue.value);
watch(triggerElement, (el) => {
itemContext.onTriggerChange(el ?? undefined);
});
onMounted(() => {
if (triggerElement.value) itemContext.onTriggerChange(triggerElement.value);
});
function handlePointerEnter() {
if (menuContext.disableHoverTrigger.value) return;
wasClickClose.value = false;
itemContext.wasEscapeCloseRef.value = false;
}
function handlePointerMove(ev: PointerEvent) {
if (menuContext.disableHoverTrigger.value) return;
if (ev.pointerType !== 'mouse') return;
if (disabled || wasClickClose.value || itemContext.wasEscapeCloseRef.value || hasPointerMoveOpened.value) return;
menuContext.onTriggerEnter(itemContext.value);
hasPointerMoveOpened.value = true;
}
function handlePointerLeave(ev: PointerEvent) {
if (menuContext.disableHoverTrigger.value) return;
if (ev.pointerType !== 'mouse') return;
if (disabled) return;
menuContext.onTriggerLeave();
hasPointerMoveOpened.value = false;
}
function handleClick(event: MouseEvent | PointerEvent) {
const isMouse = !('pointerType' in event) || (event as PointerEvent).pointerType === 'mouse';
if (isMouse && menuContext.disableClickTrigger.value) return;
// 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) {
const verticalEntryKey = menuContext.dir.value === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
const entryKey = menuContext.orientation === 'horizontal' ? 'ArrowDown' : verticalEntryKey;
if (open.value && ev.key === entryKey) {
itemContext.onEntryKeyDown();
ev.preventDefault();
ev.stopPropagation();
}
}
function setFocusProxyRef(node: Element | ComponentPublicInstance | null) {
if (!node) {
itemContext.onFocusProxyChange(undefined);
return;
}
const el = unrefElement(node as Parameters<typeof unrefElement>[0]);
if (el instanceof HTMLElement) itemContext.onFocusProxyChange(el);
}
function handleVisuallyHiddenFocus(ev: FocusEvent) {
const content = document.getElementById(itemContext.contentId);
const prevFocused = ev.relatedTarget as HTMLElement | null;
const wasTriggerFocused = prevFocused === triggerElement.value;
const wasFocusFromContent = !!content?.contains(prevFocused);
if (wasTriggerFocused || !wasFocusFromContent)
itemContext.onFocusProxyEnter(wasTriggerFocused ? 'start' : 'end');
}
</script>
<template>
<!-- CollectionItem must wrap the button itself (not RovingFocusItem, which
renders its own span) so the element registered in the nav collection
carries the trigger id that Root/Sub match `activeTrigger` against. -->
<RovingFocusItem :focusable="!disabled">
<CollectionItem :value="{ value: itemContext.value }">
<Primitive
:id="itemContext.triggerId"
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-state="getOpenState(menuContext.modelValue.value, itemContext.value)"
:aria-expanded="open"
:aria-controls="itemContext.contentId"
data-primitives-navigation-menu-trigger
data-primitives-collection-item
v-bind="$attrs"
@pointerenter="handlePointerEnter"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@click="handleClick"
@keydown="handleKeydown"
>
<slot />
</Primitive>
</CollectionItem>
</RovingFocusItem>
<template v-if="open">
<VisuallyHidden
:ref="setFocusProxyRef"
aria-hidden="true"
:tabindex="0"
@focus="handleVisuallyHiddenFocus"
/>
<span v-if="menuContext.viewport.value" :aria-owns="itemContext.contentId" />
</template>
</template>
@@ -0,0 +1,179 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An optional shared container that all `NavigationMenuContent` panels teleport into,
* positioned beneath the active trigger and sized to the open panel (exposed as CSS
* variables for animating between panels). Render one inside `NavigationMenuRoot` for
* a single animated mega-menu surface; omit it to render each content inline.
*/
export interface NavigationMenuViewportProps extends PrimitiveProps {
/** Keep mounted regardless of open state. */
forceMount?: boolean;
/**
* Alignment of the viewport relative to the active trigger. Applies to the
* main axis (horizontal orientation) and the cross axis (vertical orientation).
* @default 'center'
*/
align?: 'start' | 'center' | 'end';
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
import { useEventListener, useForwardExpose, useResizeObserver } from '@robonen/vue';
import { Presence } from '../../utilities/presence';
import { clamp } from '@robonen/stdlib';
import { Primitive } from '../../internal/primitive';
import { useNavigationMenuContext } from './context';
import { whenMouse } from './utils';
defineOptions({ inheritAttrs: false });
const { forceMount = false, align = 'center', as = 'div' } = defineProps<NavigationMenuViewportProps>();
const menuContext = useNavigationMenuContext();
const { forwardRef, currentElement } = useForwardExpose();
const open = computed(() => menuContext.modelValue.value !== '');
const present = computed(() => open.value);
const size = ref<{ width: number; height: number } | undefined>();
const activeContentEl = shallowRef<HTMLElement | undefined>(undefined);
watch(currentElement, (el) => {
menuContext.onViewportChange(el);
});
// Track which content is currently open and observe its size.
let contentObserver: ResizeObserver | undefined;
function watchOpenContent() {
contentObserver?.disconnect();
const root = currentElement.value;
if (!root) return;
const openContent = root.querySelector<HTMLElement>('[data-state=open]');
activeContentEl.value = openContent ?? undefined;
if (!openContent) return;
contentObserver = new ResizeObserver(() => {
size.value = { width: openContent.offsetWidth, height: openContent.offsetHeight };
});
contentObserver.observe(openContent);
size.value = { width: openContent.offsetWidth, height: openContent.offsetHeight };
}
watch(() => menuContext.modelValue.value, () => {
// Defer to next microtask so the new content has mounted.
queueMicrotask(watchOpenContent);
});
watch(currentElement, () => {
if (currentElement.value) watchOpenContent();
});
onScopeDispose(() => {
contentObserver?.disconnect();
});
// Bumped whenever the layout viewport / body / root resizes so the position
// recomputes (getBoundingClientRect isn't reactive on its own).
const repositionTick = ref(0);
function reposition() {
repositionTick.value++;
}
useEventListener('resize', reposition);
useResizeObserver(currentElement, reposition);
const SCREEN_OFFSET = 10;
// Position based on active trigger, clamped to all four viewport edges. For
// horizontal orientation `align` shifts the main (horizontal) axis; for vertical
// orientation it shifts the cross (vertical) axis so a side-anchored panel can be
// start/center/end aligned against its trigger.
const positionStyle = computed(() => {
// Touch the tick so resize re-runs the computation.
void repositionTick.value;
const viewport = currentElement.value;
const trigger = menuContext.activeTrigger.value;
if (!viewport || !trigger || !size.value) return {};
const triggerRect = trigger.getBoundingClientRect();
const viewportWidth = size.value.width;
const viewportHeight = size.value.height;
const isHorizontal = menuContext.orientation === 'horizontal';
let left: number;
let top: number;
if (isHorizontal) {
switch (align) {
case 'start':
left = triggerRect.left;
break;
case 'end':
left = triggerRect.right - viewportWidth;
break;
default:
left = triggerRect.left + (triggerRect.width / 2) - (viewportWidth / 2);
}
top = triggerRect.bottom;
}
else {
// Vertical: open beside the trigger; `align` controls the cross (vertical) axis.
left = triggerRect.right;
switch (align) {
case 'start':
top = triggerRect.top;
break;
case 'end':
top = triggerRect.bottom - viewportHeight;
break;
default:
top = triggerRect.top + (triggerRect.height / 2) - (viewportHeight / 2);
}
}
const maxLeft = window.innerWidth - viewportWidth - SCREEN_OFFSET;
const maxTop = window.innerHeight - viewportHeight - SCREEN_OFFSET;
left = clamp(left, SCREEN_OFFSET, Math.max(SCREEN_OFFSET, maxLeft));
top = clamp(top, SCREEN_OFFSET, Math.max(SCREEN_OFFSET, maxTop));
return {
'--primitives-navigation-menu-viewport-width': `${viewportWidth}px`,
'--primitives-navigation-menu-viewport-height': `${viewportHeight}px`,
'--primitives-navigation-menu-viewport-left': `${left}px`,
'--primitives-navigation-menu-viewport-top': `${top}px`,
};
});
function handlePointerEnter() {
menuContext.onContentEnter(menuContext.modelValue.value);
}
const handlePointerLeave = whenMouse(() => {
menuContext.onContentLeave();
});
</script>
<template>
<Presence v-slot="{ present: isPresent }" :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
<Primitive
:ref="forwardRef"
:as="as"
:data-state="open ? 'open' : 'closed'"
:data-orientation="menuContext.orientation"
data-primitives-navigation-menu-viewport
:hidden="!isPresent"
:style="{
...positionStyle,
// Prevent interaction while the panel is animating out.
pointerEvents: !open && menuContext.isRootMenu ? 'none' : undefined,
}"
v-bind="$attrs"
@pointerenter="handlePointerEnter"
@pointerleave="handlePointerLeave"
>
<slot />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,56 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h } from 'vue';
import { NavigationMenuList, NavigationMenuRoot } from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountRoot(attrs: Record<string, unknown> = {}) {
const Harness = defineComponent({
setup() {
return () => h(NavigationMenuRoot, attrs, {
default: () => h(NavigationMenuList),
});
},
});
return track(mount(Harness, { attachTo: document.body }));
}
describe('navigation-menu — root landmark a11y', () => {
it('renders a <nav> element (implicit role=navigation)', () => {
mountRoot();
const nav = document.querySelector('nav');
expect(nav).toBeTruthy();
// <nav> has implicit role="navigation" — no explicit role attribute needed.
});
it('falls back to aria-label="Main" when no label is supplied', () => {
mountRoot();
const nav = document.querySelector('nav') as HTMLElement;
expect(nav.getAttribute('aria-label')).toBe('Main');
});
it('honours a user-supplied aria-label', () => {
mountRoot({ 'aria-label': 'Primary site navigation' });
const nav = document.querySelector('nav') as HTMLElement;
expect(nav.getAttribute('aria-label')).toBe('Primary site navigation');
});
it('exposes data-orientation matching the orientation prop', () => {
mountRoot({ orientation: 'vertical' });
const nav = document.querySelector('nav') as HTMLElement;
expect(nav.getAttribute('data-orientation')).toBe('vertical');
});
});
@@ -0,0 +1,291 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuRoot,
NavigationMenuTrigger,
NavigationMenuViewport,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function press(el: Element, key: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}
interface MenuMountOptions {
withViewport?: boolean;
contentProps?: Record<string, unknown>;
contentSlot?: () => any;
rootProps?: Record<string, unknown>;
triggerProps?: Record<string, unknown>;
itemProps?: Record<string, unknown>;
listProps?: Record<string, unknown>;
}
function mountMenu(opts: MenuMountOptions = {}) {
const {
withViewport = true,
contentProps = {},
contentSlot,
rootProps = {},
triggerProps = {},
itemProps = {},
listProps = {},
} = opts;
const items = ['products', 'company'];
const Harness = defineComponent({
setup() {
return () => h(NavigationMenuRoot, rootProps, {
default: () => [
h(NavigationMenuList, listProps, {
default: () => items.map(value =>
h(NavigationMenuItem, { value, ...itemProps }, {
default: () => [
h(NavigationMenuTrigger, { 'data-testid': `trigger-${value}`, ...triggerProps }, { default: () => value }),
h(NavigationMenuContent, { 'data-testid': `content-${value}`, ...contentProps }, {
default: contentSlot ?? (() => 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<HTMLElement>(`[data-testid="trigger-${value}"]`)!;
}
function content(value?: string): HTMLElement | null {
if (value) return document.querySelector<HTMLElement>(`[data-testid="content-${value}"]`);
return document.querySelector<HTMLElement>('[data-primitives-navigation-menu-content]');
}
describe('navigation-menu — polymorphism (as)', () => {
it('renders the root as a custom element when `as` is supplied', () => {
mountMenu({ rootProps: { as: 'div' } });
expect(document.querySelector('nav')).toBeNull();
const root = document.querySelector('[data-primitives-navigation-menu]') as HTMLElement;
expect(root.tagName).toBe('DIV');
// landmark label fallback still applies
expect(root.getAttribute('aria-label')).toBe('Main');
});
it('defaults the root to a <nav> landmark when `as` is omitted', () => {
mountMenu();
expect(document.querySelector('nav')).toBeTruthy();
});
it('renders the trigger as a custom element', async () => {
mountMenu({ triggerProps: { as: 'a', href: '#go' } });
const t = trigger();
expect(t.tagName).toBe('A');
// non-button triggers must not get type="button"
expect(t.getAttribute('type')).toBeNull();
});
it('keeps type="button" for the default button trigger', () => {
mountMenu();
expect(trigger().getAttribute('type')).toBe('button');
});
it('renders the item as a custom element (default li)', () => {
mountMenu();
expect(document.querySelector('li[data-primitives-navigation-menu-item]')).toBeTruthy();
mountMenu({ itemProps: { as: 'div' } });
expect(document.querySelector('div[data-primitives-navigation-menu-item]')).toBeTruthy();
});
it('renders the list inner element as a custom element (default ul)', () => {
mountMenu();
expect(document.querySelector('ul[data-primitives-navigation-menu-list]')).toBeTruthy();
mountMenu({ listProps: { as: 'div' } });
expect(document.querySelector('div[data-primitives-navigation-menu-list]')).toBeTruthy();
});
});
describe('navigation-menu — indicator a11y/polymorphism', () => {
it('marks the indicator aria-hidden and supports `as`', async () => {
const Harness = defineComponent({
setup() {
return () => h(NavigationMenuRoot, null, {
default: () => h(NavigationMenuList, null, {
default: () => [
h(NavigationMenuItem, { value: 'a' }, {
default: () => h(NavigationMenuTrigger, { 'data-testid': 'trigger-a' }, { default: () => 'a' }),
}),
h(NavigationMenuIndicator, { as: 'span' }),
],
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
// open so the indicator becomes present
trigger('a').click();
await nextTick();
await sleep(20);
const indicator = document.querySelector('[data-primitives-navigation-menu-indicator]') as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.getAttribute('aria-hidden')).toBe('true');
expect(indicator.tagName).toBe('SPAN');
});
});
describe('navigation-menu — content hidden/pointer-events when not present', () => {
it('removes the hidden attribute on the open content panel', async () => {
mountMenu();
trigger().click();
await nextTick();
await sleep(50);
const panel = content('products')!;
expect(panel).toBeTruthy();
expect(panel.hasAttribute('hidden')).toBe(false);
});
});
describe('navigation-menu — DismissableLayer prop forwarding', () => {
it('forwards disableOutsidePointerEvents from content to the dismissable layer (body becomes inert)', async () => {
mountMenu({ contentProps: { disableOutsidePointerEvents: true } });
trigger().click();
await nextTick();
await sleep(50);
// DismissableLayer applies pointer-events:none to document.body when a layer
// with disableOutsidePointerEvents is active.
expect(document.body.style.pointerEvents).toBe('none');
});
it('does not make the body inert by default (disableOutsidePointerEvents off)', async () => {
mountMenu();
trigger().click();
await nextTick();
await sleep(50);
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
describe('navigation-menu — arrow navigation bail-out for text fields', () => {
it('does not move focus away from an INPUT inside the content on ArrowDown', async () => {
mountMenu({
contentSlot: () => [
h('input', { 'data-testid': 'field', 'data-primitives-collection-item': '' }),
h(NavigationMenuLink, { href: '#', 'data-testid': 'link' }, { default: () => 'link' }),
],
});
trigger().click();
await nextTick();
await sleep(50);
const field = document.querySelector<HTMLInputElement>('[data-testid="field"]')!;
field.focus();
expect(document.activeElement).toBe(field);
press(content('products')!, 'ArrowDown');
await nextTick();
// Focus must stay in the text field (native caret movement), not jump to the link.
expect(document.activeElement).toBe(field);
});
});
describe('navigation-menu — link select payload', () => {
it('emits select with detail.originalEvent', async () => {
const selectEvents: CustomEvent[] = [];
const Harness = defineComponent({
setup() {
return () => h(NavigationMenuRoot, null, {
default: () => h(NavigationMenuList, null, {
default: () => h(NavigationMenuItem, { value: 'a' }, {
default: () => h(NavigationMenuLink, {
href: '#',
'data-testid': 'plainlink',
onSelect: (e: CustomEvent) => selectEvents.push(e),
}, { default: () => 'link' }),
}),
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
const link = document.querySelector<HTMLElement>('[data-testid="plainlink"]')!;
link.click();
await nextTick();
expect(selectEvents.length).toBe(1);
expect(selectEvents[0]!.detail).toBeTruthy();
expect(selectEvents[0]!.detail.originalEvent).toBeInstanceOf(Event);
});
it('keeps the menu open when select is prevented', async () => {
mountMenu({
contentSlot: () => h(NavigationMenuLink, {
href: '#',
'data-testid': 'prevlink',
onSelect: (e: CustomEvent) => e.preventDefault(),
}, { default: () => 'link' }),
});
trigger().click();
await nextTick();
await sleep(50);
expect(trigger().getAttribute('data-state')).toBe('open');
const link = document.querySelector<HTMLElement>('[data-testid="prevlink"]')!;
link.click();
await nextTick();
await sleep(50);
expect(trigger().getAttribute('data-state')).toBe('open');
});
});
describe('navigation-menu — item keyboard close + focus return', () => {
it('closes and returns focus to the trigger on Enter when open', async () => {
mountMenu();
const btn = trigger();
btn.click();
await nextTick();
await sleep(20);
expect(btn.getAttribute('data-state')).toBe('open');
btn.focus();
press(btn, 'Enter');
await nextTick();
await sleep(20);
expect(btn.getAttribute('data-state')).toBe('closed');
expect(document.activeElement).toBe(btn);
});
it('closes and returns focus to the trigger on Space when open', async () => {
mountMenu();
const btn = trigger();
btn.click();
await nextTick();
await sleep(20);
btn.focus();
press(btn, ' ');
await nextTick();
await sleep(20);
expect(btn.getAttribute('data-state')).toBe('closed');
expect(document.activeElement).toBe(btn);
});
});
@@ -0,0 +1,200 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuRoot,
NavigationMenuTrigger,
NavigationMenuViewport,
} from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(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<string, unknown>;
}
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<HTMLElement>(`[data-testid="trigger-${value}"]`)!;
}
function content(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-primitives-navigation-menu-content]');
}
function viewport(): HTMLElement | null {
return document.querySelector<HTMLElement>('[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');
});
});
@@ -0,0 +1,65 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import type { Orientation } from '../../utilities/roving-focus';
import { useContextFactory } from '@robonen/vue';
/**
* Context shared by `NavigationMenuRoot` and `NavigationMenuSub`. Children
* (item / list / trigger / content / viewport / indicator) read from this
* single context regardless of whether they are inside a root or a submenu.
*/
export interface NavigationMenuContext {
isRootMenu: boolean;
modelValue: Ref<string>;
previousValue: Ref<string>;
baseId: ComputedRef<string> | Ref<string>;
dir: Ref<Direction>;
orientation: Orientation;
disableClickTrigger: Ref<boolean>;
disableHoverTrigger: Ref<boolean>;
disablePointerLeaveClose: Ref<boolean>;
unmountOnHide: Ref<boolean>;
rootNavigationMenu: ShallowRef<HTMLElement | undefined>;
activeTrigger: ShallowRef<HTMLElement | undefined>;
onActiveTriggerChange: (el: HTMLElement | undefined) => void;
indicatorTrack: ShallowRef<HTMLElement | undefined>;
onIndicatorTrackChange: (el: HTMLElement | undefined) => void;
viewport: ShallowRef<HTMLElement | undefined>;
onViewportChange: (el: HTMLElement | undefined) => void;
onTriggerEnter: (itemValue: string) => void;
onTriggerLeave: () => void;
onContentEnter: (itemValue: string) => void;
onContentLeave: () => void;
onItemSelect: (itemValue: string) => void;
onItemDismiss: () => void;
}
export interface NavigationMenuItemContext {
value: string;
contentId: string;
triggerId: string;
triggerRef: ShallowRef<HTMLElement | undefined>;
onTriggerChange: (el: HTMLElement | undefined) => void;
focusProxyRef: ShallowRef<HTMLElement | undefined>;
onFocusProxyChange: (el: HTMLElement | undefined) => void;
wasEscapeCloseRef: Ref<boolean>;
onEntryKeyDown: () => void;
onFocusProxyEnter: (side: 'start' | 'end') => void;
onContentFocusOutside: () => void;
onRootContentClose: () => void;
}
export const {
inject: useNavigationMenuContext,
provide: provideNavigationMenuContext,
} = useContextFactory<NavigationMenuContext>('NavigationMenu');
export const {
inject: useNavigationMenuItemContext,
provide: provideNavigationMenuItemContext,
} = useContextFactory<NavigationMenuItemContext>('NavigationMenuItem');
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuRoot,
NavigationMenuTrigger,
NavigationMenuViewport,
} from '@robonen/primitives';
const value = ref('');
const products = [
{ title: 'Analytics', desc: 'Real-time dashboards for every metric.', icon: 'i-carbon-chart-line' },
{ title: 'Automation', desc: 'Workflows that run themselves.', icon: 'i-carbon-flow' },
{ title: 'Reports', desc: 'Share insights with your team.', icon: 'i-carbon-document' },
{ title: 'Integrations', desc: 'Connect the tools you already use.', icon: 'i-carbon-plug' },
];
const resources = [
{ title: 'Documentation', desc: 'Guides and API reference.' },
{ title: 'Changelog', desc: 'What shipped this week.' },
{ title: 'Community', desc: 'Ask questions, share patterns.' },
];
const triggerClass = 'group inline-flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-fg outline-none transition-colors hover:bg-bg-subtle focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-bg-subtle';
const linkClass = 'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium text-fg no-underline outline-none transition-colors hover:bg-bg-subtle focus-visible:ring-2 focus-visible:ring-ring data-[active]:text-accent';
</script>
<template>
<NavigationMenuRoot
v-model="value"
class="demo-card relative flex w-full justify-center p-1.5 shadow-sm"
>
<NavigationMenuList class="flex list-none items-center gap-1 p-0">
<NavigationMenuItem value="products">
<NavigationMenuTrigger :class="triggerClass">
Products
<span
class="i-carbon-chevron-down text-fg-muted transition-transform duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
<NavigationMenuContent
class="grid w-[28rem] grid-cols-2 gap-1 p-3 outline-none data-[motion=from-start]:animate-in data-[motion=from-end]:animate-in"
>
<NavigationMenuLink
v-for="item in products"
:key="item.title"
href="#"
class="flex gap-3 rounded-lg p-3 no-underline outline-none transition-colors hover:bg-bg-subtle focus-visible:ring-2 focus-visible:ring-ring"
>
<span :class="item.icon" class="mt-0.5 shrink-0 text-accent" aria-hidden="true" />
<span class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-fg">{{ item.title }}</span>
<span class="text-xs text-fg-muted">{{ item.desc }}</span>
</span>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem value="resources">
<NavigationMenuTrigger :class="triggerClass">
Resources
<span
class="i-carbon-chevron-down text-fg-muted transition-transform duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuTrigger>
<NavigationMenuContent class="flex w-64 flex-col gap-1 p-3 outline-none">
<NavigationMenuLink
v-for="item in resources"
:key="item.title"
href="#"
class="flex flex-col gap-0.5 rounded-lg p-3 no-underline outline-none transition-colors hover:bg-bg-subtle focus-visible:ring-2 focus-visible:ring-ring"
>
<span class="text-sm font-medium text-fg">{{ item.title }}</span>
<span class="text-xs text-fg-muted">{{ item.desc }}</span>
</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem value="pricing">
<NavigationMenuLink href="#" active :class="linkClass">
Pricing
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuIndicator
class="absolute top-full left-0 z-10 flex h-2 items-end justify-center overflow-hidden transition-[width,transform] duration-200 data-[state=hidden]:opacity-0 data-[state=visible]:opacity-100"
:style="{
width: 'var(--primitives-navigation-menu-indicator-size)',
transform: 'translateX(var(--primitives-navigation-menu-indicator-position))',
}"
>
<span class="relative top-1 h-2 w-2 rotate-45 rounded-tl-sm border-l border-t border-border bg-bg-elevated" />
</NavigationMenuIndicator>
</NavigationMenuList>
<NavigationMenuViewport
class="demo-card fixed z-50 mt-2 overflow-hidden shadow-lg transition-[width,height] duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in"
:style="{
left: 'var(--primitives-navigation-menu-viewport-left)',
top: 'var(--primitives-navigation-menu-viewport-top)',
width: 'var(--primitives-navigation-menu-viewport-width)',
height: 'var(--primitives-navigation-menu-viewport-height)',
}"
/>
</NavigationMenuRoot>
</template>
@@ -0,0 +1,31 @@
export { default as NavigationMenuRoot } from './NavigationMenuRoot.vue';
export { default as NavigationMenuSub } from './NavigationMenuSub.vue';
export { default as NavigationMenuList } from './NavigationMenuList.vue';
export { default as NavigationMenuItem } from './NavigationMenuItem.vue';
export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.vue';
export { default as NavigationMenuLink } from './NavigationMenuLink.vue';
export { default as NavigationMenuContent } from './NavigationMenuContent.vue';
export { default as NavigationMenuContentImpl } from './NavigationMenuContentImpl.vue';
export { default as NavigationMenuViewport } from './NavigationMenuViewport.vue';
export { default as NavigationMenuIndicator } from './NavigationMenuIndicator.vue';
export {
useNavigationMenuContext,
useNavigationMenuItemContext,
} from './context';
export type {
NavigationMenuContext,
NavigationMenuItemContext,
} from './context';
export type { NavigationMenuRootProps, NavigationMenuRootEmits } from './NavigationMenuRoot.vue';
export type { NavigationMenuSubProps, NavigationMenuSubEmits } from './NavigationMenuSub.vue';
export type { NavigationMenuListProps } from './NavigationMenuList.vue';
export type { NavigationMenuItemProps } from './NavigationMenuItem.vue';
export type { NavigationMenuTriggerProps } from './NavigationMenuTrigger.vue';
export type { NavigationMenuLinkProps, NavigationMenuLinkEmits } from './NavigationMenuLink.vue';
export type { NavigationMenuContentProps, NavigationMenuContentEmits } from './NavigationMenuContent.vue';
export type { NavigationMenuContentImplProps, NavigationMenuContentImplEmits } from './NavigationMenuContentImpl.vue';
export type { NavigationMenuViewportProps } from './NavigationMenuViewport.vue';
export type { NavigationMenuIndicatorProps } from './NavigationMenuIndicator.vue';
@@ -0,0 +1,55 @@
/**
* Returns the open state string for the current item value vs the active menu value.
*/
export function getOpenState(value: string, itemValue: string): 'open' | 'closed' {
return value === itemValue ? 'open' : 'closed';
}
export function makeTriggerId(baseId: string, value: string): string {
return `${baseId}-trigger-${value}`;
}
export function makeContentId(baseId: string, value: string): string {
return `${baseId}-content-${value}`;
}
/** Only call `handler` when the pointer device is a mouse. */
export function whenMouse<E extends PointerEvent>(handler: (event: E) => void): (event: E) => void {
return (event: E) => {
if (event.pointerType === 'mouse') handler(event);
};
}
/**
* Temporarily removes elements from the tab order while content is closed, returning
* a restore function. Used so background content keeps its tabindex when re-opened.
*/
export function removeFromTabOrder(candidates: HTMLElement[]): () => void {
for (const c of candidates) {
c.dataset['tabindex'] = c.getAttribute('tabindex') ?? '';
c.setAttribute('tabindex', '-1');
}
return () => {
for (const c of candidates) {
const prev = c.dataset['tabindex'] ?? '';
if (prev === '') c.removeAttribute('tabindex');
else c.setAttribute('tabindex', prev);
}
};
}
/** 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. */
export const EVENT_ROOT_CONTENT_DISMISS = 'navigationMenu.rootContentDismiss';