140 lines
3.5 KiB
Vue
140 lines
3.5 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref } from "vue";
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
text: string;
|
|
disabled?: boolean;
|
|
side?: "top" | "right" | "bottom" | "left";
|
|
sideOffset?: number;
|
|
delay?: number;
|
|
}>(),
|
|
{
|
|
disabled: false,
|
|
side: "top",
|
|
sideOffset: 8,
|
|
delay: 300,
|
|
},
|
|
);
|
|
|
|
const triggerRef = ref<HTMLElement>();
|
|
const show = ref(false);
|
|
const x = ref(0);
|
|
const y = ref(0);
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function triggerElement(): HTMLElement | undefined {
|
|
const root = triggerRef.value;
|
|
const child = root?.firstElementChild;
|
|
return child instanceof HTMLElement ? child : root;
|
|
}
|
|
|
|
const tooltipTransformClass = computed(() => {
|
|
switch (props.side) {
|
|
case "right":
|
|
return "-translate-y-1/2";
|
|
case "left":
|
|
return "-translate-x-full -translate-y-1/2";
|
|
case "bottom":
|
|
return "-translate-x-1/2";
|
|
case "top":
|
|
default:
|
|
return "-translate-x-1/2 -translate-y-full";
|
|
}
|
|
});
|
|
|
|
const arrowClass = computed(() => {
|
|
switch (props.side) {
|
|
case "right":
|
|
return "absolute -left-1 top-1/2 -translate-y-1/2";
|
|
case "left":
|
|
return "absolute -right-1 top-1/2 -translate-y-1/2";
|
|
case "bottom":
|
|
return "absolute -top-1 left-1/2 -translate-x-1/2";
|
|
case "top":
|
|
default:
|
|
return "absolute -bottom-1 left-1/2 -translate-x-1/2";
|
|
}
|
|
});
|
|
|
|
function clearTimer() {
|
|
if (!timer) return;
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
|
|
function updatePosition() {
|
|
const el = triggerElement();
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const offset = props.sideOffset;
|
|
switch (props.side) {
|
|
case "right":
|
|
x.value = Math.min(window.innerWidth - 8, rect.right + offset);
|
|
y.value = Math.min(Math.max(8, rect.top + rect.height / 2), window.innerHeight - 8);
|
|
break;
|
|
case "left":
|
|
x.value = Math.max(8, rect.left - offset);
|
|
y.value = Math.min(Math.max(8, rect.top + rect.height / 2), window.innerHeight - 8);
|
|
break;
|
|
case "bottom":
|
|
x.value = Math.min(Math.max(8, rect.left + rect.width / 2), window.innerWidth - 8);
|
|
y.value = Math.min(window.innerHeight - 8, rect.bottom + offset);
|
|
break;
|
|
case "top":
|
|
default:
|
|
x.value = Math.min(Math.max(8, rect.left + rect.width / 2), window.innerWidth - 8);
|
|
y.value = Math.max(8, rect.top - offset);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
clearTimer();
|
|
show.value = false;
|
|
window.removeEventListener("scroll", close, true);
|
|
window.removeEventListener("resize", close);
|
|
}
|
|
|
|
function open() {
|
|
if (props.disabled || !props.text) return;
|
|
updatePosition();
|
|
show.value = true;
|
|
window.addEventListener("scroll", close, true);
|
|
window.addEventListener("resize", close);
|
|
}
|
|
|
|
function scheduleOpen() {
|
|
if (props.disabled || !props.text) return;
|
|
clearTimer();
|
|
timer = setTimeout(open, props.delay);
|
|
}
|
|
|
|
onBeforeUnmount(close);
|
|
</script>
|
|
|
|
<template>
|
|
<span
|
|
ref="triggerRef"
|
|
class="contents"
|
|
@mouseenter="scheduleOpen"
|
|
@mouseleave="close"
|
|
@focusin="scheduleOpen"
|
|
@focusout="close"
|
|
>
|
|
<slot />
|
|
</span>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="show"
|
|
class="pointer-events-none fixed z-50 inline-flex w-fit max-w-xs items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background"
|
|
:class="tooltipTransformClass"
|
|
:style="{ left: `${x}px`, top: `${y}px` }"
|
|
role="tooltip"
|
|
>
|
|
{{ text }}
|
|
<span :class="[arrowClass, 'size-2.5 rotate-45 rounded-[2px] bg-foreground']" aria-hidden="true" />
|
|
</div>
|
|
</Teleport>
|
|
</template>
|