Context Menu
Demo
Right click here
Related Components
This requires the following components to be installed:
Related Utils
This requires the following utils to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { KbdProps } from '@/ui/components/Kbd.vue'
import type { LinkProps } from '@/ui/components/Link.vue'
import type { ArrayOrNested, ComponentConfig, DynamicSlots, MergeTypes, NestedItem } from '@/ui/utils/utils'
import type { ContextMenuContentEmits, ContextMenuContentProps, ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import ContextMenuContent from '@/ui/components/ContextMenuContent.vue'
import theme from '@/ui/theme/context-menu'
import { omit } from '@/ui/utils/omit'
import { reactivePick } from '@vueuse/shared'
import { ContextMenuRoot, ContextMenuTrigger, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type ContextMenu = ComponentConfig<typeof theme>
export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw'> {
label?: string
icon?: string
color?: ContextMenu['variants']['color']
avatar?: AvatarProps
content?: Omit<ContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<ContextMenuContentEmits>>
kbds?: KbdProps['value'][] | KbdProps[]
type?: 'label' | 'separator' | 'link' | 'checkbox'
slot?: string
loading?: boolean
disabled?: boolean
checked?: boolean
open?: boolean
defaultOpen?: boolean
children?: ArrayOrNested<ContextMenuItem>
onSelect?: (e: Event) => void
onUpdateChecked?: (checked: boolean) => void
[key: string]: any
}
export interface ContextMenuProps<T extends ArrayOrNested<ContextMenuItem> = ArrayOrNested<ContextMenuItem>> extends Omit<ContextMenuRootProps, 'dir'> {
size?: ContextMenu['variants']['size']
items?: T
content?: Omit<ContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<ContextMenuContentEmits>>
portal?: boolean | string | HTMLElement
labelKey?: keyof NestedItem<T>
disabled?: boolean
class?: any
ui?: ContextMenu['slots']
}
export interface ContextMenuEmits extends ContextMenuRootEmits {}
type SlotProps<T extends ContextMenuItem> = (props: { item: T, active?: boolean, index: number }) => any
export type ContextMenuSlots<
A extends ArrayOrNested<ContextMenuItem> = ArrayOrNested<ContextMenuItem>,
T extends NestedItem<A> = NestedItem<A>,
> = {
'default': (props?: object) => any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'content-top': (props?: object) => any
'content-bottom': (props?: object) => any
} & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script>
<script setup lang="ts" generic="T extends ArrayOrNested<ContextMenuItem>">
const props = withDefaults(defineProps<ContextMenuProps<T>>(), {
portal: true,
modal: true,
labelKey: 'label',
})
const emits = defineEmits<ContextMenuEmits>()
const slots = defineSlots<ContextMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'modal'), emits)
const contentProps = toRef(() => props.content)
const proxySlots = omit(slots, ['default'])
const ui = computed(() => tv(theme)({
size: props.size,
}))
</script>
<template>
<ContextMenuRoot v-bind="rootProps">
<ContextMenuTrigger v-if="!!slots.default" as-child :disabled="disabled" :class="props.class">
<slot />
</ContextMenuTrigger>
<ContextMenuContent
:class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })"
:ui="ui"
:ui-override="props.ui"
v-bind="contentProps"
:items="items"
:portal="portal"
:label-key="(labelKey as keyof NestedItem<T>)"
>
<template v-for="(_, name) in proxySlots" #[name]="slotData">
<slot :name="(name as keyof ContextMenuSlots<T>)" v-bind="slotData" />
</template>
</ContextMenuContent>
</ContextMenuRoot>
</template>vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { KbdProps } from '@/UI/Components/Kbd.vue'
import type { LinkProps } from '@/UI/Components/Link.vue'
import type { ArrayOrNested, ComponentConfig, DynamicSlots, MergeTypes, NestedItem } from '@/UI/Utils/utils'
import type { ContextMenuContentEmits, ContextMenuContentProps, ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import ContextMenuContent from '@/UI/Components/ContextMenuContent.vue'
import theme from '@/UI/Theme/context-menu'
import { omit } from '@/UI/Utils/omit'
import { reactivePick } from '@vueuse/shared'
import { ContextMenuRoot, ContextMenuTrigger, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type ContextMenu = ComponentConfig<typeof theme>
export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw'> {
label?: string
icon?: string
color?: ContextMenu['variants']['color']
avatar?: AvatarProps
content?: Omit<ContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<ContextMenuContentEmits>>
kbds?: KbdProps['value'][] | KbdProps[]
type?: 'label' | 'separator' | 'link' | 'checkbox'
slot?: string
loading?: boolean
disabled?: boolean
checked?: boolean
open?: boolean
defaultOpen?: boolean
children?: ArrayOrNested<ContextMenuItem>
onSelect?: (e: Event) => void
onUpdateChecked?: (checked: boolean) => void
[key: string]: any
}
export interface ContextMenuProps<T extends ArrayOrNested<ContextMenuItem> = ArrayOrNested<ContextMenuItem>> extends Omit<ContextMenuRootProps, 'dir'> {
size?: ContextMenu['variants']['size']
items?: T
content?: Omit<ContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<ContextMenuContentEmits>>
portal?: boolean | string | HTMLElement
labelKey?: keyof NestedItem<T>
disabled?: boolean
class?: any
ui?: ContextMenu['slots']
}
export interface ContextMenuEmits extends ContextMenuRootEmits {}
type SlotProps<T extends ContextMenuItem> = (props: { item: T, active?: boolean, index: number }) => any
export type ContextMenuSlots<
A extends ArrayOrNested<ContextMenuItem> = ArrayOrNested<ContextMenuItem>,
T extends NestedItem<A> = NestedItem<A>,
> = {
'default': (props?: object) => any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'content-top': (props?: object) => any
'content-bottom': (props?: object) => any
} & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script>
<script setup lang="ts" generic="T extends ArrayOrNested<ContextMenuItem>">
const props = withDefaults(defineProps<ContextMenuProps<T>>(), {
portal: true,
modal: true,
labelKey: 'label',
})
const emits = defineEmits<ContextMenuEmits>()
const slots = defineSlots<ContextMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'modal'), emits)
const contentProps = toRef(() => props.content)
const proxySlots = omit(slots, ['default'])
const ui = computed(() => tv(theme)({
size: props.size,
}))
</script>
<template>
<ContextMenuRoot v-bind="rootProps">
<ContextMenuTrigger v-if="!!slots.default" as-child :disabled="disabled" :class="props.class">
<slot />
</ContextMenuTrigger>
<ContextMenuContent
:class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })"
:ui="ui"
:ui-override="props.ui"
v-bind="contentProps"
:items="items"
:portal="portal"
:label-key="(labelKey as keyof NestedItem<T>)"
>
<template v-for="(_, name) in proxySlots" #[name]="slotData">
<slot :name="(name as keyof ContextMenuSlots<T>)" v-bind="slotData" />
</template>
</ContextMenuContent>
</ContextMenuRoot>
</template>Theme
ts
export default {
slots: {
content: '',
group: '',
label: '',
separator: '',
item: '',
itemLeadingIcon: '',
itemLeadingAvatar: '',
itemLeadingAvatarSize: '',
itemTrailing: '',
itemTrailingIcon: '',
itemTrailingKbds: '',
itemTrailingKbdsSize: '',
itemLabel: '',
itemLabelExternalIcon: '',
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: '',
},
active: {
true: {
item: '',
itemLeadingIcon: '',
},
false: {
item: '',
itemLeadingIcon: '',
},
},
loading: {
true: {
itemLeadingIcon: '',
},
},
size: {
xs: {
label: '',
item: '',
itemLeadingIcon: '',
itemLeadingAvatarSize: '',
itemTrailingIcon: '',
itemTrailingKbds: '',
itemTrailingKbdsSize: '',
},
sm: {
label: '',
item: '',
itemLeadingIcon: '',
itemLeadingAvatarSize: '',
itemTrailingIcon: '',
itemTrailingKbds: '',
itemTrailingKbdsSize: '',
},
md: {
label: '',
item: '',
itemLeadingIcon: '',
itemLeadingAvatarSize: '',
itemTrailingIcon: '',
itemTrailingKbds: '',
itemTrailingKbdsSize: '',
},
lg: {
label: '',
item: '',
itemLeadingIcon: '',
itemLeadingAvatarSize: '',
itemTrailingIcon: '',
itemTrailingKbds: '',
itemTrailingKbdsSize: '',
},
xl: {
label: '',
item: '',
itemLeadingIcon: '',
itemLeadingAvatarSize: '',
itemTrailingIcon: '',
itemTrailingKbds: '',
itemTrailingKbdsSize: '',
},
},
},
compoundVariants: [],
defaultVariants: {
size: 'md',
} as const,
}View Nuxt UI theme
ts
export default {
slots: {
content: 'min-w-32 bg-default shadow-lg rounded-md ring ring-default divide-y divide-default overflow-y-auto scroll-py-1 data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in]',
group: 'p-1 isolate',
label: 'w-full flex items-center font-semibold text-highlighted',
separator: '-mx-1 my-1 h-px bg-border',
item: 'group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75',
itemLeadingIcon: 'shrink-0',
itemLeadingAvatar: 'shrink-0',
itemLeadingAvatarSize: '',
itemTrailing: 'ms-auto inline-flex gap-1.5 items-center',
itemTrailingIcon: 'shrink-0',
itemTrailingKbds: 'hidden lg:inline-flex items-center shrink-0',
itemTrailingKbdsSize: '',
itemLabel: 'truncate',
itemLabelExternalIcon: 'inline-block size-3 align-top text-dimmed',
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: '',
},
active: {
true: {
item: 'text-highlighted before:bg-elevated',
itemLeadingIcon: 'text-default',
},
false: {
item: 'text-default data-highlighted:text-highlighted data-[state=open]:text-highlighted data-highlighted:before:bg-elevated/50 data-[state=open]:before:bg-elevated/50 transition-colors before:transition-colors',
itemLeadingIcon: 'text-dimmed group-data-highlighted:text-default group-data-[state=open]:text-default transition-colors',
},
},
loading: {
true: {
itemLeadingIcon: 'animate-spin',
},
},
size: {
xs: {
label: 'p-1 text-xs gap-1',
item: 'p-1 text-xs gap-1',
itemLeadingIcon: 'size-4',
itemLeadingAvatarSize: '3xs',
itemTrailingIcon: 'size-4',
itemTrailingKbds: 'gap-0.5',
itemTrailingKbdsSize: 'sm',
},
sm: {
label: 'p-1.5 text-xs gap-1.5',
item: 'p-1.5 text-xs gap-1.5',
itemLeadingIcon: 'size-4',
itemLeadingAvatarSize: '3xs',
itemTrailingIcon: 'size-4',
itemTrailingKbds: 'gap-0.5',
itemTrailingKbdsSize: 'sm',
},
md: {
label: 'p-1.5 text-sm gap-1.5',
item: 'p-1.5 text-sm gap-1.5',
itemLeadingIcon: 'size-5',
itemLeadingAvatarSize: '2xs',
itemTrailingIcon: 'size-5',
itemTrailingKbds: 'gap-0.5',
itemTrailingKbdsSize: 'md',
},
lg: {
label: 'p-2 text-sm gap-2',
item: 'p-2 text-sm gap-2',
itemLeadingIcon: 'size-5',
itemLeadingAvatarSize: '2xs',
itemTrailingIcon: 'size-5',
itemTrailingKbds: 'gap-1',
itemTrailingKbdsSize: 'md',
},
xl: {
label: 'p-2 text-base gap-2',
item: 'p-2 text-base gap-2',
itemLeadingIcon: 'size-6',
itemLeadingAvatarSize: 'xs',
itemTrailingIcon: 'size-6',
itemTrailingKbds: 'gap-1',
itemTrailingKbdsSize: 'lg',
},
},
},
compoundVariants: [
{
color: 'primary',
active: false,
class: {
item: 'text-primary data-highlighted:text-primary data-highlighted:before:bg-primary/10 data-[state=open]:before:bg-primary/10',
itemLeadingIcon: 'text-primary/75 group-data-highlighted:text-primary group-data-[state=open]:text-primary',
},
} as const,
{
color: 'primary',
active: true,
class: {
item: 'text-primary before:bg-primary/10',
itemLeadingIcon: 'text-primary',
},
} as const,
{
color: 'secondary',
active: false,
class: {
item: 'text-secondary data-highlighted:text-secondary data-highlighted:before:bg-secondary/10 data-[state=open]:before:bg-secondary/10',
itemLeadingIcon: 'text-secondary/75 group-data-highlighted:text-secondary group-data-[state=open]:text-secondary',
},
} as const,
{
color: 'secondary',
active: true,
class: {
item: 'text-secondary before:bg-secondary/10',
itemLeadingIcon: 'text-secondary',
},
} as const,
{
color: 'success',
active: false,
class: {
item: 'text-success data-highlighted:text-success data-highlighted:before:bg-success/10 data-[state=open]:before:bg-success/10',
itemLeadingIcon: 'text-success/75 group-data-highlighted:text-success group-data-[state=open]:text-success',
},
} as const,
{
color: 'success',
active: true,
class: {
item: 'text-success before:bg-success/10',
itemLeadingIcon: 'text-success',
},
} as const,
{
color: 'info',
active: false,
class: {
item: 'text-info data-highlighted:text-info data-highlighted:before:bg-info/10 data-[state=open]:before:bg-info/10',
itemLeadingIcon: 'text-info/75 group-data-highlighted:text-info group-data-[state=open]:text-info',
},
} as const,
{
color: 'info',
active: true,
class: {
item: 'text-info before:bg-info/10',
itemLeadingIcon: 'text-info',
},
} as const,
{
color: 'warning',
active: false,
class: {
item: 'text-warning data-highlighted:text-warning data-highlighted:before:bg-warning/10 data-[state=open]:before:bg-warning/10',
itemLeadingIcon: 'text-warning/75 group-data-highlighted:text-warning group-data-[state=open]:text-warning',
},
} as const,
{
color: 'warning',
active: true,
class: {
item: 'text-warning before:bg-warning/10',
itemLeadingIcon: 'text-warning',
},
} as const,
{
color: 'error',
active: false,
class: {
item: 'text-error data-highlighted:text-error data-highlighted:before:bg-error/10 data-[state=open]:before:bg-error/10',
itemLeadingIcon: 'text-error/75 group-data-highlighted:text-error group-data-[state=open]:text-error',
},
} as const,
{
color: 'error',
active: true,
class: {
item: 'text-error before:bg-error/10',
itemLeadingIcon: 'text-error',
},
} as const,
],
defaultVariants: {
size: 'md',
} as const,
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import ContextMenu from '@/ui/components/ContextMenu.vue'
import theme from '@/ui/theme/context-menu'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
const ContextMenuWrapper = defineComponent({
components: {
ContextMenu,
},
inertAttrs: false,
template: `<ContextMenu v-bind="$attrs">
<span>Right Click</span>
</ContextMenu>`,
})
describe('container', () => {
const sizes = Object.keys(theme.variants.size) as any
const items = [
[{
label: 'Appearance',
children: [{
label: 'System',
icon: 'i-lucide-monitor',
}, {
label: 'Light',
icon: 'i-lucide-sun',
}, {
label: 'Dark',
icon: 'i-lucide-moon',
}],
}],
[{
label: 'Show Sidebar',
color: 'primary',
kbds: ['meta', 'S'],
}, {
label: 'Show Toolbar',
kbds: ['shift', 'meta', 'D'],
}, {
label: 'Collapse Pinned Tabs',
disabled: true,
}],
[{
label: 'Refresh the Page',
}, {
label: 'Clear Cookies and Refresh',
}, {
label: 'Clear Cache and Refresh',
}, {
type: 'separator' as const,
}, {
label: 'Developer',
children: [[{
label: 'View Source',
kbds: ['option', 'meta', 'U'],
}, {
label: 'Developer Tools',
kbds: ['option', 'meta', 'I'],
}], [{
label: 'Inspect Elements',
kbds: ['option', 'meta', 'C'],
}], [{
label: 'JavaScript Console',
kbds: ['option', 'meta', 'J'],
slot: 'custom',
}]],
}],
]
const props = { portal: false, items }
it.each<[string, RenderOptions<typeof ContextMenu>]>([
// Props
['with items', { props }],
['with disabled', { props: { ...props, disabled: true } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
['with class', { props: { ...props, class: 'min-w-96' } }],
['with ui', { props: { ...props, ui: { itemLeadingIcon: 'size-4' } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with item slot', { props, slots: { item: () => 'Item slot' } }],
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
])(`renders %s correctly`, async (name, options) => {
const { html } = render(ContextMenuWrapper, options as any)
await fireEvent.contextMenu(screen.getByText('Right Click'))
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import ContextMenu from '@/UI/Components/ContextMenu.vue'
import theme from '@/UI/Theme/context-menu'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
const ContextMenuWrapper = defineComponent({
components: {
ContextMenu,
},
inertAttrs: false,
template: `<ContextMenu v-bind="$attrs">
<span>Right Click</span>
</ContextMenu>`,
})
describe('container', () => {
const sizes = Object.keys(theme.variants.size) as any
const items = [
[{
label: 'Appearance',
children: [{
label: 'System',
icon: 'i-lucide-monitor',
}, {
label: 'Light',
icon: 'i-lucide-sun',
}, {
label: 'Dark',
icon: 'i-lucide-moon',
}],
}],
[{
label: 'Show Sidebar',
color: 'primary',
kbds: ['meta', 'S'],
}, {
label: 'Show Toolbar',
kbds: ['shift', 'meta', 'D'],
}, {
label: 'Collapse Pinned Tabs',
disabled: true,
}],
[{
label: 'Refresh the Page',
}, {
label: 'Clear Cookies and Refresh',
}, {
label: 'Clear Cache and Refresh',
}, {
type: 'separator' as const,
}, {
label: 'Developer',
children: [[{
label: 'View Source',
kbds: ['option', 'meta', 'U'],
}, {
label: 'Developer Tools',
kbds: ['option', 'meta', 'I'],
}], [{
label: 'Inspect Elements',
kbds: ['option', 'meta', 'C'],
}], [{
label: 'JavaScript Console',
kbds: ['option', 'meta', 'J'],
slot: 'custom',
}]],
}],
]
const props = { portal: false, items }
it.each<[string, RenderOptions<typeof ContextMenu>]>([
// Props
['with items', { props }],
['with disabled', { props: { ...props, disabled: true } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
['with class', { props: { ...props, class: 'min-w-96' } }],
['with ui', { props: { ...props, ui: { itemLeadingIcon: 'size-4' } } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with item slot', { props, slots: { item: () => 'Item slot' } }],
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
])(`renders %s correctly`, async (name, options) => {
const { html } = render(ContextMenuWrapper, options as any)
await fireEvent.contextMenu(screen.getByText('Right Click'))
await new Promise(resolve => setTimeout(resolve, 0))
expect(html()).toMatchSnapshot()
})
})