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