atmosphere explorer
at main 130 lines 3.9 kB view raw
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;