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