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
@@ -0,0 +1,90 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface ContextMenuTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, shallowRef } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { MenuAnchor, useMenuContext } from '../menu';
import { Primitive } from '../primitive';
import { useContextMenuRootContext } from './context';
const { disabled = false, as = 'span', asChild } = defineProps<ContextMenuTriggerProps>();
const menuCtx = useMenuContext();
const ctxMenuCtx = useContextMenuRootContext();
const { forwardRef } = useForwardExpose();
const point = shallowRef({ x: 0, y: 0 });
const virtualEl = computed(() => ({
getBoundingClientRect: () => ({
x: point.value.x,
y: point.value.y,
width: 0,
height: 0,
top: point.value.y,
right: point.value.x,
bottom: point.value.y,
left: point.value.x,
toJSON: () => {},
}),
}));
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
const LONG_PRESS_DELAY = 700;
function clearLongPress() {
clearTimeout(longPressTimer);
}
onScopeDispose(clearLongPress);
function handleContextMenu(event: MouseEvent) {
if (disabled) return;
clearLongPress();
point.value = { x: event.clientX, y: event.clientY };
event.preventDefault();
ctxMenuCtx.onOpenChange(true);
}
function handlePointerDown(event: PointerEvent) {
if (disabled || event.button !== 0) return;
if (event.pointerType !== 'touch') return;
clearLongPress();
longPressTimer = setTimeout(() => {
point.value = { x: event.clientX, y: event.clientY };
ctxMenuCtx.onOpenChange(true);
}, LONG_PRESS_DELAY);
}
function handlePointerCancel() {
clearLongPress();
}
function handlePointerUp() {
clearLongPress();
}
</script>
<template>
<MenuAnchor as-child :reference="virtualEl">
<Primitive
:ref="forwardRef"
:as="as"
:as-child="asChild"
:data-state="menuCtx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
@contextmenu="handleContextMenu"
@pointerdown="handlePointerDown"
@pointercancel="handlePointerCancel"
@pointerup="handlePointerUp"
>
<slot />
</Primitive>
</MenuAnchor>
</template>