forked from
pds.ls/pdsls
atmosphere explorer
1import { A } from "@solidjs/router";
2import { createSignal, JSX, onCleanup, Show } from "solid-js";
3import { Portal } from "solid-js/web";
4import { canHover } from "../../layout";
5
6interface HoverCardProps {
7 /** Link href - if provided, renders an A tag */
8 href?: string;
9 /** Link/trigger label text */
10 label?: string;
11 /** Open link in new tab */
12 newTab?: boolean;
13 /** Called when hover starts (for prefetching) */
14 onHover?: () => void;
15 /** Delay in ms before showing card and calling onHover (default: 0) */
16 hoverDelay?: number;
17 /** Custom trigger element - if provided, overrides href/label */
18 trigger?: JSX.Element;
19 /** Additional classes for the wrapper span */
20 class?: string;
21 /** Additional classes for the link/label */
22 labelClass?: string;
23 /** Additional classes for the preview container */
24 previewClass?: string;
25 /** Preview content */
26 children: JSX.Element;
27}
28
29const HoverCard = (props: HoverCardProps) => {
30 const [show, setShow] = createSignal(false);
31
32 const [previewHeight, setPreviewHeight] = createSignal(0);
33 const [anchorRect, setAnchorRect] = createSignal<DOMRect | null>(null);
34 let anchorRef!: HTMLSpanElement;
35 let previewRef!: HTMLDivElement;
36 let resizeObserver: ResizeObserver | null = null;
37 let hoverTimeout: number | null = null;
38
39 const setupResizeObserver = (el: HTMLDivElement) => {
40 resizeObserver?.disconnect();
41 previewRef = el;
42 resizeObserver = new ResizeObserver(() => {
43 if (previewRef) setPreviewHeight(previewRef.offsetHeight);
44 });
45 resizeObserver.observe(el);
46 };
47
48 onCleanup(() => {
49 resizeObserver?.disconnect();
50 if (hoverTimeout !== null) {
51 clearTimeout(hoverTimeout);
52 }
53 });
54
55 const isOverflowing = (previewHeight: number) => {
56 const rect = anchorRect();
57 return rect && rect.top + previewHeight + 32 > window.innerHeight;
58 };
59
60 const getPreviewStyle = () => {
61 const rect = anchorRect();
62 if (!rect) return {};
63
64 const left = rect.left + rect.width / 2;
65 const overflowing = isOverflowing(previewHeight());
66 const gap = 4;
67
68 return {
69 left: `${left}px`,
70 top: overflowing ? `${rect.top - gap}px` : `${rect.bottom + gap}px`,
71 transform: overflowing ? "translate(-50%, -100%)" : "translate(-50%, 0)",
72 };
73 };
74
75 const handleMouseEnter = () => {
76 const delay = props.hoverDelay ?? 0;
77 setAnchorRect(anchorRef.getBoundingClientRect());
78
79 if (delay > 0) {
80 hoverTimeout = window.setTimeout(() => {
81 props.onHover?.();
82 setShow(true);
83 hoverTimeout = null;
84 }, delay);
85 } else {
86 props.onHover?.();
87 setShow(true);
88 }
89 };
90
91 const handleMouseLeave = () => {
92 if (hoverTimeout !== null) {
93 clearTimeout(hoverTimeout);
94 hoverTimeout = null;
95 }
96 setShow(false);
97 };
98
99 return (
100 <span
101 ref={anchorRef}
102 class={`group/hover-card relative ${props.class || "inline"}`}
103 onMouseEnter={handleMouseEnter}
104 onMouseLeave={handleMouseLeave}
105 >
106 {props.trigger ?? (
107 <A
108 class={`text-blue-500 hover:underline active:underline dark:text-blue-400 ${props.labelClass || ""}`}
109 href={props.href!}
110 target={props.newTab ? "_blank" : undefined}
111 >
112 {props.label}
113 </A>
114 )}
115 <Show when={show() && canHover}>
116 <Portal>
117 <div
118 ref={setupResizeObserver}
119 style={getPreviewStyle()}
120 class={`dark:bg-dark-300 dark:shadow-dark-700 pointer-events-none fixed z-50 block overflow-hidden rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-2 shadow-md dark:border-neutral-700 ${props.previewClass ?? "max-h-80 w-max max-w-sm font-mono text-xs whitespace-pre-wrap sm:max-h-112 lg:max-w-lg"}`}
121 >
122 {props.children}
123 </div>
124 </Portal>
125 </Show>
126 </span>
127 );
128};
129
130export default HoverCard;