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,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');
|
||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -1,5 +1,44 @@
|
|||||||
export * from './config-provider';
|
export * from './config-provider';
|
||||||
export * from './primitive';
|
export * from './primitive';
|
||||||
export * from './presence';
|
export * from './presence';
|
||||||
|
export * from './collection';
|
||||||
|
export * from './roving-focus';
|
||||||
export * from './pagination';
|
export * from './pagination';
|
||||||
export * from './focus-scope';
|
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';
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user