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,57 @@
<script lang="ts">
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
import type { CheckedState } from './types';
export interface MenuCheckboxItemProps extends MenuItemImplProps {
checked?: CheckedState;
defaultChecked?: CheckedState;
}
export interface MenuCheckboxItemEmits extends MenuItemImplEmits {
'update:checked': [value: CheckedState];
}
</script>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { provideMenuItemIndicatorContext, useMenuRootContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { ITEM_SELECT, getCheckedState, isIndeterminate } from './utils';
const {
checked: checkedProp,
defaultChecked = false,
...itemProps
} = defineProps<MenuCheckboxItemProps>();
const emit = defineEmits<MenuCheckboxItemEmits>();
const rootCtx = useMenuRootContext();
const local = ref<CheckedState>(defaultChecked);
const checkedState = computed<CheckedState>(() => checkedProp !== undefined ? checkedProp : local.value);
provideMenuItemIndicatorContext({ checkedState });
function handleSelect(event: Event) {
const next: CheckedState = isIndeterminate(checkedState.value) ? true : !checkedState.value;
local.value = next;
emit('update:checked', next);
const selectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true })
;(event.currentTarget as HTMLElement).dispatchEvent(selectEvent);
emit('select', event);
if (!selectEvent.defaultPrevented) rootCtx.onClose();
}
</script>
<template>
<MenuItemImpl
v-bind="itemProps"
role="menuitemcheckbox"
:aria-checked="isIndeterminate(checkedState) ? 'mixed' : checkedState"
:data-state="getCheckedState(checkedState)"
@select="handleSelect"
>
<slot />
</MenuItemImpl>
</template>