diff --git a/playground/app.vue b/playground/app.vue
index 8da7677b..0c606a8a 100644
--- a/playground/app.vue
+++ b/playground/app.vue
@@ -20,6 +20,7 @@ const components = [
'checkbox',
'chip',
'collapsible',
+ 'context-menu',
'drawer',
'dropdown-menu',
'form',
diff --git a/playground/pages/context-menu.vue b/playground/pages/context-menu.vue
new file mode 100644
index 00000000..de240517
--- /dev/null
+++ b/playground/pages/context-menu.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+ Right click here
+
+
+
diff --git a/src/runtime/components/ContextMenu.vue b/src/runtime/components/ContextMenu.vue
new file mode 100644
index 00000000..8494683a
--- /dev/null
+++ b/src/runtime/components/ContextMenu.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/runtime/components/ContextMenuContent.vue b/src/runtime/components/ContextMenuContent.vue
new file mode 100644
index 00000000..870f20a2
--- /dev/null
+++ b/src/runtime/components/ContextMenuContent.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/runtime/types/index.d.ts b/src/runtime/types/index.d.ts
index a96aaef7..3d9e9906 100644
--- a/src/runtime/types/index.d.ts
+++ b/src/runtime/types/index.d.ts
@@ -1,6 +1,6 @@
-export * from '../components/App.vue'
export * from '../components/Accordion.vue'
export * from '../components/Alert.vue'
+export * from '../components/App.vue'
export * from '../components/Avatar.vue'
export * from '../components/Badge.vue'
export * from '../components/Breadcrumb.vue'
@@ -10,6 +10,7 @@ export * from '../components/Checkbox.vue'
export * from '../components/Chip.vue'
export * from '../components/Collapsible.vue'
export * from '../components/Container.vue'
+export * from '../components/ContextMenu.vue'
export * from '../components/Drawer.vue'
export * from '../components/DropdownMenu.vue'
export * from '../components/Form.vue'
diff --git a/src/theme/context-menu.ts b/src/theme/context-menu.ts
new file mode 100644
index 00000000..71147ac3
--- /dev/null
+++ b/src/theme/context-menu.ts
@@ -0,0 +1,28 @@
+export default {
+ slots: {
+ content: 'min-w-32 bg-white dark:bg-gray-900 shadow-lg rounded-md ring ring-gray-200 dark:ring-gray-800 divide-y divide-gray-200 dark:divide-gray-800 overflow-y-auto scroll-py-1 data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in]',
+ arrow: 'fill-gray-200 dark:fill-gray-800',
+ group: 'p-1 isolate',
+ label: 'w-full flex items-center gap-1.5 p-1.5 text-sm font-medium select-none',
+ separator: '-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-800',
+ link: 'group relative w-full flex items-center gap-1.5 p-1.5 text-sm select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75',
+ linkLeadingIcon: 'shrink-0 size-5',
+ linkLeadingAvatar: 'shrink-0',
+ linkTrailing: 'ms-auto inline-flex',
+ linkTrailingIcon: 'shrink-0 size-5',
+ linkTrailingKbds: 'hidden lg:inline-flex items-center shrink-0 gap-0.5',
+ linkLabel: 'truncate'
+ },
+ variants: {
+ active: {
+ true: {
+ link: 'text-gray-900 dark:text-white before:bg-gray-100 dark:before:bg-gray-800',
+ linkLeadingIcon: 'text-gray-700 dark:text-gray-200'
+ },
+ false: {
+ link: 'text-gray-700 dark:text-gray-200 data-highlighted:text-gray-900 dark:data-highlighted:text-white data-[state=open]:text-gray-900 dark:data-[state=open]:text-white data-highlighted:before:bg-gray-50 dark:data-highlighted:before:bg-gray-800/50 data-[state=open]:before:bg-gray-50 dark:data-[state=open]:before:bg-gray-800/50',
+ linkLeadingIcon: 'text-gray-400 dark:text-gray-500 group-data-highlighted:text-gray-700 dark:group-data-highlighted:text-gray-200 group-data-[state=open]:text-gray-700 dark:group-data-[state=open]:text-gray-200'
+ }
+ }
+ }
+}
diff --git a/src/theme/index.ts b/src/theme/index.ts
index fe744570..066a256e 100644
--- a/src/theme/index.ts
+++ b/src/theme/index.ts
@@ -9,6 +9,7 @@ export { default as checkbox } from './checkbox'
export { default as chip } from './chip'
export { default as collapsible } from './collapsible'
export { default as container } from './container'
+export { default as contextMenu } from './context-menu'
export { default as drawer } from './drawer'
export { default as dropdownMenu } from './dropdown-menu'
export { default as form } from './form'
diff --git a/test/components/ContextMenu.spec.ts b/test/components/ContextMenu.spec.ts
new file mode 100644
index 00000000..ca440a5c
--- /dev/null
+++ b/test/components/ContextMenu.spec.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from 'vitest'
+import ContextMenu, { type ContextMenuProps, type ContextMenuSlots } from '../../src/runtime/components/ContextMenu.vue'
+import ComponentRender from '../component-render'
+
+// FIXME: Can't force open state
+describe('ContextMenu', () => {
+ const items = [
+ [{
+ label: 'Appearance',
+ children: [{
+ label: 'System',
+ icon: 'i-heroicons-computer-desktop'
+ }, {
+ label: 'Light',
+ icon: 'i-heroicons-sun'
+ }, {
+ label: 'Dark',
+ icon: 'i-heroicons-moon'
+ }]
+ }],
+ [{
+ label: 'Show Sidebar',
+ kbds: ['meta', 'S'],
+ select() {
+ console.log('Show Sidebar clicked')
+ }
+ }, {
+ label: 'Show Toolbar',
+ kbds: ['shift', 'meta', 'D'],
+ select() {
+ console.log('Show Toolbar clicked')
+ }
+ }, {
+ 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'],
+ select() {
+ console.log('View Source clicked')
+ }
+ }, {
+ label: 'Developer Tools',
+ kbds: ['option', 'meta', 'I'],
+ select() {
+ console.log('Developer Tools clicked')
+ }
+ }], [{
+ label: 'Inspect Elements',
+ kbds: ['option', 'meta', 'C'],
+ select() {
+ console.log('Inspect Elements clicked')
+ }
+ }], [{
+ label: 'JavaScript Console',
+ kbds: ['option', 'meta', 'J'],
+ slot: 'custom',
+ select() {
+ console.log('JavaScript Console clicked')
+ }
+ }]]
+ }]
+ ]
+
+ const props = { portal: false, items }
+
+ it.each([
+ // Props
+ ['with items', { props }],
+ ['with disabled', { props: { ...props, disabled: true } }],
+ ['with class', { props: { ...props, class: 'min-w-96' } }],
+ ['with ui', { props: { ...props, ui: { linkLeadingIcon: 'size-4' } } }],
+ // Slots
+ ['with default slot', { props, slots: { default: () => 'Default slot' } }],
+ ['with leading slot', { props, slots: { leading: () => 'Leading slot' } }],
+ ['with label slot', { props, slots: { label: () => 'Label slot' } }],
+ ['with trailing slot', { props, slots: { trailing: () => 'Trailing slot' } }],
+ ['with item slot', { props, slots: { item: () => 'Item slot' } }],
+ ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }]
+ ])('renders %s correctly', async (nameOrHtml: string, options: { props?: ContextMenuProps, slots?: Partial> }) => {
+ const html = await ComponentRender(nameOrHtml, options, ContextMenu)
+ expect(html).toMatchSnapshot()
+ })
+})
diff --git a/test/components/__snapshots__/ContextMenu.spec.ts.snap b/test/components/__snapshots__/ContextMenu.spec.ts.snap
new file mode 100644
index 00000000..6ea46cc6
--- /dev/null
+++ b/test/components/__snapshots__/ContextMenu.spec.ts.snap
@@ -0,0 +1,131 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ContextMenu > renders with class correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with custom slot correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with default slot correctly 1`] = `
+"Default slot
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with disabled correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with item slot correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with items correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with label slot correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with leading slot correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with trailing slot correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`ContextMenu > renders with ui correctly 1`] = `
+"
+
+
+
+
+
+
+
+
+"
+`;