Popover
A non-modal dialog that floats around a trigger element.
Demo
Related Composables
This requires the following composables 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
<script lang="ts">
import type { ComponentConfig } from '@/ui/utils/utils'
import type { HoverCardRootProps, PopoverArrowProps, PopoverContentEmits, PopoverContentProps, PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import { usePortal } from '@/ui/composables/usePortal'
import theme from '@/ui/theme/popover'
import { reactivePick } from '@vueuse/shared'
import defu from 'defu'
import { useForwardPropsEmits } from 'reka-ui'
import { HoverCard, Popover as RekaPopover } from 'reka-ui/namespaced'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type Popover = ComponentConfig<typeof theme>
export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps, 'openDelay' | 'closeDelay'> {
mode?: 'click' | 'hover'
content?: Omit<PopoverContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<PopoverContentEmits>>
arrow?: boolean | Omit<PopoverArrowProps, 'as' | 'asChild'>
portal?: boolean | string | HTMLElement
dismissible?: boolean
class?: any
ui?: Popover['slots']
}
export interface PopoverEmits extends PopoverRootEmits {}
export interface PopoverSlots {
default: (props: { open: boolean }) => any
content: (props?: object) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<PopoverProps>(), {
portal: true,
mode: 'click',
openDelay: 0,
closeDelay: 0,
dismissible: true,
})
const emits = defineEmits<PopoverEmits>()
const slots = defineSlots<PopoverSlots>()
const pick = props.mode === 'hover' ? reactivePick(props, 'defaultOpen', 'open', 'openDelay', 'closeDelay') : reactivePick(props, 'defaultOpen', 'open', 'modal')
const rootProps = useForwardPropsEmits(pick, emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as PopoverContentProps)
const contentEvents = computed(() => {
if (!props.dismissible) {
return {
pointerDownOutside: (e: Event) => e.preventDefault(),
interactOutside: (e: Event) => e.preventDefault(),
escapeKeyDown: (e: Event) => e.preventDefault(),
}
}
return {}
})
const arrowProps = toRef(() => props.arrow as PopoverArrowProps)
const ui = computed(() => tv(theme)({
side: contentProps.value.side,
}))
const Component = computed(() => props.mode === 'hover' ? HoverCard : RekaPopover)
</script>
<template>
<Component.Root v-slot="{ open }" v-bind="rootProps">
<Component.Trigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" />
</Component.Trigger>
<Component.Portal v-bind="portalProps">
<Component.Content v-bind="contentProps" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-on="contentEvents">
<slot name="content" />
<Component.Arrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
</Component.Content>
</Component.Portal>
</Component.Root>
</template>vue
<script lang="ts">
import type { ComponentConfig } from '@/UI/Utils/utils'
import type { HoverCardRootProps, PopoverArrowProps, PopoverContentEmits, PopoverContentProps, PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import { usePortal } from '@/UI/Composables/usePortal'
import theme from '@/UI/Theme/popover'
import { reactivePick } from '@vueuse/shared'
import defu from 'defu'
import { useForwardPropsEmits } from 'reka-ui'
import { HoverCard, Popover as RekaPopover } from 'reka-ui/namespaced'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'
type Popover = ComponentConfig<typeof theme>
export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps, 'openDelay' | 'closeDelay'> {
mode?: 'click' | 'hover'
content?: Omit<PopoverContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<PopoverContentEmits>>
arrow?: boolean | Omit<PopoverArrowProps, 'as' | 'asChild'>
portal?: boolean | string | HTMLElement
dismissible?: boolean
class?: any
ui?: Popover['slots']
}
export interface PopoverEmits extends PopoverRootEmits {}
export interface PopoverSlots {
default: (props: { open: boolean }) => any
content: (props?: object) => any
}
</script>
<script setup lang="ts">
const props = withDefaults(defineProps<PopoverProps>(), {
portal: true,
mode: 'click',
openDelay: 0,
closeDelay: 0,
dismissible: true,
})
const emits = defineEmits<PopoverEmits>()
const slots = defineSlots<PopoverSlots>()
const pick = props.mode === 'hover' ? reactivePick(props, 'defaultOpen', 'open', 'openDelay', 'closeDelay') : reactivePick(props, 'defaultOpen', 'open', 'modal')
const rootProps = useForwardPropsEmits(pick, emits)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as PopoverContentProps)
const contentEvents = computed(() => {
if (!props.dismissible) {
return {
pointerDownOutside: (e: Event) => e.preventDefault(),
interactOutside: (e: Event) => e.preventDefault(),
escapeKeyDown: (e: Event) => e.preventDefault(),
}
}
return {}
})
const arrowProps = toRef(() => props.arrow as PopoverArrowProps)
const ui = computed(() => tv(theme)({
side: contentProps.value.side,
}))
const Component = computed(() => props.mode === 'hover' ? HoverCard : RekaPopover)
</script>
<template>
<Component.Root v-slot="{ open }" v-bind="rootProps">
<Component.Trigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" />
</Component.Trigger>
<Component.Portal v-bind="portalProps">
<Component.Content v-bind="contentProps" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-on="contentEvents">
<slot name="content" />
<Component.Arrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
</Component.Content>
</Component.Portal>
</Component.Root>
</template>Theme
ts
export default {
slots: {
content: '',
arrow: '',
},
}View Nuxt UI theme
ts
export default {
slots: {
content: 'bg-default shadow-lg rounded-md ring ring-default data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in] origin-(--reka-popover-content-transform-origin) focus:outline-none pointer-events-auto',
arrow: 'fill-default',
},
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import Button from '@/ui/components/Button.vue'
import Popover from '@/ui/components/Popover.vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { h } from 'vue'
describe('button', () => {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const props = { portal: false, open: true }
it.each<RenderOptions<typeof Popover>[]>([
// Props
[{ props: { ...props } }],
[{ props: { ...props, class: 'custom-class' } }],
[{ props: { ...props, ui: { content: 'custom-content' } } }],
[{ props: { ...props, arrow: true } }],
])('should have the correct structure on click (%s)', async (options) => {
const { html } = render(Popover, {
slots: {
default: () => h(Button, { label: 'Click Me' }),
content: () => 'Popover Content',
},
...options,
})
await fireEvent.click(screen.getByText('Click Me'))
expect(html()).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import Button from '@/UI/Components/Button.vue'
import Popover from '@/UI/Components/Popover.vue'
import { fireEvent, render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { h } from 'vue'
describe('button', () => {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const props = { portal: false, open: true }
it.each<RenderOptions<typeof Popover>[]>([
// Props
[{ props: { ...props } }],
[{ props: { ...props, class: 'custom-class' } }],
[{ props: { ...props, ui: { content: 'custom-content' } } }],
[{ props: { ...props, arrow: true } }],
])('should have the correct structure on click (%s)', async (options) => {
const { html } = render(Popover, {
slots: {
default: () => h(Button, { label: 'Click Me' }),
content: () => 'Popover Content',
},
...options,
})
await fireEvent.click(screen.getByText('Click Me'))
expect(html()).toMatchSnapshot()
})
})