240 lines
5.5 KiB
Vue
240 lines
5.5 KiB
Vue
<script setup>
|
|
import { inject, ref, computed } from "vue";
|
|
|
|
defineOptions({ name: "PopoverContent" });
|
|
|
|
const props = defineProps({
|
|
side: {
|
|
type: String,
|
|
default: "bottom",
|
|
validator: (value) => ["top", "right", "bottom", "left"].includes(value),
|
|
},
|
|
align: {
|
|
type: String,
|
|
default: "center",
|
|
validator: (value) => ["start", "center", "end"].includes(value),
|
|
},
|
|
sideOffset: {
|
|
type: Number,
|
|
default: 4,
|
|
},
|
|
alignOffset: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
});
|
|
|
|
const { isOpen, position, triggerRef } = inject("popover");
|
|
const contentRef = ref(null);
|
|
|
|
const adjustedPosition = computed(() => {
|
|
if (!contentRef.value || !triggerRef.value) return position.value;
|
|
|
|
const content = contentRef.value;
|
|
const trigger = triggerRef.value;
|
|
const contentRect = content.getBoundingClientRect();
|
|
const triggerRect = trigger.getBoundingClientRect();
|
|
const windowHeight = window.innerHeight;
|
|
const windowWidth = window.innerWidth;
|
|
|
|
let { x, y } = position.value;
|
|
let adjustedSide = props.side;
|
|
|
|
// Check available space in all directions
|
|
const spaceAbove = triggerRect.top;
|
|
const spaceBelow = windowHeight - triggerRect.bottom;
|
|
const spaceLeft = triggerRect.left;
|
|
const spaceRight = windowWidth - triggerRect.right;
|
|
|
|
// Automatically switch sides if there's not enough space
|
|
if (
|
|
adjustedSide === "bottom" &&
|
|
spaceBelow < contentRect.height &&
|
|
spaceAbove > spaceBelow
|
|
) {
|
|
adjustedSide = "top";
|
|
} else if (
|
|
adjustedSide === "top" &&
|
|
spaceAbove < contentRect.height &&
|
|
spaceBelow > spaceAbove
|
|
) {
|
|
adjustedSide = "bottom";
|
|
} else if (
|
|
adjustedSide === "right" &&
|
|
spaceRight < contentRect.width &&
|
|
spaceLeft > spaceRight
|
|
) {
|
|
adjustedSide = "left";
|
|
} else if (
|
|
adjustedSide === "left" &&
|
|
spaceLeft < contentRect.width &&
|
|
spaceRight > spaceLeft
|
|
) {
|
|
adjustedSide = "right";
|
|
}
|
|
|
|
// Position based on adjusted side
|
|
switch (adjustedSide) {
|
|
case "top":
|
|
y = triggerRect.top - contentRect.height - props.sideOffset;
|
|
break;
|
|
case "right":
|
|
x = triggerRect.right + props.sideOffset;
|
|
break;
|
|
case "bottom":
|
|
y = triggerRect.bottom + props.sideOffset;
|
|
break;
|
|
case "left":
|
|
x = triggerRect.left - contentRect.width - props.sideOffset;
|
|
break;
|
|
}
|
|
|
|
// Adjust alignment
|
|
switch (props.align) {
|
|
case "start":
|
|
if (adjustedSide === "top" || adjustedSide === "bottom") {
|
|
x = triggerRect.left + props.alignOffset;
|
|
} else {
|
|
y = triggerRect.top + props.alignOffset;
|
|
}
|
|
break;
|
|
case "center":
|
|
if (adjustedSide === "top" || adjustedSide === "bottom") {
|
|
x =
|
|
triggerRect.left +
|
|
triggerRect.width / 2 -
|
|
contentRect.width / 2 +
|
|
props.alignOffset;
|
|
} else {
|
|
y =
|
|
triggerRect.top +
|
|
triggerRect.height / 2 -
|
|
contentRect.height / 2 +
|
|
props.alignOffset;
|
|
}
|
|
break;
|
|
case "end":
|
|
if (adjustedSide === "top" || adjustedSide === "bottom") {
|
|
x = triggerRect.right - contentRect.width + props.alignOffset;
|
|
} else {
|
|
y = triggerRect.bottom - contentRect.height + props.alignOffset;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Ensure the content stays within viewport bounds with padding
|
|
const VIEWPORT_PADDING = 8;
|
|
|
|
if (x + contentRect.width > windowWidth - VIEWPORT_PADDING) {
|
|
x = windowWidth - contentRect.width - VIEWPORT_PADDING;
|
|
}
|
|
if (x < VIEWPORT_PADDING) {
|
|
x = VIEWPORT_PADDING;
|
|
}
|
|
if (y + contentRect.height > windowHeight - VIEWPORT_PADDING) {
|
|
y = windowHeight - contentRect.height - VIEWPORT_PADDING;
|
|
}
|
|
if (y < VIEWPORT_PADDING) {
|
|
y = VIEWPORT_PADDING;
|
|
}
|
|
|
|
return { x, y, adjustedSide };
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Transition name="popover">
|
|
<div
|
|
v-if="isOpen"
|
|
ref="contentRef"
|
|
class="popover-content absolute z-50 min-w-[8rem] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in"
|
|
:class="{
|
|
'slide-in-from-top-2': adjustedPosition.adjustedSide === 'bottom',
|
|
'slide-in-from-right-2': adjustedPosition.adjustedSide === 'left',
|
|
'slide-in-from-left-2': adjustedPosition.adjustedSide === 'right',
|
|
'slide-in-from-bottom-2': adjustedPosition.adjustedSide === 'top',
|
|
}"
|
|
:style="{
|
|
position: 'fixed',
|
|
top: `${adjustedPosition.y}px`,
|
|
left: `${adjustedPosition.x}px`,
|
|
}"
|
|
>
|
|
<slot />
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.popover-enter-active,
|
|
.popover-leave-active {
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
}
|
|
|
|
.popover-enter-from,
|
|
.popover-leave-to {
|
|
opacity: 0;
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.slide-in-from-top-2 {
|
|
animation: slideInFromTop 0.2s ease;
|
|
}
|
|
|
|
.slide-in-from-right-2 {
|
|
animation: slideInFromRight 0.2s ease;
|
|
}
|
|
|
|
.slide-in-from-bottom-2 {
|
|
animation: slideInFromBottom 0.2s ease;
|
|
}
|
|
|
|
.slide-in-from-left-2 {
|
|
animation: slideInFromLeft 0.2s ease;
|
|
}
|
|
|
|
@keyframes slideInFromTop {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-0.5rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
@keyframes slideInFromRight {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(0.5rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
@keyframes slideInFromBottom {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(0.5rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
@keyframes slideInFromLeft {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(-0.5rem);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
</style>
|