Files
tools/vue/primitives/src/navigation-menu/NavigationMenuRoot.vue
T

232 lines
6.7 KiB
Vue

<script lang="ts">
import type { Direction } from '../config-provider';
import type { Orientation } from '../roving-focus';
/**
* A collection of navigation links and disclosure menus, typically used for the
* primary site header. `NavigationMenuRoot` owns the open state and hover/click
* timing for the whole menu, rendering as a `<nav>` landmark and providing context
* to every list, item, trigger, content, viewport, and indicator beneath it. Reach
* for it over a generic dropdown when you need keyboard-accessible, animatable
* mega-menu panels that share a single active state.
*/
export interface NavigationMenuRootProps {
/** 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, NAVIGATION_MENU_COLLECTION_KEY } from './utils';
defineOptions({ inheritAttrs: false });
const {
defaultValue,
dir,
orientation = 'horizontal',
delayDuration = 200,
skipDelayDuration = 300,
disableClickTrigger = false,
disableHoverTrigger = false,
disablePointerLeaveClose = false,
unmountOnHide = true,
} = 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 ?? '');
/** Controlled active item value. Use `v-model`. */
const modelValue = defineModel<string | undefined>({
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
const next = v ?? '';
localValue.value = next;
return next;
},
}) as unknown as Ref<string>;
const previousValue = ref<string>('');
const baseId = useId(undefined, 'primitives-navigation-menu');
const { forwardRef, currentElement: rootNavigationMenu } = useForwardExpose();
const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
const viewport = shallowRef<HTMLElement | undefined>(undefined);
const activeTrigger = shallowRef<HTMLElement | undefined>(undefined);
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
// Manual debounce — open delay shrinks to 150ms once the menu is open or while
// the skip window is active (so moving between triggers feels instantaneous).
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let skipDelayTimer: ReturnType<typeof setTimeout> | undefined;
const isDelaySkipped = ref(false);
function clearDebounce() {
if (debounceTimer !== undefined) {
clearTimeout(debounceTimer);
debounceTimer = undefined;
}
}
function clearSkipDelay() {
if (skipDelayTimer !== undefined) {
clearTimeout(skipDelayTimer);
skipDelayTimer = undefined;
}
}
function triggerSkipDelay() {
clearSkipDelay();
isDelaySkipped.value = true;
skipDelayTimer = setTimeout(() => {
isDelaySkipped.value = false;
skipDelayTimer = undefined;
}, skipDelayDuration);
}
const computedDelay = computed(() => {
const isOpen = modelValue.value !== '';
if (isOpen || isDelaySkipped.value) return 150;
return delayDuration;
});
function debouncedSet(val: string) {
clearDebounce();
debounceTimer = setTimeout(() => {
previousValue.value = modelValue.value;
modelValue.value = val;
debounceTimer = undefined;
}, computedDelay.value);
}
function cancelDebounce() {
clearDebounce();
}
watchEffect(() => {
if (!modelValue.value) return;
const items = getItems().map(i => i.ref);
// Trigger id pattern: `${baseId}-trigger-${value}`
const matched = items.find(item => item.id.includes(`-trigger-${modelValue.value}`));
if (matched) activeTrigger.value = matched;
});
function onItemDismiss() {
previousValue.value = modelValue.value;
modelValue.value = '';
}
// Custom event isn't part of HTMLElementEventMap so wire it up manually.
watchEffect((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'] ?? 'Main') as string"
:data-orientation="orientation"
:dir="dirRef"
data-primitives-navigation-menu
v-bind="$attrs"
>
<slot :model-value="modelValue" />
</Primitive>
</CollectionSlot>
</template>