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,15 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
export interface ContextMenuArrowProps extends MenuArrowProps {}
</script>
<script setup lang="ts">
import { MenuArrow } from '../menu';
const props = defineProps<ContextMenuArrowProps>();
</script>
<template>
<MenuArrow v-bind="props"><slot /></MenuArrow>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
export interface ContextMenuCheckboxItemProps extends MenuCheckboxItemProps {}
export type ContextMenuCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
<script setup lang="ts">
import { MenuCheckboxItem } from '../menu';
const props = defineProps<ContextMenuCheckboxItemProps>();
const emit = defineEmits<ContextMenuCheckboxItemEmits>();
</script>
<template>
<MenuCheckboxItem
v-bind="props"
@select="emit('select', $event)"
@update:checked="emit('update:checked', $event)"
><slot /></MenuCheckboxItem>
</template>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
export interface ContextMenuContentProps extends MenuContentProps {}
export type ContextMenuContentEmits = MenuContentEmits;
</script>
<script setup lang="ts">
import { MenuContent } from '../menu';
const props = defineProps<ContextMenuContentProps>();
const emit = defineEmits<ContextMenuContentEmits>();
</script>
<template>
<MenuContent
v-bind="props"
side="right"
align="start"
update-position-strategy="always"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContent>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
export interface ContextMenuGroupProps extends MenuGroupProps {}
</script>
<script setup lang="ts">
import { MenuGroup } from '../menu';
const props = defineProps<ContextMenuGroupProps>();
</script>
<template>
<MenuGroup v-bind="props"><slot /></MenuGroup>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
export interface ContextMenuItemProps extends MenuItemProps {}
export type ContextMenuItemEmits = MenuItemEmits;
</script>
<script setup lang="ts">
import { MenuItem } from '../menu';
const props = defineProps<ContextMenuItemProps>();
const emit = defineEmits<ContextMenuItemEmits>();
</script>
<template>
<MenuItem v-bind="props" @select="emit('select', $event)"><slot /></MenuItem>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
export interface ContextMenuItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
<script setup lang="ts">
import { MenuItemIndicator } from '../menu';
const props = defineProps<ContextMenuItemIndicatorProps>();
</script>
<template>
<MenuItemIndicator v-bind="props"><slot /></MenuItemIndicator>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
export interface ContextMenuLabelProps extends MenuLabelProps {}
</script>
<script setup lang="ts">
import { MenuLabel } from '../menu';
const props = defineProps<ContextMenuLabelProps>();
</script>
<template>
<MenuLabel v-bind="props"><slot /></MenuLabel>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
export interface ContextMenuPortalProps extends MenuPortalProps {}
</script>
<script setup lang="ts">
import { MenuPortal } from '../menu';
const props = defineProps<ContextMenuPortalProps>();
</script>
<template>
<MenuPortal v-bind="props"><slot /></MenuPortal>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
export interface ContextMenuRadioGroupProps extends MenuRadioGroupProps {}
export type ContextMenuRadioGroupEmits = MenuRadioGroupEmits;
</script>
<script setup lang="ts">
import { MenuRadioGroup } from '../menu';
const props = defineProps<ContextMenuRadioGroupProps>();
const emit = defineEmits<ContextMenuRadioGroupEmits>();
</script>
<template>
<MenuRadioGroup v-bind="props" @update:model-value="emit('update:modelValue', $event)"><slot /></MenuRadioGroup>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
export interface ContextMenuRadioItemProps extends MenuRadioItemProps {}
export type ContextMenuRadioItemEmits = MenuRadioItemEmits;
</script>
<script setup lang="ts">
import { MenuRadioItem } from '../menu';
const props = defineProps<ContextMenuRadioItemProps>();
const emit = defineEmits<ContextMenuRadioItemEmits>();
</script>
<template>
<MenuRadioItem v-bind="props" @select="emit('select', $event)"><slot /></MenuRadioItem>
</template>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Direction } from '../config-provider';
export interface ContextMenuRootProps {
dir?: Direction;
modal?: boolean;
}
export interface ContextMenuRootEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { ref, toRef } from 'vue';
import { MenuRoot } from '../menu';
import { provideContextMenuRootContext } from './context';
const { dir, modal = true } = defineProps<ContextMenuRootProps>();
const emit = defineEmits<ContextMenuRootEmits>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const open = ref(false);
provideContextMenuRootContext({
open,
onOpenChange: (v) => {
open.value = v;
emit('update:open', v);
},
modal: toRef(() => modal),
});
</script>
<template>
<MenuRoot
:open="open"
:dir="dir"
:modal="modal"
@update:open="open = $event"
>
<slot :open="open" />
</MenuRoot>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
export interface ContextMenuSeparatorProps extends MenuSeparatorProps {}
</script>
<script setup lang="ts">
import { MenuSeparator } from '../menu';
const props = defineProps<ContextMenuSeparatorProps>();
</script>
<template>
<MenuSeparator v-bind="props"><slot /></MenuSeparator>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
export interface ContextMenuSubProps extends MenuSubProps {}
export type ContextMenuSubEmits = MenuSubEmits;
</script>
<script setup lang="ts">
import { MenuSub } from '../menu';
const props = defineProps<ContextMenuSubProps>();
const emit = defineEmits<ContextMenuSubEmits>();
</script>
<template>
<MenuSub v-bind="props" @update:open="emit('update:open', $event)"><slot /></MenuSub>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
export interface ContextMenuSubContentProps extends MenuSubContentProps {}
export type ContextMenuSubContentEmits = MenuSubContentEmits;
</script>
<script setup lang="ts">
import { MenuSubContent } from '../menu';
const props = defineProps<ContextMenuSubContentProps>();
const emit = defineEmits<ContextMenuSubContentEmits>();
</script>
<template>
<MenuSubContent
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
><slot /></MenuSubContent>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
export interface ContextMenuSubTriggerProps extends MenuSubTriggerProps {}
</script>
<script setup lang="ts">
import { MenuSubTrigger } from '../menu';
const props = defineProps<ContextMenuSubTriggerProps>();
</script>
<template>
<MenuSubTrigger v-bind="props"><slot /></MenuSubTrigger>
</template>
@@ -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>
@@ -0,0 +1,14 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface ContextMenuRootContext {
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
modal: Ref<boolean>;
}
export const {
inject: useContextMenuRootContext,
provide: provideContextMenuRootContext,
} = useContextFactory<ContextMenuRootContext>('ContextMenuRootContext');
+17
View File
@@ -0,0 +1,17 @@
export { useContextMenuRootContext } from './context';
export { default as ContextMenuArrow, type ContextMenuArrowProps } from './ContextMenuArrow.vue';
export { default as ContextMenuCheckboxItem, type ContextMenuCheckboxItemEmits, type ContextMenuCheckboxItemProps } from './ContextMenuCheckboxItem.vue';
export { default as ContextMenuContent, type ContextMenuContentEmits, type ContextMenuContentProps } from './ContextMenuContent.vue';
export { default as ContextMenuGroup, type ContextMenuGroupProps } from './ContextMenuGroup.vue';
export { default as ContextMenuItem, type ContextMenuItemEmits, type ContextMenuItemProps } from './ContextMenuItem.vue';
export { default as ContextMenuItemIndicator, type ContextMenuItemIndicatorProps } from './ContextMenuItemIndicator.vue';
export { default as ContextMenuLabel, type ContextMenuLabelProps } from './ContextMenuLabel.vue';
export { default as ContextMenuPortal, type ContextMenuPortalProps } from './ContextMenuPortal.vue';
export { default as ContextMenuRadioGroup, type ContextMenuRadioGroupEmits, type ContextMenuRadioGroupProps } from './ContextMenuRadioGroup.vue';
export { default as ContextMenuRadioItem, type ContextMenuRadioItemEmits, type ContextMenuRadioItemProps } from './ContextMenuRadioItem.vue';
export { default as ContextMenuRoot, type ContextMenuRootEmits, type ContextMenuRootProps } from './ContextMenuRoot.vue';
export { default as ContextMenuSeparator, type ContextMenuSeparatorProps } from './ContextMenuSeparator.vue';
export { default as ContextMenuSub, type ContextMenuSubEmits, type ContextMenuSubProps } from './ContextMenuSub.vue';
export { default as ContextMenuSubContent, type ContextMenuSubContentEmits, type ContextMenuSubContentProps } from './ContextMenuSubContent.vue';
export { default as ContextMenuSubTrigger, type ContextMenuSubTriggerProps } from './ContextMenuSubTrigger.vue';
export { default as ContextMenuTrigger, type ContextMenuTriggerProps } from './ContextMenuTrigger.vue';
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
export interface DropdownMenuArrowProps extends MenuArrowProps {}
</script>
<script setup lang="ts">
import { MenuArrow } from '../menu';
const props = defineProps<DropdownMenuArrowProps>();
</script>
<template>
<MenuArrow v-bind="props"><slot /></MenuArrow>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
export interface DropdownMenuCheckboxItemProps extends MenuCheckboxItemProps {}
export type DropdownMenuCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
<script setup lang="ts">
import { MenuCheckboxItem } from '../menu';
const props = defineProps<DropdownMenuCheckboxItemProps>();
const emit = defineEmits<DropdownMenuCheckboxItemEmits>();
</script>
<template>
<MenuCheckboxItem
v-bind="props"
@select="emit('select', $event)"
@update:checked="emit('update:checked', $event)"
><slot /></MenuCheckboxItem>
</template>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
export interface DropdownMenuContentProps extends MenuContentProps {}
export type DropdownMenuContentEmits = MenuContentEmits;
</script>
<script setup lang="ts">
import { MenuContent } from '../menu';
import { useDropdownMenuRootContext } from './context';
const props = defineProps<DropdownMenuContentProps>();
const emit = defineEmits<DropdownMenuContentEmits>();
const ddCtx = useDropdownMenuRootContext();
</script>
<template>
<MenuContent
v-bind="props"
:id="ddCtx.contentId.value"
:aria-labelledby="ddCtx.triggerId.value"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContent>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
export interface DropdownMenuGroupProps extends MenuGroupProps {}
</script>
<script setup lang="ts">
import { MenuGroup } from '../menu';
const props = defineProps<DropdownMenuGroupProps>();
</script>
<template>
<MenuGroup v-bind="props"><slot /></MenuGroup>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
export interface DropdownMenuItemProps extends MenuItemProps {}
export type DropdownMenuItemEmits = MenuItemEmits;
</script>
<script setup lang="ts">
import { MenuItem } from '../menu';
const props = defineProps<DropdownMenuItemProps>();
const emit = defineEmits<DropdownMenuItemEmits>();
</script>
<template>
<MenuItem v-bind="props" @select="emit('select', $event)"><slot /></MenuItem>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
export interface DropdownMenuItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
<script setup lang="ts">
import { MenuItemIndicator } from '../menu';
const props = defineProps<DropdownMenuItemIndicatorProps>();
</script>
<template>
<MenuItemIndicator v-bind="props"><slot /></MenuItemIndicator>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
export interface DropdownMenuLabelProps extends MenuLabelProps {}
</script>
<script setup lang="ts">
import { MenuLabel } from '../menu';
const props = defineProps<DropdownMenuLabelProps>();
</script>
<template>
<MenuLabel v-bind="props"><slot /></MenuLabel>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
export interface DropdownMenuPortalProps extends MenuPortalProps {}
</script>
<script setup lang="ts">
import { MenuPortal } from '../menu';
const props = defineProps<DropdownMenuPortalProps>();
</script>
<template>
<MenuPortal v-bind="props"><slot /></MenuPortal>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
export interface DropdownMenuRadioGroupProps extends MenuRadioGroupProps {}
export type DropdownMenuRadioGroupEmits = MenuRadioGroupEmits;
</script>
<script setup lang="ts">
import { MenuRadioGroup } from '../menu';
const props = defineProps<DropdownMenuRadioGroupProps>();
const emit = defineEmits<DropdownMenuRadioGroupEmits>();
</script>
<template>
<MenuRadioGroup v-bind="props" @update:model-value="emit('update:modelValue', $event)"><slot /></MenuRadioGroup>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
export interface DropdownMenuRadioItemProps extends MenuRadioItemProps {}
export type DropdownMenuRadioItemEmits = MenuRadioItemEmits;
</script>
<script setup lang="ts">
import { MenuRadioItem } from '../menu';
const props = defineProps<DropdownMenuRadioItemProps>();
const emit = defineEmits<DropdownMenuRadioItemEmits>();
</script>
<template>
<MenuRadioItem v-bind="props" @select="emit('select', $event)"><slot /></MenuRadioItem>
</template>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Direction } from '../config-provider';
export interface DropdownMenuRootProps {
open?: boolean;
defaultOpen?: boolean;
dir?: Direction;
modal?: boolean;
}
export interface DropdownMenuRootEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { useId } from '../config-provider';
import { MenuRoot } from '../menu';
import { provideDropdownMenuRootContext } from './context';
const {
open: openProp,
defaultOpen = false,
dir,
modal = true,
} = defineProps<DropdownMenuRootProps>();
const emit = defineEmits<DropdownMenuRootEmits>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const local = ref(defaultOpen);
const open = computed({
get: () => openProp !== undefined ? openProp : local.value,
set: (v) => {
local.value = v;
emit('update:open', v);
},
});
const triggerRef = shallowRef<HTMLElement | null>(null);
const triggerId = useId(undefined, 'dropdown-trigger');
const contentId = useId(undefined, 'dropdown-content');
provideDropdownMenuRootContext({
triggerId,
contentId,
triggerRef,
onTriggerChange: (el) => {
triggerRef.value = el;
},
});
</script>
<template>
<MenuRoot
:open="open"
:dir="dir"
:modal="modal"
@update:open="open = $event"
>
<slot :open="open" />
</MenuRoot>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
export interface DropdownMenuSeparatorProps extends MenuSeparatorProps {}
</script>
<script setup lang="ts">
import { MenuSeparator } from '../menu';
const props = defineProps<DropdownMenuSeparatorProps>();
</script>
<template>
<MenuSeparator v-bind="props"><slot /></MenuSeparator>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
export interface DropdownMenuSubProps extends MenuSubProps {}
export type DropdownMenuSubEmits = MenuSubEmits;
</script>
<script setup lang="ts">
import { MenuSub } from '../menu';
const props = defineProps<DropdownMenuSubProps>();
const emit = defineEmits<DropdownMenuSubEmits>();
</script>
<template>
<MenuSub v-bind="props" @update:open="emit('update:open', $event)"><slot /></MenuSub>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
export interface DropdownMenuSubContentProps extends MenuSubContentProps {}
export type DropdownMenuSubContentEmits = MenuSubContentEmits;
</script>
<script setup lang="ts">
import { MenuSubContent } from '../menu';
const props = defineProps<DropdownMenuSubContentProps>();
const emit = defineEmits<DropdownMenuSubContentEmits>();
</script>
<template>
<MenuSubContent
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
><slot /></MenuSubContent>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
export interface DropdownMenuSubTriggerProps extends MenuSubTriggerProps {}
</script>
<script setup lang="ts">
import { MenuSubTrigger } from '../menu';
const props = defineProps<DropdownMenuSubTriggerProps>();
</script>
<template>
<MenuSubTrigger v-bind="props"><slot /></MenuSubTrigger>
</template>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface DropdownMenuTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { MenuAnchor, useMenuContext } from '../menu';
import { Primitive } from '../primitive';
import { useDropdownMenuRootContext } from './context';
const { disabled = false, as = 'button', asChild } = defineProps<DropdownMenuTriggerProps>();
const menuCtx = useMenuContext();
const ddCtx = useDropdownMenuRootContext();
const { forwardRef, currentElement } = useForwardExpose();
onMounted(() => {
ddCtx.onTriggerChange(currentElement.value ?? null);
});
onUnmounted(() => {
ddCtx.onTriggerChange(null);
});
function handlePointerDown(event: PointerEvent) {
if (disabled) return;
if (event.button !== 0 || event.ctrlKey) return;
if (!menuCtx.open.value) {
menuCtx.onOpenChange(true);
event.preventDefault();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
menuCtx.onOpenChange(true);
}
}
</script>
<template>
<MenuAnchor as-child>
<Primitive
:ref="forwardRef"
:as="as"
:as-child="asChild"
:id="ddCtx.triggerId.value"
aria-haspopup="menu"
:aria-expanded="menuCtx.open.value"
:aria-controls="ddCtx.contentId.value"
:data-state="menuCtx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@pointerdown="handlePointerDown"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</MenuAnchor>
</template>
@@ -0,0 +1,15 @@
import type { ComputedRef, ShallowRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface DropdownMenuRootContext {
triggerId: ComputedRef<string>;
triggerRef: ShallowRef<HTMLElement | null>;
contentId: ComputedRef<string>;
onTriggerChange: (el: HTMLElement | null) => void;
}
export const {
inject: useDropdownMenuRootContext,
provide: provideDropdownMenuRootContext,
} = useContextFactory<DropdownMenuRootContext>('DropdownMenuRootContext');
+17
View File
@@ -0,0 +1,17 @@
export { useDropdownMenuRootContext } from './context';
export { default as DropdownMenuArrow, type DropdownMenuArrowProps } from './DropdownMenuArrow.vue';
export { default as DropdownMenuCheckboxItem, type DropdownMenuCheckboxItemEmits, type DropdownMenuCheckboxItemProps } from './DropdownMenuCheckboxItem.vue';
export { default as DropdownMenuContent, type DropdownMenuContentEmits, type DropdownMenuContentProps } from './DropdownMenuContent.vue';
export { default as DropdownMenuGroup, type DropdownMenuGroupProps } from './DropdownMenuGroup.vue';
export { default as DropdownMenuItem, type DropdownMenuItemEmits, type DropdownMenuItemProps } from './DropdownMenuItem.vue';
export { default as DropdownMenuItemIndicator, type DropdownMenuItemIndicatorProps } from './DropdownMenuItemIndicator.vue';
export { default as DropdownMenuLabel, type DropdownMenuLabelProps } from './DropdownMenuLabel.vue';
export { default as DropdownMenuPortal, type DropdownMenuPortalProps } from './DropdownMenuPortal.vue';
export { default as DropdownMenuRadioGroup, type DropdownMenuRadioGroupEmits, type DropdownMenuRadioGroupProps } from './DropdownMenuRadioGroup.vue';
export { default as DropdownMenuRadioItem, type DropdownMenuRadioItemEmits, type DropdownMenuRadioItemProps } from './DropdownMenuRadioItem.vue';
export { default as DropdownMenuRoot, type DropdownMenuRootEmits, type DropdownMenuRootProps } from './DropdownMenuRoot.vue';
export { default as DropdownMenuSeparator, type DropdownMenuSeparatorProps } from './DropdownMenuSeparator.vue';
export { default as DropdownMenuSub, type DropdownMenuSubEmits, type DropdownMenuSubProps } from './DropdownMenuSub.vue';
export { default as DropdownMenuSubContent, type DropdownMenuSubContentEmits, type DropdownMenuSubContentProps } from './DropdownMenuSubContent.vue';
export { default as DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps } from './DropdownMenuSubTrigger.vue';
export { default as DropdownMenuTrigger, type DropdownMenuTriggerProps } from './DropdownMenuTrigger.vue';
+39
View File
@@ -1,5 +1,44 @@
export * from './config-provider';
export * from './primitive';
export * from './presence';
export * from './collection';
export * from './roving-focus';
export * from './pagination';
export * from './focus-scope';
export * from './visually-hidden';
export * from './teleport';
export * from './dismissable-layer';
export * from './dialog';
export * from './alert-dialog';
export * from './scroll-area';
export * from './separator';
export * from './label';
export * from './aspect-ratio';
export * from './toggle';
export * from './switch';
export * from './progress';
export * from './collapsible';
export * from './avatar';
export * from './slider';
export * from './checkbox';
export * from './toolbar';
export * from './radio-group';
export * from './toggle-group';
export * from './number-field';
export * from './pin-input';
export * from './tabs';
export * from './accordion';
export * from './popper';
export * from './hover-card';
export * from './popover';
export * from './tooltip';
export * from './tree';
export * from './stepper';
export * from './editable';
export * from './tags-input';
export * from './listbox';
export * from './menu';
export * from './dropdown-menu';
export * from './context-menu';
export * from './menubar';
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PopperAnchorProps } from '../popper';
export interface MenuAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { PopperAnchor } from '../popper';
const props = defineProps<MenuAnchorProps>();
</script>
<template>
<PopperAnchor v-bind="props">
<slot />
</PopperAnchor>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export interface MenuArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
const props = defineProps<MenuArrowProps>();
</script>
<template>
<PopperArrow v-bind="props">
<slot />
</PopperArrow>
</template>
@@ -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>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
export interface MenuContentProps extends MenuContentImplProps {
forceMount?: boolean;
}
export type MenuContentEmits = MenuContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import { useMenuContext, useMenuRootContext } from './context';
import MenuRootContentModal from './MenuRootContentModal.vue';
import MenuRootContentNonModal from './MenuRootContentNonModal.vue';
const { forceMount = false, ...contentProps } = defineProps<MenuContentProps>();
const emit = defineEmits<MenuContentEmits>();
const menuCtx = useMenuContext();
const rootCtx = useMenuRootContext();
</script>
<template>
<Presence :present="forceMount || menuCtx.open.value">
<MenuRootContentModal
v-if="rootCtx.modal.value"
v-bind="contentProps"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuRootContentModal>
<MenuRootContentNonModal
v-else
v-bind="contentProps"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuRootContentNonModal>
</Presence>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../primitive';
export interface MenuContentImplProps extends PrimitiveProps, Pick<PopperContentProps,
| 'side' | 'sideOffset' | 'sideFlip' | 'align' | 'alignOffset' | 'alignFlip'
| 'avoidCollisions' | 'collisionBoundary' | 'collisionPadding' | 'arrowPadding'
| 'sticky' | 'hideWhenDetached' | 'positionStrategy' | 'updatePositionStrategy'
| 'reference' | 'prioritizePosition'
> {
loop?: boolean;
trapFocus?: boolean;
disableOutsidePointerEvents?: boolean;
}
export interface MenuContentImplEmits {
closeAutoFocus: [event: Event];
escapeKeyDown: [event: KeyboardEvent];
pointerDownOutside: [event: PointerEvent | MouseEvent];
focusOutside: [event: FocusEvent];
interactOutside: [event: PointerEvent | MouseEvent | FocusEvent];
dismiss: [];
entryFocus: [event: Event];
openAutoFocus: [event: Event];
}
</script>
<script setup lang="ts">
import { onScopeDispose, ref } from 'vue';
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { PopperContent } from '../popper';
import { RovingFocusGroup } from '../roving-focus';
import { useForwardExpose } from '@robonen/vue';
import { provideMenuContentContext, useMenuContext, useMenuRootContext } from './context';
import { FIRST_LAST_KEYS, getNextMatch, getOpenState, isPointerInGraceArea } from './utils';
const {
loop = false,
trapFocus = false,
disableOutsidePointerEvents = false,
side = 'bottom',
sideOffset = 0,
align = 'start',
as = 'div',
...popperProps
} = defineProps<MenuContentImplProps>();
const emit = defineEmits<MenuContentImplEmits>();
const menuCtx = useMenuContext();
const rootCtx = useMenuRootContext();
const { forwardRef, currentElement: contentElement } = useForwardExpose();
const searchRef = ref('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function clearSearch() {
clearTimeout(searchTimer);
searchRef.value = '';
}
onScopeDispose(clearSearch);
const pointerGraceTimerRef = ref<number>(0);
const pointerGraceIntentRef = ref<{ area: Array<{ x: number; y: number }>; side: 'left' | 'right' } | null>(null);
provideMenuContentContext({
onItemEnter: (event) => {
if (pointerGraceIntentRef.value) {
return isPointerInGraceArea(event, pointerGraceIntentRef.value.area);
}
return false;
},
onItemLeave: (_event) => {
contentElement.value?.focus({ preventScroll: true });
},
onTriggerLeave: (event) => {
if (pointerGraceIntentRef.value) {
return isPointerInGraceArea(event, pointerGraceIntentRef.value.area);
}
return false;
},
searchRef,
pointerGraceTimerRef,
onPointerGraceIntentChange: (intent) => {
pointerGraceIntentRef.value = intent;
},
});
function handleMountAutoFocus(event: Event) {
event.preventDefault();
if (rootCtx.isUsingKeyboardRef.value) {
contentElement.value?.focus({ preventScroll: true });
}
emit('openAutoFocus', event);
}
function handleKeyDown(event: KeyboardEvent) {
const isCharKey = event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey;
if (isCharKey) {
clearTimeout(searchTimer);
searchRef.value += event.key;
const content = contentElement.value;
if (!content) return;
const items = Array.from(
content.querySelectorAll<HTMLElement>('[data-primitives-menu-item]:not([data-disabled])'),
);
const currentItem = content.querySelector<HTMLElement>('[data-primitives-menu-item][data-highlighted]');
const match = getNextMatch(items, searchRef.value, currentItem);
if (match) match.focus({ preventScroll: true });
searchTimer = setTimeout(clearSearch, 1000);
event.stopPropagation();
}
if (FIRST_LAST_KEYS.includes(event.key)) {
event.stopPropagation();
}
}
function handleBlur(event: FocusEvent) {
const content = contentElement.value;
if (!content) return;
if (!content.contains(event.relatedTarget as Node)) {
clearSearch();
}
}
</script>
<template>
<FocusScope
as="template"
:trapped="trapFocus"
:loop="loop"
@mount-auto-focus="handleMountAutoFocus"
@unmount-auto-focus="emit('closeAutoFocus', $event)"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="disableOutsidePointerEvents"
@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')"
>
<RovingFocusGroup
as="template"
orientation="vertical"
:dir="rootCtx.dir.value"
:loop="loop"
@entry-focus="(event: Event) => {
emit('entryFocus', event)
if (!rootCtx.isUsingKeyboardRef.value) event.preventDefault()
}"
>
<PopperContent
:ref="forwardRef"
:as="as"
role="menu"
aria-orientation="vertical"
data-primitives-menu-content=""
:data-state="getOpenState(menuCtx.open.value)"
:dir="rootCtx.dir.value"
:side="side"
:side-offset="sideOffset"
:align="align"
:style="{
'--primitives-menu-content-transform-origin': 'var(--popper-transform-origin)',
'--primitives-menu-content-available-width': 'var(--popper-available-width)',
'--primitives-menu-content-available-height': 'var(--popper-available-height)',
}"
v-bind="popperProps"
@keydown="handleKeyDown"
@blur="handleBlur"
>
<slot />
</PopperContent>
</RovingFocusGroup>
</DismissableLayer>
</FocusScope>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenuGroupProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useId } from '../config-provider';
import { Primitive } from '../primitive';
import { provideMenuGroupContext } from './context';
const { as = 'div', asChild } = defineProps<MenuGroupProps>();
const id = useId(undefined, 'menu-group');
provideMenuGroupContext({ id: id.value });
</script>
<template>
<Primitive :as="as" :as-child="asChild" role="group" :id="id">
<slot />
</Primitive>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
export interface MenuItemProps extends MenuItemImplProps {}
export type MenuItemEmits = MenuItemImplEmits;
</script>
<script setup lang="ts">
import { useMenuRootContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { ITEM_SELECT } from './utils';
const props = defineProps<MenuItemProps>();
const emit = defineEmits<MenuItemEmits>();
const rootCtx = useMenuRootContext();
function handleSelect(event: Event) {
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="props" @select="handleSelect">
<slot />
</MenuItemImpl>
</template>
+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>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenuItemIndicatorProps extends PrimitiveProps {
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Presence } from '../presence';
import { Primitive } from '../primitive';
import { useMenuItemIndicatorContext } from './context';
import { getCheckedState, isIndeterminate } from './utils';
const { as = 'span', asChild, forceMount = false } = defineProps<MenuItemIndicatorProps>();
const ctx = useMenuItemIndicatorContext();
const isPresent = computed(() => ctx.checkedState.value === true || isIndeterminate(ctx.checkedState.value));
</script>
<template>
<Presence :present="forceMount || isPresent">
<Primitive
:as="as"
:as-child="asChild"
:data-state="getCheckedState(ctx.checkedState.value)"
>
<slot />
</Primitive>
</Presence>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenuLabelProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'div', asChild } = defineProps<MenuLabelProps>();
</script>
<template>
<Primitive :as="as" :as-child="asChild">
<slot />
</Primitive>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PortalProps } from '../teleport';
export interface MenuPortalProps extends PortalProps {}
</script>
<script setup lang="ts">
import { Portal } from '../teleport';
const { to, defer, disabled } = defineProps<MenuPortalProps>();
</script>
<template>
<Portal :to="to" :defer="defer" :disabled="disabled">
<slot />
</Portal>
</template>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenuRadioGroupProps extends PrimitiveProps {
modelValue?: string;
defaultValue?: string;
}
export interface MenuRadioGroupEmits {
'update:modelValue': [value: string];
}
</script>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Primitive } from '../primitive';
import { provideMenuRadioGroupContext } from './context';
const { modelValue, defaultValue, as = 'div', asChild } = defineProps<MenuRadioGroupProps>();
const emit = defineEmits<MenuRadioGroupEmits>();
const local = ref(defaultValue);
const value = computed(() => modelValue !== undefined ? modelValue : local.value);
provideMenuRadioGroupContext({
modelValue: value,
onValueChange: (v) => {
local.value = v;
emit('update:modelValue', v);
},
});
</script>
<template>
<Primitive :as="as" :as-child="asChild" role="group">
<slot />
</Primitive>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import type { MenuItemImplEmits, MenuItemImplProps } from './MenuItemImpl.vue';
export interface MenuRadioItemProps extends MenuItemImplProps {
value: string;
}
export type MenuRadioItemEmits = MenuItemImplEmits;
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { provideMenuItemIndicatorContext, useMenuRadioGroupContext, useMenuRootContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { ITEM_SELECT, getCheckedState } from './utils';
const { value, ...itemProps } = defineProps<MenuRadioItemProps>();
const emit = defineEmits<MenuRadioItemEmits>();
const radioCtx = useMenuRadioGroupContext();
const rootCtx = useMenuRootContext();
const checkedState = computed(() => radioCtx.modelValue.value === value);
provideMenuItemIndicatorContext({ checkedState });
function handleSelect(event: Event) {
radioCtx.onValueChange(value);
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="menuitemradio"
:aria-checked="checkedState"
:data-state="getCheckedState(checkedState)"
@select="handleSelect"
>
<slot />
</MenuItemImpl>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import type { Direction } from '../config-provider';
export interface MenuRootProps {
open?: boolean;
dir?: Direction;
modal?: boolean;
}
export interface MenuRootEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { shallowRef, toRef } from 'vue';
import { useConfig } from '../config-provider';
import { PopperRoot } from '../popper';
import { provideMenuContext, provideMenuRootContext } from './context';
import { useIsUsingKeyboard } from './useIsUsingKeyboard';
const {
open = false,
dir: dirProp,
modal = true,
} = defineProps<MenuRootProps>();
const emit = defineEmits<MenuRootEmits>();
defineSlots<{ default?: () => unknown }>();
const config = useConfig();
const dirRef = toRef(() => dirProp ?? config.dir.value);
const isUsingKeyboardRef = useIsUsingKeyboard();
const content = shallowRef<HTMLElement | null>(null);
const openRef = toRef(() => open);
provideMenuContext({
open: openRef,
onOpenChange: v => emit('update:open', v),
content,
onContentChange: (el) => { content.value = el; },
});
provideMenuRootContext({
onClose: () => emit('update:open', false),
dir: dirRef,
isUsingKeyboardRef,
modal: toRef(() => modal),
});
</script>
<template>
<PopperRoot>
<slot />
</PopperRoot>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
import { shallowRef, watchEffect } from 'vue';
import { useBodyScrollLock, useFocusGuard } from '@robonen/vue';
import { useHideOthers } from '../utils/useHideOthers';
import MenuContentImpl from './MenuContentImpl.vue';
import { useMenuContext } from './context';
const props = defineProps<MenuContentImplProps>();
const emit = defineEmits<MenuContentImplEmits>();
const menuCtx = useMenuContext();
const contentRef = shallowRef<HTMLElement | null>(null);
watchEffect(() => menuCtx.onContentChange(contentRef.value));
useFocusGuard();
useBodyScrollLock();
useHideOthers(contentRef);
</script>
<template>
<MenuContentImpl
v-bind="props"
:ref="(comp: any) => { contentRef = comp?.$el ?? null }"
:trap-focus="menuCtx.open.value"
:disable-outside-pointer-events="menuCtx.open.value"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContentImpl>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
import { shallowRef, watchEffect } from 'vue';
import MenuContentImpl from './MenuContentImpl.vue';
import { useMenuContext } from './context';
const props = defineProps<MenuContentImplProps>();
const emit = defineEmits<MenuContentImplEmits>();
const menuCtx = useMenuContext();
const contentRef = shallowRef<HTMLElement | null>(null);
watchEffect(() => menuCtx.onContentChange(contentRef.value));
</script>
<template>
<MenuContentImpl
v-bind="props"
:ref="(comp: any) => { contentRef = comp?.$el ?? null }"
:trap-focus="false"
:disable-outside-pointer-events="false"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContentImpl>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenuSeparatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'div', asChild } = defineProps<MenuSeparatorProps>();
</script>
<template>
<Primitive :as="as" :as-child="asChild" role="separator" aria-orientation="horizontal">
<slot />
</Primitive>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
export interface MenuSubProps {
open?: boolean;
}
export interface MenuSubEmits {
'update:open': [value: boolean];
}
</script>
<script setup lang="ts">
import { shallowRef, toRef } from 'vue';
import { useId } from '../config-provider';
import { PopperRoot } from '../popper';
import { provideMenuContext, provideMenuSubContext } from './context';
const { open = false } = defineProps<MenuSubProps>();
const emit = defineEmits<MenuSubEmits>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const openRef = toRef(() => open);
const trigger = shallowRef<HTMLElement | null>(null);
const contentId = useId(undefined, 'menu-sub-content');
const triggerId = useId(undefined, 'menu-sub-trigger');
provideMenuContext({
open: openRef,
onOpenChange: v => emit('update:open', v),
content: shallowRef(null),
onContentChange: () => {},
});
provideMenuSubContext({
contentId,
triggerId,
trigger,
onTriggerChange: (el) => { trigger.value = el; },
});
</script>
<template>
<PopperRoot>
<slot :open="open" />
</PopperRoot>
</template>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { MenuContentImplEmits, MenuContentImplProps } from './MenuContentImpl.vue';
export interface MenuSubContentProps extends MenuContentImplProps {
forceMount?: boolean;
}
export type MenuSubContentEmits = MenuContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import { useMenuContext, useMenuRootContext, useMenuSubContext } from './context';
import MenuContentImpl from './MenuContentImpl.vue';
const { forceMount = false, ...contentProps } = defineProps<MenuSubContentProps>();
const emit = defineEmits<MenuSubContentEmits>();
const menuCtx = useMenuContext();
const subCtx = useMenuSubContext();
const rootCtx = useMenuRootContext();
</script>
<template>
<Presence :present="forceMount || menuCtx.open.value">
<MenuContentImpl
:id="subCtx.contentId.value"
v-bind="contentProps"
:aria-labelledby="subCtx.triggerId.value"
:trap-focus="false"
:disable-outside-pointer-events="false"
:side="rootCtx.dir.value === 'rtl' ? 'left' : 'right'"
align="start"
:side-offset="2"
:align-offset="-5"
@close-auto-focus="(event: Event) => { event.preventDefault(); emit('closeAutoFocus', event) }"
@escape-key-down="(event: KeyboardEvent) => {
emit('escapeKeyDown', event)
menuCtx.onOpenChange(false)
}"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="(event: FocusEvent) => {
if (subCtx.trigger.value?.contains(event.target as Node)) event.preventDefault()
emit('focusOutside', event)
}"
@interact-outside="emit('interactOutside', $event)"
@dismiss="menuCtx.onOpenChange(false)"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContentImpl>
</Presence>
</template>
@@ -0,0 +1,83 @@
<script lang="ts">
import type { MenuItemImplProps } from './MenuItemImpl.vue';
export interface MenuSubTriggerProps extends MenuItemImplProps {}
</script>
<script setup lang="ts">
import { PopperAnchor } from '../popper';
import { useMenuContentContext, useMenuContext, useMenuRootContext, useMenuSubContext } from './context';
import MenuItemImpl from './MenuItemImpl.vue';
import { SUB_CLOSE_KEYS, SUB_OPEN_KEYS, getOpenState, isPointerInGraceArea } from './utils';
const props = defineProps<MenuSubTriggerProps>();
const menuCtx = useMenuContext();
const subCtx = useMenuSubContext();
const rootCtx = useMenuRootContext();
const contentCtx = useMenuContentContext();
let openTimer: ReturnType<typeof setTimeout> | undefined;
function open() {
clearTimeout(openTimer);
menuCtx.onOpenChange(true);
}
function close() {
clearTimeout(openTimer);
menuCtx.onOpenChange(false);
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return;
if (props.disabled) return;
if (contentCtx.onItemEnter(event)) return;
if (!menuCtx.open.value) {
clearTimeout(openTimer);
openTimer = setTimeout(() => menuCtx.onOpenChange(true), 100);
}
}
function handlePointerLeave(event: PointerEvent) {
if (event.pointerType === 'touch') return;
clearTimeout(openTimer);
if (contentCtx.onTriggerLeave(event)) return;
close();
}
function handleKeyDown(event: KeyboardEvent) {
if (props.disabled) return;
const openKeys = SUB_OPEN_KEYS[rootCtx.dir.value]!;
const closeKeys = SUB_CLOSE_KEYS[rootCtx.dir.value]!;
if (openKeys.includes(event.key)) {
event.preventDefault();
open();
}
if (closeKeys.includes(event.key)) {
event.preventDefault();
close();
}
}
</script>
<template>
<PopperAnchor as-child>
<MenuItemImpl
v-bind="props"
:id="subCtx.triggerId.value"
:ref="(el: unknown) => subCtx.onTriggerChange((el as any)?.$el ?? null)"
aria-haspopup="menu"
:aria-expanded="menuCtx.open.value"
:aria-controls="subCtx.contentId.value"
:data-state="getOpenState(menuCtx.open.value)"
role="menuitem"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
@keydown="handleKeyDown"
@select.prevent
>
<slot />
</MenuItemImpl>
</PopperAnchor>
</template>
+62
View File
@@ -0,0 +1,62 @@
import type { CheckedState } from './types';
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../config-provider';
import { useContextFactory } from '@robonen/vue';
export interface MenuContext {
open: Ref<boolean>;
onOpenChange: (open: boolean) => void;
content: Ref<HTMLElement | null>;
onContentChange: (el: HTMLElement | null) => void;
}
export const { inject: useMenuContext, provide: provideMenuContext }
= useContextFactory<MenuContext>('MenuContext');
export interface MenuRootContext {
onClose: () => void;
dir: Ref<Direction>;
isUsingKeyboardRef: Ref<boolean>;
modal: Ref<boolean>;
}
export const { inject: useMenuRootContext, provide: provideMenuRootContext }
= useContextFactory<MenuRootContext>('MenuRootContext');
export interface MenuContentContext {
onItemEnter: (event: PointerEvent) => boolean;
onItemLeave: (event: PointerEvent) => void;
onTriggerLeave: (event: PointerEvent) => boolean;
searchRef: Ref<string>;
pointerGraceTimerRef: Ref<number>;
onPointerGraceIntentChange: (intent: { area: Array<{ x: number; y: number }>; side: 'left' | 'right' } | null) => void;
}
export const { inject: useMenuContentContext, provide: provideMenuContentContext }
= useContextFactory<MenuContentContext>('MenuContentContext');
export interface MenuSubContext {
contentId: ComputedRef<string>;
triggerId: ComputedRef<string>;
trigger: ShallowRef<HTMLElement | null>;
onTriggerChange: (el: HTMLElement | null) => void;
}
export const { inject: useMenuSubContext, provide: provideMenuSubContext }
= useContextFactory<MenuSubContext>('MenuSubContext');
export interface MenuRadioGroupContext {
modelValue: Ref<string | undefined>;
onValueChange: (value: string) => void;
}
export const { inject: useMenuRadioGroupContext, provide: provideMenuRadioGroupContext }
= useContextFactory<MenuRadioGroupContext>('MenuRadioGroupContext');
export interface MenuItemIndicatorContext {
checkedState: Ref<CheckedState>;
}
export const { inject: useMenuItemIndicatorContext, provide: provideMenuItemIndicatorContext }
= useContextFactory<MenuItemIndicatorContext>('MenuItemIndicatorContext');
export interface MenuGroupContext {
id: string;
}
export const { inject: useMenuGroupContext, provide: provideMenuGroupContext }
= useContextFactory<MenuGroupContext>('MenuGroupContext');
+20
View File
@@ -0,0 +1,20 @@
export type { CheckedState } from './types';
export { useMenuContext, useMenuContentContext, useMenuRootContext, useMenuSubContext } from './context';
export { default as MenuAnchor, type MenuAnchorProps } from './MenuAnchor.vue';
export { default as MenuArrow, type MenuArrowProps } from './MenuArrow.vue';
export { default as MenuCheckboxItem, type MenuCheckboxItemEmits, type MenuCheckboxItemProps } from './MenuCheckboxItem.vue';
export { default as MenuContent, type MenuContentEmits, type MenuContentProps } from './MenuContent.vue';
export { default as MenuGroup, type MenuGroupProps } from './MenuGroup.vue';
export { default as MenuItem, type MenuItemEmits, type MenuItemProps } from './MenuItem.vue';
export { default as MenuItemImpl, type MenuItemImplEmits, type MenuItemImplProps } from './MenuItemImpl.vue';
export { default as MenuItemIndicator, type MenuItemIndicatorProps } from './MenuItemIndicator.vue';
export { default as MenuLabel, type MenuLabelProps } from './MenuLabel.vue';
export { default as MenuPortal, type MenuPortalProps } from './MenuPortal.vue';
export { default as MenuRadioGroup, type MenuRadioGroupEmits, type MenuRadioGroupProps } from './MenuRadioGroup.vue';
export { default as MenuRadioItem, type MenuRadioItemEmits, type MenuRadioItemProps } from './MenuRadioItem.vue';
export { default as MenuRoot, type MenuRootEmits, type MenuRootProps } from './MenuRoot.vue';
export { default as MenuSeparator, type MenuSeparatorProps } from './MenuSeparator.vue';
export { default as MenuSub, type MenuSubEmits, type MenuSubProps } from './MenuSub.vue';
export { default as MenuSubContent, type MenuSubContentEmits, type MenuSubContentProps } from './MenuSubContent.vue';
export { default as MenuSubTrigger, type MenuSubTriggerProps } from './MenuSubTrigger.vue';
+1
View File
@@ -0,0 +1 @@
export type CheckedState = boolean | 'indeterminate';
@@ -0,0 +1,23 @@
import { ref } from 'vue';
const isUsingKeyboard = ref(false);
let initialized = false;
function init() {
if (initialized || typeof document === 'undefined') return;
initialized = true;
document.addEventListener('keydown', () => {
isUsingKeyboard.value = true;
}, { capture: true, passive: true });
document.addEventListener('pointerdown', () => {
isUsingKeyboard.value = false;
}, { capture: true, passive: true });
document.addEventListener('pointermove', () => {
isUsingKeyboard.value = false;
}, { capture: true, passive: true });
}
export function useIsUsingKeyboard() {
init();
return isUsingKeyboard;
}
+86
View File
@@ -0,0 +1,86 @@
import type { CheckedState } from './types';
import { getActiveElement } from '@robonen/platform/browsers';
export const ITEM_SELECT = 'menu.itemSelect';
export const SELECTION_KEYS = ['Enter', ' '];
export const FIRST_KEYS = ['ArrowDown', 'PageUp', 'Home'];
export const LAST_KEYS = ['ArrowUp', 'PageDown', 'End'];
export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
export const SUB_OPEN_KEYS: Record<string, string[]> = {
ltr: [...SELECTION_KEYS, 'ArrowRight'],
rtl: [...SELECTION_KEYS, 'ArrowLeft'],
};
export const SUB_CLOSE_KEYS: Record<string, string[]> = {
ltr: ['ArrowLeft'],
rtl: ['ArrowRight'],
};
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
export function isIndeterminate(checked: CheckedState): checked is 'indeterminate' {
return checked === 'indeterminate';
}
export function getCheckedState(checked: CheckedState): 'checked' | 'unchecked' | 'indeterminate' {
if (isIndeterminate(checked)) return 'indeterminate';
return checked ? 'checked' : 'unchecked';
}
export function focusFirst(candidates: HTMLElement[]): void {
for (const candidate of candidates) {
const prev = getActiveElement();
candidate.focus({ preventScroll: true });
if (getActiveElement() !== prev) return;
}
}
export function getNextMatch(
items: HTMLElement[],
search: string,
currentItem?: HTMLElement | null,
): HTMLElement | undefined {
const isRepeating = search.length > 1 && Array.from(search).every(c => c === search[0]);
const normalizedSearch = isRepeating ? search[0]! : search;
const currentIndex = currentItem ? items.indexOf(currentItem) : -1;
const wrappedItems = currentIndex !== -1
? [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)]
: items;
const getText = (el: HTMLElement) =>
el.dataset['primitiveMenuItemTextValue'] ?? el.textContent?.trim() ?? '';
return wrappedItems.find(item =>
getText(item).toLowerCase().startsWith(normalizedSearch.toLowerCase()),
);
}
export interface Point { x: number; y: number };
export type Polygon = Point[];
export type Side = 'left' | 'right';
export interface GraceIntent { area: Polygon; side: Side }
export function isPointInPolygon(point: Point, polygon: Polygon): boolean {
const { x, y } = point;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i]!.x;
const yi = polygon[i]!.y;
const xj = polygon[j]!.x;
const yj = polygon[j]!.y;
const intersects = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersects) inside = !inside;
}
return inside;
}
export function isPointerInGraceArea(event: PointerEvent, area: Polygon): boolean {
return isPointInPolygon({ x: event.clientX, y: event.clientY }, area);
}
export function isMouseEvent(event: Event): event is MouseEvent {
return ['mousedown', 'mouseup', 'mousemove', 'click'].includes(event.type);
}
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuArrowProps } from '../menu';
export interface MenubarArrowProps extends MenuArrowProps {}
</script>
<script setup lang="ts">
import { MenuArrow } from '../menu';
const props = defineProps<MenubarArrowProps>();
</script>
<template>
<MenuArrow v-bind="props"><slot /></MenuArrow>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { MenuCheckboxItemEmits, MenuCheckboxItemProps } from '../menu';
export interface MenubarCheckboxItemProps extends MenuCheckboxItemProps {}
export type MenubarCheckboxItemEmits = MenuCheckboxItemEmits;
</script>
<script setup lang="ts">
import { MenuCheckboxItem } from '../menu';
const props = defineProps<MenubarCheckboxItemProps>();
const emit = defineEmits<MenubarCheckboxItemEmits>();
</script>
<template>
<MenuCheckboxItem
v-bind="props"
@select="emit('select', $event)"
@update:checked="emit('update:checked', $event)"
><slot /></MenuCheckboxItem>
</template>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { MenuContentEmits, MenuContentProps } from '../menu';
export interface MenubarContentProps extends MenuContentProps {}
export type MenubarContentEmits = MenuContentEmits;
</script>
<script setup lang="ts">
import { MenuContent } from '../menu';
import { useMenubarMenuContext, useMenubarRootContext } from './context';
const props = defineProps<MenubarContentProps>();
const emit = defineEmits<MenubarContentEmits>();
const rootCtx = useMenubarRootContext();
const menuCtx = useMenubarMenuContext();
</script>
<template>
<MenuContent
:id="menuCtx.contentId.value"
v-bind="props"
align="start"
:aria-labelledby="menuCtx.triggerId.value"
@close-auto-focus="(event: Event) => {
if (!menuCtx.wasKeyboardTriggerOpenRef.value) event.preventDefault()
menuCtx.wasKeyboardTriggerOpenRef.value = false
menuCtx.triggerRef.value?.focus({ preventScroll: true })
emit('closeAutoFocus', event)
}"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="(event: PointerEvent | MouseEvent) => {
const target = event.target as Node
const isMenubarTrigger = menuCtx.triggerRef.value?.contains(target)
if (isMenubarTrigger) event.preventDefault()
emit('pointerDownOutside', event)
}"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="rootCtx.onMenuClose()"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
>
<slot />
</MenuContent>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuGroupProps } from '../menu';
export interface MenubarGroupProps extends MenuGroupProps {}
</script>
<script setup lang="ts">
import { MenuGroup } from '../menu';
const props = defineProps<MenubarGroupProps>();
</script>
<template>
<MenuGroup v-bind="props"><slot /></MenuGroup>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuItemEmits, MenuItemProps } from '../menu';
export interface MenubarItemProps extends MenuItemProps {}
export type MenubarItemEmits = MenuItemEmits;
</script>
<script setup lang="ts">
import { MenuItem } from '../menu';
const props = defineProps<MenubarItemProps>();
const emit = defineEmits<MenubarItemEmits>();
</script>
<template>
<MenuItem v-bind="props" @select="emit('select', $event)"><slot /></MenuItem>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuItemIndicatorProps } from '../menu';
export interface MenubarItemIndicatorProps extends MenuItemIndicatorProps {}
</script>
<script setup lang="ts">
import { MenuItemIndicator } from '../menu';
const props = defineProps<MenubarItemIndicatorProps>();
</script>
<template>
<MenuItemIndicator v-bind="props"><slot /></MenuItemIndicator>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuLabelProps } from '../menu';
export interface MenubarLabelProps extends MenuLabelProps {}
</script>
<script setup lang="ts">
import { MenuLabel } from '../menu';
const props = defineProps<MenubarLabelProps>();
</script>
<template>
<MenuLabel v-bind="props"><slot /></MenuLabel>
</template>
@@ -0,0 +1,50 @@
<script lang="ts">
export interface MenubarMenuProps {
value?: string;
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import { useId } from '../config-provider';
import { MenuRoot } from '../menu';
import { provideMenubarMenuContext, useMenubarRootContext } from './context';
const { value: valueProp } = defineProps<MenubarMenuProps>();
defineSlots<{ default?: (props: { open: boolean }) => unknown }>();
const rootCtx = useMenubarRootContext();
const autoValue = useId(undefined, 'menubar-menu');
const menuValue = valueProp ?? autoValue.value;
const triggerRef = shallowRef<HTMLElement | null>(null);
const triggerId = useId(undefined, 'menubar-trigger');
const contentId = useId(undefined, 'menubar-content');
const wasKeyboardTriggerOpenRef = ref(false);
const open = computed(() => rootCtx.value.value === menuValue);
provideMenubarMenuContext({
value: menuValue,
triggerId,
contentId,
triggerRef,
onTriggerChange: (el) => { triggerRef.value = el; },
wasKeyboardTriggerOpenRef,
});
</script>
<template>
<MenuRoot
:open="open"
:dir="rootCtx.dir.value"
:modal="false"
@update:open="(v) => {
if (!v) rootCtx.onMenuClose()
}"
>
<slot :open="open" />
</MenuRoot>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuPortalProps } from '../menu';
export interface MenubarPortalProps extends MenuPortalProps {}
</script>
<script setup lang="ts">
import { MenuPortal } from '../menu';
const props = defineProps<MenubarPortalProps>();
</script>
<template>
<MenuPortal v-bind="props"><slot /></MenuPortal>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuRadioGroupEmits, MenuRadioGroupProps } from '../menu';
export interface MenubarRadioGroupProps extends MenuRadioGroupProps {}
export type MenubarRadioGroupEmits = MenuRadioGroupEmits;
</script>
<script setup lang="ts">
import { MenuRadioGroup } from '../menu';
const props = defineProps<MenubarRadioGroupProps>();
const emit = defineEmits<MenubarRadioGroupEmits>();
</script>
<template>
<MenuRadioGroup v-bind="props" @update:model-value="emit('update:modelValue', $event)"><slot /></MenuRadioGroup>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuRadioItemEmits, MenuRadioItemProps } from '../menu';
export interface MenubarRadioItemProps extends MenuRadioItemProps {}
export type MenubarRadioItemEmits = MenuRadioItemEmits;
</script>
<script setup lang="ts">
import { MenuRadioItem } from '../menu';
const props = defineProps<MenubarRadioItemProps>();
const emit = defineEmits<MenubarRadioItemEmits>();
</script>
<template>
<MenuRadioItem v-bind="props" @select="emit('select', $event)"><slot /></MenuRadioItem>
</template>
@@ -0,0 +1,61 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { PrimitiveProps } from '../primitive';
export interface MenubarRootProps extends PrimitiveProps {
modelValue?: string;
defaultValue?: string;
dir?: Direction;
loop?: boolean;
}
export interface MenubarRootEmits {
'update:modelValue': [value: string | undefined];
}
</script>
<script setup lang="ts">
import { computed, ref, toRef } from 'vue';
import { useConfig } from '../config-provider';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../primitive';
import { provideMenubarRootContext } from './context';
const {
modelValue,
defaultValue,
dir: dirProp,
loop = true,
as = 'div',
asChild,
} = defineProps<MenubarRootProps>();
const emit = defineEmits<MenubarRootEmits>();
const config = useConfig();
const dirRef = toRef(() => dirProp ?? config.dir.value);
const { forwardRef } = useForwardExpose();
const local = ref<string | undefined>(defaultValue);
const value = computed({
get: () => modelValue !== undefined ? modelValue : local.value,
set: (v) => {
local.value = v;
emit('update:modelValue', v);
},
});
provideMenubarRootContext({
value,
dir: dirRef,
loop: toRef(() => loop),
onMenuOpen: (v) => { value.value = v; },
onMenuClose: () => { value.value = undefined; },
onMenuToggle: (v) => { value.value = value.value === v ? undefined : v; },
});
</script>
<template>
<Primitive :ref="forwardRef" :as="as" :as-child="asChild" role="menubar">
<slot />
</Primitive>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuSeparatorProps } from '../menu';
export interface MenubarSeparatorProps extends MenuSeparatorProps {}
</script>
<script setup lang="ts">
import { MenuSeparator } from '../menu';
const props = defineProps<MenubarSeparatorProps>();
</script>
<template>
<MenuSeparator v-bind="props"><slot /></MenuSeparator>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type { MenuSubEmits, MenuSubProps } from '../menu';
export interface MenubarSubProps extends MenuSubProps {}
export type MenubarSubEmits = MenuSubEmits;
</script>
<script setup lang="ts">
import { MenuSub } from '../menu';
const props = defineProps<MenubarSubProps>();
const emit = defineEmits<MenubarSubEmits>();
</script>
<template>
<MenuSub v-bind="props" @update:open="emit('update:open', $event)"><slot /></MenuSub>
</template>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { MenuSubContentEmits, MenuSubContentProps } from '../menu';
export interface MenubarSubContentProps extends MenuSubContentProps {}
export type MenubarSubContentEmits = MenuSubContentEmits;
</script>
<script setup lang="ts">
import { MenuSubContent } from '../menu';
const props = defineProps<MenubarSubContentProps>();
const emit = defineEmits<MenubarSubContentEmits>();
</script>
<template>
<MenuSubContent
v-bind="props"
@close-auto-focus="emit('closeAutoFocus', $event)"
@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')"
@entry-focus="emit('entryFocus', $event)"
@open-auto-focus="emit('openAutoFocus', $event)"
><slot /></MenuSubContent>
</template>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { MenuSubTriggerProps } from '../menu';
export interface MenubarSubTriggerProps extends MenuSubTriggerProps {}
</script>
<script setup lang="ts">
import { MenuSubTrigger } from '../menu';
const props = defineProps<MenubarSubTriggerProps>();
</script>
<template>
<MenuSubTrigger v-bind="props"><slot /></MenuSubTrigger>
</template>
@@ -0,0 +1,72 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface MenubarTriggerProps extends PrimitiveProps {
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { MenuAnchor, useMenuContext } from '../menu';
import { Primitive } from '../primitive';
import { useMenubarMenuContext, useMenubarRootContext } from './context';
const { disabled = false, as = 'button', asChild } = defineProps<MenubarTriggerProps>();
const rootCtx = useMenubarRootContext();
const menuCtx = useMenubarMenuContext();
const menuMenuCtx = useMenuContext();
const { forwardRef, currentElement } = useForwardExpose();
onMounted(() => menuCtx.onTriggerChange(currentElement.value ?? null));
onUnmounted(() => menuCtx.onTriggerChange(null));
function handlePointerDown(event: PointerEvent) {
if (disabled || event.button !== 0) return;
event.preventDefault();
rootCtx.onMenuToggle(menuCtx.value);
}
function handlePointerEnter() {
if (disabled) return;
if (rootCtx.value.value !== undefined && rootCtx.value.value !== menuCtx.value) {
rootCtx.onMenuOpen(menuCtx.value);
menuCtx.triggerRef.value?.focus({ preventScroll: true });
}
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
event.preventDefault();
menuCtx.wasKeyboardTriggerOpenRef.value = true;
rootCtx.onMenuOpen(menuCtx.value);
}
}
</script>
<template>
<MenuAnchor as-child>
<Primitive
:ref="forwardRef"
:as="as"
:as-child="asChild"
:id="menuCtx.triggerId.value"
role="menuitem"
aria-haspopup="menu"
:aria-expanded="menuMenuCtx.open.value"
:aria-controls="menuCtx.contentId.value"
:data-state="menuMenuCtx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@pointerdown="handlePointerDown"
@pointerenter="handlePointerEnter"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</MenuAnchor>
</template>
+32
View File
@@ -0,0 +1,32 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../config-provider';
import { useContextFactory } from '@robonen/vue';
export interface MenubarRootContext {
value: Ref<string | undefined>;
dir: Ref<Direction>;
loop: Ref<boolean>;
onMenuOpen: (value: string) => void;
onMenuClose: () => void;
onMenuToggle: (value: string) => void;
}
export const {
inject: useMenubarRootContext,
provide: provideMenubarRootContext,
} = useContextFactory<MenubarRootContext>('MenubarRootContext');
export interface MenubarMenuContext {
value: string;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
triggerRef: ShallowRef<HTMLElement | null>;
onTriggerChange: (el: HTMLElement | null) => void;
wasKeyboardTriggerOpenRef: Ref<boolean>;
}
export const {
inject: useMenubarMenuContext,
provide: provideMenubarMenuContext,
} = useContextFactory<MenubarMenuContext>('MenubarMenuContext');
+50
View File
@@ -0,0 +1,50 @@
export { default as MenubarRoot } from './MenubarRoot.vue';
export type { MenubarRootProps, MenubarRootEmits } from './MenubarRoot.vue';
export { default as MenubarMenu } from './MenubarMenu.vue';
export type { MenubarMenuProps } from './MenubarMenu.vue';
export { default as MenubarTrigger } from './MenubarTrigger.vue';
export type { MenubarTriggerProps } from './MenubarTrigger.vue';
export { default as MenubarContent } from './MenubarContent.vue';
export type { MenubarContentProps, MenubarContentEmits } from './MenubarContent.vue';
export { default as MenubarPortal } from './MenubarPortal.vue';
export type { MenubarPortalProps } from './MenubarPortal.vue';
export { default as MenubarArrow } from './MenubarArrow.vue';
export type { MenubarArrowProps } from './MenubarArrow.vue';
export { default as MenubarSeparator } from './MenubarSeparator.vue';
export type { MenubarSeparatorProps } from './MenubarSeparator.vue';
export { default as MenubarLabel } from './MenubarLabel.vue';
export type { MenubarLabelProps } from './MenubarLabel.vue';
export { default as MenubarGroup } from './MenubarGroup.vue';
export type { MenubarGroupProps } from './MenubarGroup.vue';
export { default as MenubarItem } from './MenubarItem.vue';
export type { MenubarItemProps, MenubarItemEmits } from './MenubarItem.vue';
export { default as MenubarCheckboxItem } from './MenubarCheckboxItem.vue';
export type { MenubarCheckboxItemProps, MenubarCheckboxItemEmits } from './MenubarCheckboxItem.vue';
export { default as MenubarRadioGroup } from './MenubarRadioGroup.vue';
export type { MenubarRadioGroupProps, MenubarRadioGroupEmits } from './MenubarRadioGroup.vue';
export { default as MenubarRadioItem } from './MenubarRadioItem.vue';
export type { MenubarRadioItemProps, MenubarRadioItemEmits } from './MenubarRadioItem.vue';
export { default as MenubarItemIndicator } from './MenubarItemIndicator.vue';
export type { MenubarItemIndicatorProps } from './MenubarItemIndicator.vue';
export { default as MenubarSub } from './MenubarSub.vue';
export type { MenubarSubProps, MenubarSubEmits } from './MenubarSub.vue';
export { default as MenubarSubTrigger } from './MenubarSubTrigger.vue';
export type { MenubarSubTriggerProps } from './MenubarSubTrigger.vue';
export { default as MenubarSubContent } from './MenubarSubContent.vue';
export type { MenubarSubContentProps, MenubarSubContentEmits } from './MenubarSubContent.vue';