Added Tooltip Component

This commit is contained in:
2025-08-22 20:02:00 +05:30
parent 2d1fd5f39b
commit 3df0251cff
6 changed files with 648 additions and 1 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ export interface Props {
const {
position = "right",
size = "320px",
size = "384px",
overlay = true,
closeOnOverlayClick = true,
closeOnEsc = true,
+432
View File
@@ -0,0 +1,432 @@
---
import type { HTMLAttributes } from "astro/types";
type Placement =
| "bottom-start"
| "bottom"
| "bottom-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "left-start"
| "left"
| "left-end";
interface Props extends HTMLAttributes<"div"> {
text?: string; // simple string; or use <slot name="content">
placement?: Placement; // default: "top"
offset?: number; // gap between trigger & tooltip (default 10)
shiftPadding?: number; // viewport padding (default 8)
showArrow?: boolean; // default true
delayOpen?: number; // ms (default 80)
delayClose?: number; // ms (default 80)
interactive?: boolean; // hoverable panel (default false)
disabled?: boolean; // default false
maxWidth?: number | string; // e.g. 280 or "20rem" (default "18rem")
openOn?: "hover" | "focus" | "hover-focus"; // default "hover-focus"
}
const {
text,
placement = "top",
offset = 10,
shiftPadding = 8,
showArrow = true,
delayOpen = 80,
delayClose = 80,
interactive = false,
disabled = false,
maxWidth = "18rem",
openOn = "hover",
class: className,
...rest
}: Props = Astro.props;
const id = `tt-${Math.random().toString(36).slice(2)}`;
---
<div
id={id}
class={["wr:relative wr:inline-flex", className].join(" ")}
{...rest}
data-tt-root
data-placement={placement}
data-offset={offset}
data-shift-padding={shiftPadding}
data-show-arrow={showArrow ? "1" : ""}
data-delay-open={delayOpen}
data-delay-close={delayClose}
data-interactive={interactive ? "1" : ""}
data-open-on={openOn}
data-disabled={disabled ? "1" : ""}
>
<!-- Unstyled trigger wrapper (keeps a single trigger, no duplicate UI) -->
<span
data-tt-trigger
class="wr:inline-flex wr:align-middle wr:cursor-help wr:select-none"
tabindex={disabled ? undefined : 0}
aria-describedby={`${id}-tip`}
aria-expanded="false"
>
<slot name="trigger" />
</span>
<!-- Portaled tooltip panel (moved to body as fixed) -->
<div
class="wr:hidden wr:z-50 wr:max-w-[--tt-max] wr:rounded-xl wr:border wr:border-border
wr:bg-popover/95 wr:backdrop-blur-md wr:text-popover-foreground
wr:px-3 wr:py-2 wr:text-xs wr:leading-5
wr:shadow-[0_18px_50px_-15px_rgba(0,0,0,0.35),0_6px_20px_-16px_rgba(0,0,0,0.25)]
wr:origin-[var(--tt-origin,center)] wr:translate-y-1 wr:scale-95 wr:opacity-0
wr:transition wr:duration-150 wr:ease-out"
data-tt-panel
style={{
"--tt-max":
typeof maxWidth === "number"
? `${maxWidth}px`
: String(maxWidth),
}}
role="tooltip"
aria-hidden="true"
>
{showArrow && <div data-tt-arrow class="wr:absolute wr:size-2" />}
{text ? <div class="wr:whitespace-pre-wrap">{text}</div> : <slot />}
</div>
</div>
<style>
[data-tt-panel] {
--ar: 8px;
} /* arrow size */
/* Triangular arrow that points the right way based on side */
[data-tt-panel][data-side="bottom"] [data-tt-arrow] {
position: absolute;
top: calc(var(--ar) * -1);
width: 0;
height: 0;
border-left: var(--ar) solid transparent;
border-right: var(--ar) solid transparent;
border-bottom: var(--ar) solid hsl(var(--popover)); /* points UP */
filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.18));
}
[data-tt-panel][data-side="top"] [data-tt-arrow] {
position: absolute;
bottom: calc(var(--ar) * -1);
width: 0;
height: 0;
border-left: var(--ar) solid transparent;
border-right: var(--ar) solid transparent;
border-top: var(--ar) solid hsl(var(--popover)); /* points DOWN */
filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.18));
}
[data-tt-panel][data-side="right"] [data-tt-arrow] {
position: absolute;
left: calc(var(--ar) * -1);
width: 0;
height: 0;
border-top: var(--ar) solid transparent;
border-bottom: var(--ar) solid transparent;
border-right: var(--ar) solid hsl(var(--popover)); /* points LEFT->RIGHT */
filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.18));
}
[data-tt-panel][data-side="left"] [data-tt-arrow] {
position: absolute;
right: calc(var(--ar) * -1);
width: 0;
height: 0;
border-top: var(--ar) solid transparent;
border-bottom: var(--ar) solid transparent;
border-left: var(--ar) solid hsl(var(--popover)); /* points RIGHT->LEFT */
filter: drop-shadow(0 8px 14px rgba(0, 0, 0, 0.18));
}
/* Open state animation stays the same */
[data-tt-panel].is-open {
transform: translateY(0) scale(1);
opacity: 1;
}
</style>
<script type="module" is:inline define:vars={{ id }}>
(() => {
const root = document.getElementById(id);
if (!root) return;
const trigger = root.querySelector("[data-tt-trigger]");
const panel = root.querySelector("[data-tt-panel]");
const arrowEl = panel?.querySelector("[data-tt-arrow]");
const disabled = !!root.dataset.disabled;
const placement = root.dataset.placement || "top";
const offset = Number(root.dataset.offset || 10);
const pad = Number(root.dataset.shiftPadding || 8);
const showArrow = !!root.dataset.showArrow;
const delayOpen = Number(root.dataset.delayOpen || 80);
const delayClose = Number(root.dataset.delayClose || 80);
const interactive = !!root.dataset.interactive;
const openOn = root.dataset.openOn || "hover-focus";
// Portal (fixed) for robust positioning
const portal = document.createElement("div");
portal.style.position = "fixed";
document.body.appendChild(portal);
panel.style.position = "fixed";
portal.appendChild(panel);
let open = false,
openT = 0,
closeT = 0;
const sideOf = (p) => p.split("-")[0];
const alignOf = (p) => p.split("-")[1] || "center";
function setOriginFor(side, align) {
const mapSide =
{ top: "bottom", bottom: "top", left: "right", right: "left" }[
side
] || "top";
const mapAlign =
side === "top" || side === "bottom"
? align === "start"
? "left"
: align === "end"
? "right"
: "center"
: align === "start"
? "top"
: align === "end"
? "bottom"
: "center";
panel.style.setProperty("--tt-origin", `${mapSide} ${mapAlign}`);
}
function placeOnce(pref) {
const t = trigger.getBoundingClientRect();
const p = panel.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const side = sideOf(pref);
const align = alignOf(pref);
let x = 0,
y = 0;
if (side === "bottom") y = t.bottom + offset;
else if (side === "top") y = t.top - p.height - offset;
else if (side === "right") x = t.right + offset;
else if (side === "left") x = t.left - p.width - offset;
if (side === "top" || side === "bottom") {
if (align === "start") x = t.left;
else if (align === "end") x = t.right - p.width;
else x = t.left + (t.width - p.width) / 2;
} else {
if (align === "start") y = t.top;
else if (align === "end") y = t.bottom - p.height;
else y = t.top + (t.height - p.height) / 2;
}
x = Math.max(pad, Math.min(x, vw - p.width - pad));
y = Math.max(pad, Math.min(y, vh - p.height - pad));
return { x: Math.round(x), y: Math.round(y) };
}
function smartPosition() {
const prefSide = sideOf(placement);
const prefAlign = alignOf(placement);
const flips = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
const candidates = [
placement,
`${flips[prefSide]}${prefAlign ? `-${prefAlign}` : ""}`,
];
if (prefSide === "top" || prefSide === "bottom") {
candidates.push(
`left-${prefAlign || "start"}`,
`right-${prefAlign || "start"}`,
);
} else {
candidates.push(
`top-${prefAlign || "start"}`,
`bottom-${prefAlign || "start"}`,
);
}
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
let best = null;
// Temporarily make visible to measure accurately
panel.style.visibility = "hidden";
panel.classList.remove("wr:hidden");
const measure = () => {
for (const cand of candidates) {
const pos = placeOnce(cand);
const p = panel.getBoundingClientRect();
const over =
Math.max(0, -pos.x) +
Math.max(0, -pos.y) +
Math.max(0, pos.x + p.width - vw) +
Math.max(0, pos.y + p.height - vh);
if (!best || over < best.score)
best = { cand, ...pos, score: over };
}
};
measure();
panel.style.visibility = "";
return best;
}
function positionPanel() {
if (!open) return;
const best = smartPosition();
panel.style.left = best.x + "px";
panel.style.top = best.y + "px";
const side = sideOf(best.cand);
const align = alignOf(best.cand);
setOriginFor(side, align);
panel.dataset.side = side;
if (showArrow && arrowEl) {
Object.assign(arrowEl.style, {
left: "",
top: "",
right: "",
bottom: "",
});
const t = trigger.getBoundingClientRect();
const p = panel.getBoundingClientRect();
const aw = arrowEl.getBoundingClientRect().width;
const ah = arrowEl.getBoundingClientRect().height;
const AR = 7;
if (side === "bottom") {
arrowEl.style.top = -AR + "px";
arrowEl.style.left =
Math.round(t.left + t.width / 2 - p.left - aw / 2) +
"px";
} else if (side === "top") {
arrowEl.style.bottom = -AR + "px";
arrowEl.style.left =
Math.round(t.left + t.width / 2 - p.left - aw / 2) +
"px";
} else if (side === "right") {
arrowEl.style.left = -AR + "px";
arrowEl.style.top =
Math.round(t.top + t.height / 2 - p.top - ah / 2) +
"px";
} else if (side === "left") {
arrowEl.style.right = -AR + "px";
arrowEl.style.top =
Math.round(t.top + t.height / 2 - p.top - ah / 2) +
"px";
}
}
}
function setOpen(v) {
if (disabled || open === v) return;
open = v;
trigger.setAttribute("aria-expanded", String(open));
if (open) {
panel.classList.remove("wr:hidden");
requestAnimationFrame(() => {
panel.classList.add("is-open");
panel.classList.remove(
"wr:translate-y-1",
"wr:scale-95",
"wr:opacity-0",
);
positionPanel();
});
window.addEventListener("resize", positionPanel, {
passive: true,
});
window.addEventListener("scroll", positionPanel, {
passive: true,
});
} else {
panel.classList.remove("is-open");
panel.classList.add(
"wr:translate-y-1",
"wr:scale-95",
"wr:opacity-0",
);
setTimeout(() => panel.classList.add("wr:hidden"), 140);
window.removeEventListener("resize", positionPanel);
window.removeEventListener("scroll", positionPanel);
}
}
const openLater = () => {
clearTimeout(closeT);
clearTimeout(openT);
openT = setTimeout(() => setOpen(true), delayOpen);
};
const closeLater = () => {
clearTimeout(openT);
clearTimeout(closeT);
closeT = setTimeout(() => setOpen(false), delayClose);
};
// Wire triggers
if (openOn === "hover" || openOn === "hover-focus") {
trigger.addEventListener("pointerenter", openLater);
trigger.addEventListener("pointerleave", closeLater);
if (interactive) {
panel.addEventListener("pointerenter", () => {
clearTimeout(closeT);
});
panel.addEventListener("pointerleave", closeLater);
}
}
if (openOn === "focus" || openOn === "hover-focus") {
trigger.addEventListener("focus", openLater);
trigger.addEventListener("blur", closeLater);
}
if (openOn === "click") {
trigger.addEventListener("click", () => setOpen(!open));
document.addEventListener("pointerdown", (e) => {
if (!root.contains(e.target) && !panel.contains(e.target))
setOpen(false);
});
document.addEventListener(
"keydown",
(e) => e.key === "Escape" && setOpen(false),
);
}
// Close on outside pointerdown
document.addEventListener("pointerdown", (e) => {
if (!open) return;
if (root.contains(e.target) || panel.contains(e.target)) return;
setOpen(false);
});
// Cleanup on client navigation
document.addEventListener("astro:before-swap", () => {
try {
panel.remove();
} catch {}
try {
portal.remove();
} catch {}
});
})();
</script>