295 lines
8.7 KiB
Vue
295 lines
8.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
|
import { cn } from "@/lib/common/utils";
|
|
import { floatingArrowOffset, floatingViewportShift } from "@/lib/common/floatingViewportPosition";
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
text: string;
|
|
disabled?: boolean | (() => boolean);
|
|
side?: "top" | "right" | "bottom" | "left";
|
|
sideOffset?: number;
|
|
delay?: number;
|
|
closeDelay?: number;
|
|
openOnFocus?: boolean;
|
|
nowrap?: boolean;
|
|
surface?: "foreground" | "popover";
|
|
contentClass?: string;
|
|
}>(),
|
|
{
|
|
disabled: false,
|
|
side: "top",
|
|
sideOffset: 8,
|
|
delay: 300,
|
|
closeDelay: 100,
|
|
openOnFocus: true,
|
|
nowrap: false,
|
|
surface: "foreground",
|
|
},
|
|
);
|
|
|
|
const triggerRef = ref<HTMLElement>();
|
|
const tooltipRef = ref<HTMLElement>();
|
|
const show = ref(false);
|
|
const x = ref(0);
|
|
const y = ref(0);
|
|
const arrowOffset = ref<number>();
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let suppressOpenUntil = 0;
|
|
let openSource: "hover" | "focus" | null = null;
|
|
|
|
function clearCloseTimer() {
|
|
if (!closeTimer) return;
|
|
clearTimeout(closeTimer);
|
|
closeTimer = 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.25 top-1/2 -translate-y-1/2 border-b border-l";
|
|
case "left":
|
|
return "absolute -right-1.25 top-1/2 -translate-y-1/2 border-t border-r";
|
|
case "bottom":
|
|
return "absolute -top-1.25 left-1/2 -translate-x-1/2 border-t border-l";
|
|
case "top":
|
|
default:
|
|
return "absolute -bottom-1.25 left-1/2 -translate-x-1/2 border-b border-r";
|
|
}
|
|
});
|
|
|
|
const tooltipSurfaceClass = computed(() => (props.surface === "popover" ? "bg-popover text-popover-foreground" : "bg-foreground text-background"));
|
|
|
|
const arrowSurfaceClass = computed(() => (props.surface === "popover" ? "bg-popover border-border" : "bg-foreground border-foreground"));
|
|
|
|
const arrowStyle = computed(() => {
|
|
if (arrowOffset.value === undefined) return undefined;
|
|
return props.side === "left" || props.side === "right" ? { top: `${arrowOffset.value}px` } : { left: `${arrowOffset.value}px` };
|
|
});
|
|
|
|
function clearTimer() {
|
|
if (!timer) return;
|
|
clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
|
|
function isPointerActive(): boolean {
|
|
const el = triggerElement();
|
|
if (!el || !el.isConnected) return false;
|
|
return el.matches(":hover") || tooltipRef.value?.matches(":hover") || false;
|
|
}
|
|
|
|
function isFocusActive(): boolean {
|
|
const el = triggerElement();
|
|
const active = document.activeElement;
|
|
return !!el && active instanceof Node && el.contains(active);
|
|
}
|
|
|
|
function hasFocusVisible(): boolean {
|
|
const el = triggerElement();
|
|
const root = triggerRef.value;
|
|
if (!el || !root) return false;
|
|
try {
|
|
return el.matches(":focus-visible") || !!root.querySelector(":focus-visible");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isOpenSourceActive(): boolean {
|
|
return openSource === "focus" ? isFocusActive() : isPointerActive();
|
|
}
|
|
|
|
function isDisabled(): boolean {
|
|
return typeof props.disabled === "function" ? props.disabled() : props.disabled;
|
|
}
|
|
|
|
function updatePosition() {
|
|
const el = triggerElement();
|
|
if (!el) return;
|
|
arrowOffset.value = undefined;
|
|
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 fitPositionToViewport() {
|
|
const tooltip = tooltipRef.value;
|
|
if (!tooltip) return;
|
|
const rect = tooltip.getBoundingClientRect();
|
|
const shift = floatingViewportShift(rect, { width: window.innerWidth, height: window.innerHeight });
|
|
x.value += shift.x;
|
|
y.value += shift.y;
|
|
arrowOffset.value = props.side === "left" || props.side === "right" ? floatingArrowOffset(rect.height, shift.y) : floatingArrowOffset(rect.width, shift.x);
|
|
}
|
|
|
|
function close() {
|
|
clearTimer();
|
|
clearCloseTimer();
|
|
show.value = false;
|
|
openSource = null;
|
|
removeGlobalListeners();
|
|
}
|
|
|
|
function suppressForContextMenu() {
|
|
suppressOpenUntil = Date.now() + 250;
|
|
close();
|
|
}
|
|
|
|
function closeIfTriggerInactive() {
|
|
if (isOpenSourceActive()) {
|
|
clearCloseTimer();
|
|
} else {
|
|
scheduleClose();
|
|
}
|
|
}
|
|
|
|
function scheduleClose() {
|
|
if (props.closeDelay <= 0) {
|
|
if (!isOpenSourceActive()) close();
|
|
return;
|
|
}
|
|
if (closeTimer) return;
|
|
closeTimer = setTimeout(() => {
|
|
closeTimer = null;
|
|
if (!isOpenSourceActive()) close();
|
|
}, props.closeDelay);
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
if (tooltipRef.value?.contains(e.target as Node)) return;
|
|
close();
|
|
}
|
|
|
|
function onScroll(e: Event) {
|
|
const target = e.target;
|
|
if (target instanceof Node && tooltipRef.value?.contains(target)) {
|
|
clearCloseTimer();
|
|
return;
|
|
}
|
|
close();
|
|
}
|
|
|
|
function addGlobalListeners() {
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", close);
|
|
window.addEventListener("blur", close);
|
|
document.addEventListener("visibilitychange", close);
|
|
document.addEventListener("pointermove", closeIfTriggerInactive, true);
|
|
document.addEventListener("pointerdown", onPointerDown, true);
|
|
document.addEventListener("contextmenu", close, true);
|
|
}
|
|
|
|
function removeGlobalListeners() {
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", close);
|
|
window.removeEventListener("blur", close);
|
|
document.removeEventListener("visibilitychange", close);
|
|
document.removeEventListener("pointermove", closeIfTriggerInactive, true);
|
|
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
document.removeEventListener("contextmenu", close, true);
|
|
}
|
|
|
|
const slots = defineSlots<{ default(): any; content?(): any }>();
|
|
const hasContent = computed(() => !!props.text || !!slots.content);
|
|
|
|
function open(source: "hover" | "focus") {
|
|
if (Date.now() < suppressOpenUntil) return;
|
|
if (isDisabled() || !hasContent.value) return;
|
|
if (source === "focus" ? !hasFocusVisible() : !isPointerActive()) return;
|
|
updatePosition();
|
|
openSource = source;
|
|
show.value = true;
|
|
void nextTick(fitPositionToViewport);
|
|
addGlobalListeners();
|
|
}
|
|
|
|
function scheduleOpen(source: "hover" | "focus" = "hover") {
|
|
if (Date.now() < suppressOpenUntil) return;
|
|
if (isDisabled() || !hasContent.value) return;
|
|
clearTimer();
|
|
timer = setTimeout(() => open(source), props.delay);
|
|
}
|
|
|
|
function scheduleFocusOpen() {
|
|
if (!props.openOnFocus) return;
|
|
if (!hasFocusVisible()) return;
|
|
scheduleOpen("focus");
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
close();
|
|
});
|
|
|
|
watch(
|
|
() => [props.disabled, props.text] as const,
|
|
() => {
|
|
if (isDisabled() || !props.text) close();
|
|
else if (show.value) {
|
|
updatePosition();
|
|
void nextTick(fitPositionToViewport);
|
|
}
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<span ref="triggerRef" class="contents" @mouseenter="() => scheduleOpen('hover')" @mouseleave="scheduleClose" @focusin="scheduleFocusOpen" @focusout="close" @contextmenu.capture="suppressForContextMenu">
|
|
<slot />
|
|
</span>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="show"
|
|
ref="tooltipRef"
|
|
class="fixed z-50 rounded-md text-xs"
|
|
:class="cn([tooltipSurfaceClass, slots.content ? '' : ['inline-flex w-fit max-w-xs items-center gap-1.5 px-3 py-1.5', nowrap ? 'whitespace-nowrap' : ''], tooltipTransformClass], contentClass)"
|
|
:style="{ left: `${x}px`, top: `${y}px` }"
|
|
role="tooltip"
|
|
@mouseenter="clearCloseTimer"
|
|
@mouseleave="scheduleClose"
|
|
>
|
|
<slot name="content">{{ text }}</slot>
|
|
<span :class="[arrowClass, arrowSurfaceClass, 'size-2.5 rotate-45 rounded-[2px]']" :style="arrowStyle" aria-hidden="true" />
|
|
</div>
|
|
</Teleport>
|
|
</template>
|