fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,72 @@
<script lang="ts">
import type { NavigationMenuContentImplEmits, NavigationMenuContentImplProps } from './NavigationMenuContentImpl.vue';
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 '../presence';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import NavigationMenuContentImpl from './NavigationMenuContentImpl.vue';
defineOptions({ inheritAttrs: false });
const { forceMount = false, ...rest } = defineProps<NavigationMenuContentProps>();
void rest;
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;
},
);
const present = computed(() => open.value || isLastActiveValue.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 :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
<NavigationMenuContentImpl
v-bind="$attrs"
@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,171 @@
<script lang="ts">
import type { DismissableLayerEmits, DismissableLayerProps } from '../dismissable-layer';
export interface NavigationMenuContentImplProps extends DismissableLayerProps {}
export type NavigationMenuContentImplEmits = DismissableLayerEmits & {
pointerEnterContent: [];
pointerLeaveContent: [];
};
</script>
<script setup lang="ts">
import { computed, watchEffect } from 'vue';
import { focusFirst, getActiveElement, getTabbableCandidates } from '@robonen/platform/browsers';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { getFocusIntent, wrapArray } from '../roving-focus/utils';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import { COLLECTION_ITEM_ATTR, EVENT_ROOT_CONTENT_DISMISS, getOpenState } from './utils';
defineOptions({ inheritAttrs: 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';
});
function handleKeydown(ev: KeyboardEvent) {
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 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();
}
// Listen for sibling/global EVENT_ROOT_CONTENT_DISMISS for root menus so links
// inside content can request the whole root close.
watchEffect((onCleanup) => {
const el = currentElement.value;
if (!el || !menuContext.isRootMenu) return;
function onDismiss() {
itemContext.onRootContentClose();
}
el.addEventListener(EVENT_ROOT_CONTENT_DISMISS, onDismiss);
onCleanup(() => 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"
: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="false"
v-bind="$attrs"
@keydown="handleKeydown"
@escape-key-down="handleEscapeKeyDown"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@pointerenter="emit('pointerEnterContent')"
@pointerleave="emit('pointerLeaveContent')"
>
<slot />
</DismissableLayer>
</FocusScope>
</template>
@@ -0,0 +1,87 @@
<script lang="ts">
export interface NavigationMenuIndicatorProps {
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useNavigationMenuContext } from './context';
defineOptions({ inheritAttrs: false });
const { forceMount = false } = 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>();
let triggerObserver: ResizeObserver | undefined;
let trackObserver: ResizeObserver | 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 };
}
}
watch(
() => [menuContext.activeTrigger.value, menuContext.indicatorTrack.value, isHorizontal.value],
() => {
triggerObserver?.disconnect();
trackObserver?.disconnect();
const trigger = menuContext.activeTrigger.value;
const track = menuContext.indicatorTrack.value;
if (!trigger || !track) return;
triggerObserver = new ResizeObserver(recompute);
trackObserver = new ResizeObserver(recompute);
triggerObserver.observe(trigger);
trackObserver.observe(track);
recompute();
},
{ immediate: true },
);
onScopeDispose(() => {
triggerObserver?.disconnect();
trackObserver?.disconnect();
});
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"
: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,80 @@
<script lang="ts">
export interface NavigationMenuItemProps {
/**
* 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 '../primitive';
import { provideNavigationMenuItemContext, useNavigationMenuContext } from './context';
import { makeContentId, makeTriggerId, removeFromTabOrder } from './utils';
const { value: valueProp } = 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);
}
}
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="li"
data-primitives-navigation-menu-item
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,55 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface NavigationMenuLinkProps extends PrimitiveProps {
/** Marks the link as active for styling and aria-current. */
active?: boolean;
}
export interface NavigationMenuLinkEmits {
select: [event: CustomEvent];
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../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 target = event.currentTarget as HTMLElement | null;
if (!target) return;
const linkSelectEvent = new CustomEvent(LINK_SELECT_EVENT, {
bubbles: true,
cancelable: true,
});
// Browser event handlers run synchronously; listen once for prevention semantics.
target.addEventListener(LINK_SELECT_EVENT, e => emit('select', e as CustomEvent), { once: true });
target.dispatchEvent(linkSelectEvent);
if (!linkSelectEvent.defaultPrevented && !event.metaKey) {
const rootContentDismissEvent = new CustomEvent(EVENT_ROOT_CONTENT_DISMISS, {
bubbles: true,
cancelable: true,
});
target.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,42 @@
<script lang="ts">
export interface NavigationMenuListProps {}
</script>
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { RovingFocusGroup } from '../roving-focus';
import { useNavigationMenuContext } from './context';
defineOptions({ inheritAttrs: false });
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="ul"
: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,224 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { Orientation } from '../roving-focus';
export interface NavigationMenuRootProps {
/** Controlled active item value. Use `v-model`. */
modelValue?: string;
/** 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, ref, shallowRef, toRef, watchEffect } from 'vue';
import { useForwardExpose, useId } from '@robonen/vue';
import { useCollectionProvider } from '../collection';
import { useConfig } from '../config-provider';
import { Primitive } from '../primitive';
import { provideNavigationMenuContext } from './context';
import { EVENT_ROOT_CONTENT_DISMISS } from './utils';
defineOptions({ inheritAttrs: false });
const {
defaultValue,
dir,
orientation = 'horizontal',
delayDuration = 200,
skipDelayDuration = 300,
disableClickTrigger = false,
disableHoverTrigger = false,
disablePointerLeaveClose = false,
unmountOnHide = true,
} = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => any;
}>();
const config = useConfig();
const dirRef = computed<Direction>(() => dir ?? config.dir.value);
const localValue = ref<string>(defaultValue ?? '');
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 }>();
// 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((onCleanup) => {
const el = rootNavigationMenu.value;
if (!el) return;
el.addEventListener(EVENT_ROOT_CONTENT_DISMISS, onItemDismiss);
onCleanup(() => 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="nav"
:aria-label="$attrs['aria-label'] as string | undefined ?? 'Main'"
:data-orientation="orientation"
:dir="dirRef"
data-primitives-navigation-menu
v-bind="$attrs"
>
<slot :model-value="modelValue" />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,117 @@
<script lang="ts">
import type { Orientation } from '../roving-focus';
export interface NavigationMenuSubProps {
/** Controlled active value of the submenu. Use `v-model`. */
modelValue?: string;
/** 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 '../collection';
import { Primitive } from '../primitive';
import { provideNavigationMenuContext, useNavigationMenuContext } from './context';
defineOptions({ inheritAttrs: false });
const { defaultValue, orientation = 'horizontal' } = defineProps<NavigationMenuSubProps>();
defineEmits<NavigationMenuSubEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => any;
}>();
const localValue = ref<string>(defaultValue ?? '');
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 }>();
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"
:data-orientation="orientation"
data-primitives-navigation-menu
v-bind="$attrs"
>
<slot :model-value="modelValue" />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,152 @@
<script lang="ts">
import type { ComponentPublicInstance } from 'vue';
export interface NavigationMenuTriggerProps {
/** 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 '../collection';
import { Primitive } from '../primitive';
import { RovingFocusItem } from '../roving-focus';
import { VisuallyHidden } from '../visually-hidden';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import { getOpenState } from './utils';
defineOptions({ inheritAttrs: false });
const { disabled = false } = defineProps<NavigationMenuTriggerProps>();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
const { CollectionItem } = useCollectionInjector<{ value: string }>();
const { forwardRef, currentElement: triggerElement } = useForwardExpose();
// Auto-reset flag that suppresses click→toggle right after a pointermove open.
const hasPointerMoveOpened = ref(false);
let pointerMoveResetTimer: ReturnType<typeof setTimeout> | undefined;
function markPointerMoveOpened() {
hasPointerMoveOpened.value = true;
if (pointerMoveResetTimer !== undefined) clearTimeout(pointerMoveResetTimer);
pointerMoveResetTimer = setTimeout(() => {
hasPointerMoveOpened.value = false;
pointerMoveResetTimer = undefined;
}, 300);
}
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);
markPointerMoveOpened();
}
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;
// 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;
}
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 :value="{ value: itemContext.value }">
<RovingFocusItem :focusable="!disabled">
<Primitive
:id="itemContext.triggerId"
:ref="forwardRef"
as="button"
type="button"
: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>
</RovingFocusItem>
</CollectionItem>
<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,121 @@
<script lang="ts">
export interface NavigationMenuViewportProps {
/** Keep mounted regardless of open state. */
forceMount?: boolean;
/**
* Horizontal alignment of the viewport relative to the active trigger.
* @default 'center'
*/
align?: 'start' | 'center' | 'end';
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, ref, shallowRef, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useNavigationMenuContext } from './context';
import { clamp, whenMouse } from './utils';
defineOptions({ inheritAttrs: false });
const { forceMount = false, align = 'center' } = 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();
});
// Position based on active trigger, clamped to viewport edges.
const positionStyle = computed(() => {
const viewport = currentElement.value;
const trigger = menuContext.activeTrigger.value;
if (!viewport || !trigger || !size.value) return {};
const triggerRect = trigger.getBoundingClientRect();
const viewportWidth = size.value.width;
let left: number;
switch (align) {
case 'start':
left = triggerRect.left;
break;
case 'end':
left = triggerRect.right - viewportWidth;
break;
default:
left = triggerRect.left + (triggerRect.width / 2) - (viewportWidth / 2);
}
const maxLeft = window.innerWidth - viewportWidth - 10;
left = clamp(left, 10, Math.max(10, maxLeft));
const top = triggerRect.bottom;
return {
'--primitives-navigation-menu-viewport-width': `${size.value.width}px`,
'--primitives-navigation-menu-viewport-height': `${size.value.height}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 :present="present" :force-mount="forceMount || !menuContext.unmountOnHide.value">
<Primitive
:ref="forwardRef"
:data-state="open ? 'open' : 'closed'"
:data-orientation="menuContext.orientation"
data-primitives-navigation-menu-viewport
:style="positionStyle"
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,65 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../config-provider';
import type { Orientation } from '../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,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,51 @@
/**
* 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);
}
};
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/** Selector identifying the link/item nodes for arrow navigation inside content. */
export const COLLECTION_ITEM_ATTR = 'data-primitives-collection-item';
/** 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';