UIPackage

Popover

Vue overlay
Edit on GitHub

Click-triggered floating panel anchored to a trigger element. Supports optional localStorage persistence and configurable dismissal (click-outside, escape, manual). Built on reka-ui with collision detection.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/popover.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/popover

Examples

Props

Name Type / Values Default Required
persist string | boolean false optional
closeBehavior PopoverCloseBehavior 'auto' optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

PopoverContext
interface PopoverContext {
  closeBehavior: Ref<PopoverCloseBehavior>
}

Dependencies

Used by

Files (6)

  • app/components/ui/popover/Popover.vue 1.8 kB
    <script setup lang="ts">
    import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
    import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
    import { computed, onMounted, provide, ref, toRef, useId, watch } from 'vue'
    import { POPOVER_INJECTION_KEY, type PopoverCloseBehavior } from './context'
    
    const props = withDefaults(
      defineProps<
        PopoverRootProps & {
          persist?: string | boolean
          closeBehavior?: PopoverCloseBehavior
        }
      >(),
      {
        persist: false,
        closeBehavior: 'auto',
      },
    )
    
    const emits = defineEmits<PopoverRootEmits>()
    
    provide(POPOVER_INJECTION_KEY, {
      closeBehavior: toRef(props, 'closeBehavior'),
    })
    
    const autoId = useId()
    const storageKey = computed(() => {
      if (props.persist === false) return null
      if (props.persist === true) return `uipkge-popover-${autoId}`
      return props.persist
    })
    
    const localOpen = ref<boolean>(props.defaultOpen ?? false)
    
    onMounted(() => {
      if (!storageKey.value || typeof localStorage === 'undefined') return
      if (localStorage.getItem(storageKey.value) === '1') {
        localOpen.value = true
        if (props.open === undefined) {
          emits('update:open', true)
        }
      }
    })
    
    watch(
      () => (props.open === undefined ? localOpen.value : props.open),
      (v) => {
        if (!storageKey.value || typeof localStorage === 'undefined') return
        if (v) localStorage.setItem(storageKey.value, '1')
        else localStorage.removeItem(storageKey.value)
      },
    )
    
    const forwarded = useForwardPropsEmits(
      computed(() => ({
        open: props.open,
        defaultOpen: props.defaultOpen,
        modal: props.modal,
      })),
      emits,
    )
    
    function onUpdateOpen(value: boolean) {
      localOpen.value = value
      emits('update:open', value)
    }
    </script>
    
    <template>
      <PopoverRoot v-slot="slotProps" data-uipkge data-slot="popover" v-bind="forwarded" @update:open="onUpdateOpen">
        <slot v-bind="slotProps" />
      </PopoverRoot>
    </template>
  • app/components/ui/popover/PopoverAnchor.vue 0.3 kB
    <script setup lang="ts">
    import type { PopoverAnchorProps } from 'reka-ui'
    import { PopoverAnchor } from 'reka-ui'
    
    const props = defineProps<PopoverAnchorProps>()
    </script>
    
    <template>
      <PopoverAnchor data-uipkge data-slot="popover-anchor" v-bind="props">
        <slot />
      </PopoverAnchor>
    </template>
  • app/components/ui/popover/PopoverContent.vue 2.1 kB
    <script setup lang="ts">
    import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
    import { inject } from 'vue'
    import { cn } from '@/lib/utils'
    import { POPOVER_INJECTION_KEY } from './context'
    
    defineOptions({
      inheritAttrs: false,
    })
    
    const props = withDefaults(defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(), {
      align: 'center',
      sideOffset: 4,
    })
    const emits = defineEmits<PopoverContentEmits>()
    
    const ctx = inject(POPOVER_INJECTION_KEY, null)
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    function onPointerDownOutside(e: Event) {
      const mode = ctx?.closeBehavior.value ?? 'auto'
      if (mode === 'esc' || mode === 'manual' || mode === 'none') {
        e.preventDefault()
      }
    }
    
    function onEscapeKeyDown(e: KeyboardEvent) {
      const mode = ctx?.closeBehavior.value ?? 'auto'
      if (mode === 'click-outside' || mode === 'manual' || mode === 'none') {
        e.preventDefault()
      }
    }
    </script>
    
    <template>
      <PopoverPortal>
        <PopoverContent
          data-uipkge
          data-slot="popover-content"
          v-bind="{ ...$attrs, ...forwarded }"
          :class="
            cn(
              'bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--reka-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
              props.class,
            )
          "
          @pointer-down-outside="onPointerDownOutside"
          @escape-key-down="onEscapeKeyDown"
        >
          <slot />
        </PopoverContent>
      </PopoverPortal>
    </template>
  • app/components/ui/popover/PopoverTrigger.vue 0.3 kB
    <script setup lang="ts">
    import type { PopoverTriggerProps } from 'reka-ui'
    import { PopoverTrigger } from 'reka-ui'
    
    const props = defineProps<PopoverTriggerProps>()
    </script>
    
    <template>
      <PopoverTrigger data-uipkge data-slot="popover-trigger" v-bind="props">
        <slot />
      </PopoverTrigger>
    </template>
  • app/components/ui/popover/context.ts 0.3 kB
    import type { InjectionKey, Ref } from 'vue'
    
    export type PopoverCloseBehavior = 'auto' | 'click-outside' | 'esc' | 'manual' | 'none'
    
    export interface PopoverContext {
      closeBehavior: Ref<PopoverCloseBehavior>
    }
    
    export const POPOVER_INJECTION_KEY: InjectionKey<PopoverContext> = Symbol('uipkge-popover')
  • app/components/ui/popover/index.ts 0.2 kB
    export { default as Popover } from './Popover.vue'
    export { default as PopoverAnchor } from './PopoverAnchor.vue'
    export { default as PopoverContent } from './PopoverContent.vue'
    export { default as PopoverTrigger } from './PopoverTrigger.vue'

Raw manifest: https://uipkge.dev/r/vue/popover.json