feat(primitives): add menu, dropdown-menu, context-menu, and menubar primitives

Implements WAI-ARIA APG-compliant headless menu primitive families:

- menu: base primitive with MenuRoot, MenuContent, MenuItem,
  MenuCheckboxItem, MenuRadioGroup/Item, MenuSub, and helpers
- dropdown-menu: DropdownMenuRoot with trigger anchoring
- context-menu: ContextMenuRoot with right-click virtual anchor
- menubar: MenubarRoot with keyboard navigation between menus

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-17 18:48:43 +07:00
parent 333a18cbaf
commit 1d3efa5028
81 changed files with 2554 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenuItemImplProps extends PrimitiveProps {
disabled?: boolean;
textValue?: string;
}
export interface MenuItemImplEmits {
select: [event: Event];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { RovingFocusItem } from '../roving-focus';
import { Primitive } from '../primitive';
import { useMenuContentContext } from './context';
const {
disabled = false,
textValue: textValueProp,
as = 'div',
asChild,
} = defineProps<MenuItemImplProps>();
const emit = defineEmits<MenuItemImplEmits>();
const contentCtx = useMenuContentContext();
const itemRef = shallowRef<HTMLElement | null>(null);
const isHighlighted = ref(false);
const textValue = computed(() => textValueProp ?? itemRef.value?.textContent?.trim() ?? '');
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return;
if (disabled) return;
if (contentCtx.onItemEnter(event)) return;
const item = event.currentTarget as HTMLElement;
item.focus({ preventScroll: true });
}
function handlePointerLeave(event: PointerEvent) {
if (event.pointerType === 'touch') return;
if (disabled) return;
contentCtx.onItemLeave(event);
}
function handleFocus() {
isHighlighted.value = true;
}
function handleBlur() {
isHighlighted.value = false;
}
function handleClick(event: MouseEvent) {
if (disabled) return;
emit('select', event);
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
el.click();
}
}
</script>
<template>
<RovingFocusItem as-child :focusable="!disabled" :active="isHighlighted">
<Primitive
:ref="(el: unknown) => { itemRef = el as HTMLElement | null }"
:as="as"
:as-child="asChild"
role="menuitem"
data-primitives-menu-item=""
:data-primitive-menu-item-text-value="textValue"
:data-highlighted="isHighlighted ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:aria-disabled="disabled || undefined"
:tabindex="isHighlighted ? 0 : -1"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@focus="handleFocus"
@blur="handleBlur"
@click="handleClick"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</RovingFocusItem>
</template>