mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
210 lines
5.1 KiB
210 lines
5.1 KiB
<script setup> |
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' |
|
import { useMotion } from '@vueuse/motion' |
|
|
|
const props = defineProps({ |
|
modelValue: { |
|
type: Boolean, |
|
default: false, |
|
}, |
|
placement: { |
|
type: String, |
|
default: 'center', |
|
validator: (value) => ['top', 'bottom', 'left', 'right', 'center'].includes(value), |
|
}, |
|
offset: { |
|
type: Array, |
|
default: () => [0, 10], |
|
}, |
|
width: { |
|
type: String, |
|
default: 'auto', |
|
}, |
|
zIndex: { |
|
type: Number, |
|
default: 1000, |
|
}, |
|
closeOnClickOutside: { |
|
type: Boolean, |
|
default: true, |
|
}, |
|
closeOnEsc: { |
|
type: Boolean, |
|
default: true, |
|
}, |
|
}) |
|
|
|
const emit = defineEmits(['update:modelValue']) |
|
|
|
const triggerRef = ref(null) |
|
const popoverRef = ref(null) |
|
|
|
const isOpen = useVModel(props, 'modelValue', emit) |
|
|
|
const popoverStyle = computed(() => ({ |
|
position: 'fixed', |
|
zIndex: props.zIndex, |
|
width: props.width, |
|
})) |
|
|
|
const { variant } = useMotion(popoverRef, { |
|
initial: { |
|
opacity: 0, |
|
scale: 0.1, |
|
}, |
|
enter: { |
|
opacity: 1, |
|
scale: 1, |
|
transition: { |
|
type: 'spring', |
|
stiffness: 300, |
|
damping: 20, |
|
}, |
|
}, |
|
leave: { |
|
opacity: 0, |
|
scale: 0.1, |
|
transition: { |
|
type: 'spring', |
|
stiffness: 300, |
|
damping: 20, |
|
}, |
|
}, |
|
}) |
|
|
|
const updatePopoverPosition = () => { |
|
if (!triggerRef.value || !popoverRef.value) return |
|
|
|
const triggerRect = triggerRef.value.getBoundingClientRect() |
|
const popoverRect = popoverRef.value.getBoundingClientRect() |
|
|
|
let top, left |
|
|
|
switch (props.placement) { |
|
case 'top': |
|
top = triggerRect.top - popoverRect.height - props.offset[1] |
|
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 + props.offset[0] |
|
break |
|
case 'bottom': |
|
top = triggerRect.bottom + props.offset[1] |
|
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 + props.offset[0] |
|
break |
|
case 'left': |
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 + props.offset[1] |
|
left = triggerRect.left - popoverRect.width - props.offset[0] |
|
break |
|
case 'right': |
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 + props.offset[1] |
|
left = triggerRect.right + props.offset[0] |
|
break |
|
case 'center': |
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 + props.offset[1] |
|
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 + props.offset[0] |
|
break |
|
} |
|
|
|
// Ensure the popover stays within the viewport |
|
const viewportWidth = window.innerWidth |
|
const viewportHeight = window.innerHeight |
|
|
|
top = Math.max(0, Math.min(top, viewportHeight - popoverRect.height)) |
|
left = Math.max(0, Math.min(left, viewportWidth - popoverRect.width)) |
|
|
|
Object.assign(popoverRef.value.style, { |
|
top: `${top}px`, |
|
left: `${left}px`, |
|
}) |
|
} |
|
|
|
const openPopover = () => { |
|
isOpen.value = true |
|
nextTick(() => { |
|
updatePopoverPosition() |
|
variant.value = 'enter' |
|
}) |
|
} |
|
|
|
const closePopover = () => { |
|
variant.value = 'leave' |
|
setTimeout(() => { |
|
isOpen.value = false |
|
}, 300) |
|
} |
|
|
|
const handleClickOutside = (event) => { |
|
if ( |
|
props.closeOnClickOutside && |
|
popoverRef.value && |
|
!popoverRef.value.contains(event.target) && |
|
!triggerRef.value.contains(event.target) |
|
) { |
|
closePopover() |
|
} |
|
} |
|
|
|
const handleKeyDown = (event) => { |
|
if (props.closeOnEsc && event.key === 'Escape') { |
|
closePopover() |
|
} |
|
} |
|
|
|
onMounted(() => { |
|
if (props.closeOnClickOutside) document.addEventListener('mousedown', handleClickOutside) |
|
if (props.closeOnEsc) document.addEventListener('keydown', handleKeyDown) |
|
window.addEventListener('resize', updatePopoverPosition) |
|
window.addEventListener('scroll', updatePopoverPosition) |
|
}) |
|
|
|
onUnmounted(() => { |
|
if (props.closeOnClickOutside) document.removeEventListener('mousedown', handleClickOutside) |
|
if (props.closeOnEsc) document.removeEventListener('keydown', handleKeyDown) |
|
window.removeEventListener('resize', updatePopoverPosition) |
|
window.removeEventListener('scroll', updatePopoverPosition) |
|
}) |
|
|
|
watch( |
|
() => props.modelValue, |
|
(newValue) => { |
|
isOpen.value = newValue |
|
if (newValue) { |
|
nextTick(() => { |
|
updatePopoverPosition() |
|
variant.value = 'enter' |
|
}) |
|
} else { |
|
variant.value = 'leave' |
|
} |
|
}, |
|
) |
|
</script> |
|
|
|
<template> |
|
<div> |
|
<div ref="triggerRef" @click="openPopover"> |
|
<slot name="trigger" :open="openPopover" :close="closePopover" :is-open="isOpen"> |
|
<button>Open Popover</button> |
|
</slot> |
|
</div> |
|
|
|
<Teleport to="body"> |
|
<div v-if="isOpen" ref="popoverRef" v-motion :style="popoverStyle" class="popover-content"> |
|
<slot name="content" :close="closePopover"> |
|
<div class="p-4"> |
|
<p>Default popover content</p> |
|
<button @click="closePopover">Close</button> |
|
</div> |
|
</slot> |
|
</div> |
|
</Teleport> |
|
</div> |
|
</template> |
|
|
|
<style scoped> |
|
.popover-content { |
|
background-color: white; |
|
border: 1px solid #ccc; |
|
border-radius: 4px; |
|
padding: 10px; |
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
|
} |
|
</style>
|
|
|