Compare commits

...

2 Commits

Author SHA1 Message Date
Benjamin Lu
31cb321d0d fix: prune unused shadcn menu components 2026-03-23 17:56:15 -07:00
Benjamin Lu
97c8be9ce9 feat: add shadcn menu ui components 2026-03-23 17:40:36 -07:00
12 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import ContextMenuContent from './ContextMenuContent.vue'
import ContextMenuItem from './ContextMenuItem.vue'
import ContextMenuSeparator from './ContextMenuSeparator.vue'
import ContextMenuTrigger from './ContextMenuTrigger.vue'
const TestContextMenu = defineComponent({
components: {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator
},
setup() {
const open = ref(false)
return { open }
},
template: `
<ContextMenu v-model:open="open" :modal="false">
<ContextMenuTrigger as-child>
<button type="button">Trigger</button>
</ContextMenuTrigger>
<ContextMenuContent close-on-scroll>
<ContextMenuItem text-value="First item">First item</ContextMenuItem>
<ContextMenuSeparator />
</ContextMenuContent>
</ContextMenu>
`
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('closes the content on scroll when close-on-scroll is enabled', async () => {
const wrapper = mount(TestContextMenu, {
attachTo: document.body
})
await wrapper.find('button').trigger('contextmenu')
await nextTick()
expect(wrapper.vm.open).toBe(true)
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(wrapper.vm.open).toBe(false)
})
})

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot v-bind="forwarded">
<slot />
</ContextMenuRoot>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit, useEventListener } from '@vueuse/core'
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import {
ContextMenuContent,
ContextMenuPortal,
injectContextMenuRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<
ContextMenuContentProps & {
class?: HTMLAttributes['class']
closeOnScroll?: boolean
}
>(),
{
closeOnScroll: false
}
)
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const rootContext = injectContextMenuRootContext()
useEventListener(
window,
'scroll',
() => {
if (props.closeOnScroll) {
rootContext.onOpenChange(false)
}
},
{ capture: true, passive: true }
)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
props.class
)
"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
ContextMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
props.class
)
"
>
<slot />
</ContextMenuItem>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuSeparator } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ContextMenuSeparator
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuTriggerProps } from 'reka-ui'
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<ContextMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<ContextMenuTrigger v-bind="forwardedProps">
<slot />
</ContextMenuTrigger>
</template>

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import DropdownMenu from './DropdownMenu.vue'
import DropdownMenuContent from './DropdownMenuContent.vue'
import DropdownMenuItem from './DropdownMenuItem.vue'
import DropdownMenuSeparator from './DropdownMenuSeparator.vue'
import DropdownMenuTrigger from './DropdownMenuTrigger.vue'
const TestDropdownMenu = defineComponent({
components: {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator
},
setup() {
const open = ref(false)
return { open }
},
template: `
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<button type="button">Trigger</button>
</DropdownMenuTrigger>
<DropdownMenuContent close-on-scroll>
<DropdownMenuItem text-value="First item">First item</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
`
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('DropdownMenu', () => {
it('closes the content on scroll when close-on-scroll is enabled', async () => {
const wrapper = mount(TestDropdownMenu, {
attachTo: document.body
})
await wrapper.find('button').trigger('click')
await nextTick()
expect(wrapper.vm.open).toBe(true)
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(wrapper.vm.open).toBe(false)
})
})

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit, useEventListener } from '@vueuse/core'
import type {
DropdownMenuContentEmits,
DropdownMenuContentProps
} from 'reka-ui'
import {
DropdownMenuContent,
DropdownMenuPortal,
injectDropdownMenuRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<
DropdownMenuContentProps & {
class?: HTMLAttributes['class']
closeOnScroll?: boolean
}
>(),
{
closeOnScroll: false,
sideOffset: 4
}
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const rootContext = injectDropdownMenuRootContext()
useEventListener(
window,
'scroll',
() => {
if (props.closeOnScroll) {
rootContext.onOpenChange(false)
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
props.class
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
DropdownMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
props.class
)
"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuSeparator } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DropdownMenuSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>