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 './primitive';
|
||||
export * from './presence';
|
||||
export * from './collection';
|
||||
export * from './roving-focus';
|
||||
export * from './pagination';
|
||||
export * from './focus-scope';
|
||||
export * from './visually-hidden';
|
||||
export * from './teleport';
|
||||
export * from './dismissable-layer';
|
||||
export * from './dialog';
|
||||
export * from './alert-dialog';
|
||||
export * from './scroll-area';
|
||||
export * from './separator';
|
||||
export * from './label';
|
||||
export * from './aspect-ratio';
|
||||
export * from './toggle';
|
||||
export * from './switch';
|
||||
export * from './progress';
|
||||
export * from './collapsible';
|
||||
export * from './avatar';
|
||||
export * from './slider';
|
||||
export * from './checkbox';
|
||||
export * from './toolbar';
|
||||
export * from './radio-group';
|
||||
export * from './toggle-group';
|
||||
export * from './number-field';
|
||||
export * from './pin-input';
|
||||
export * from './tabs';
|
||||
export * from './accordion';
|
||||
export * from './popper';
|
||||
export * from './hover-card';
|
||||
export * from './popover';
|
||||
export * from './tooltip';
|
||||
export * from './tree';
|
||||
export * from './stepper';
|
||||
export * from './editable';
|
||||
export * from './tags-input';
|
||||
export * from './listbox';
|
||||
|
||||
export * from './menu';
|
||||
export * from './dropdown-menu';
|
||||
export * from './context-menu';
|
||||
export * from './menubar';
|
||||
|
||||
@@ -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