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:
@@ -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';
|
||||
Reference in New Issue
Block a user