1<script lang="ts">
2 import {
3 computePosition,
4 autoUpdate,
5 offset,
6 flip,
7 shift,
8 type Placement
9 } from '@floating-ui/dom';
10 import { onMount } from 'svelte';
11 import { portal } from 'svelte-portal';
12 import type { ClassValue } from 'svelte/elements';
13
14 interface Props {
15 class?: ClassValue;
16 style?: string;
17 isOpen?: boolean;
18 trigger?: import('svelte').Snippet;
19 children?: import('svelte').Snippet;
20 placement?: Placement;
21 offsetDistance?: number;
22 openDelay?: number;
23 position?: { x: number; y: number };
24 onMouseEnter?: () => void;
25 onMouseLeave?: () => void;
26 }
27
28 let {
29 isOpen = $bindable(false),
30 trigger,
31 children,
32 placement = 'bottom-start',
33 offsetDistance = 2,
34 openDelay = 400,
35 position = $bindable(),
36 onMouseEnter,
37 onMouseLeave,
38 ...restProps
39 }: Props = $props();
40
41 let triggerRef: HTMLElement | undefined = $state();
42 let contentRef: HTMLElement | undefined = $state();
43 let cleanup: (() => void) | null = null;
44
45 let isTriggerHovered = false;
46 let isContentHovered = false;
47 let closeTimer: ReturnType<typeof setTimeout>;
48 let openTimer: ReturnType<typeof setTimeout>;
49
50 const updatePosition = async () => {
51 const { x, y } = await computePosition(triggerRef!, contentRef!, {
52 placement,
53 middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })],
54 strategy: 'fixed'
55 });
56
57 Object.assign(contentRef!.style, {
58 left: `${x}px`,
59 top: `${y}px`
60 });
61 };
62
63 const handleClose = () => (isOpen = false);
64
65 const isEventInElement = (event: MouseEvent, element: HTMLElement) => {
66 let rect = element.getBoundingClientRect();
67 let x = event.clientX;
68 let y = event.clientY;
69
70 return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
71 };
72
73 const handleClickOutside = (event: MouseEvent) => {
74 if (!isOpen) return;
75 if (!isEventInElement(event, triggerRef!) && !isEventInElement(event, contentRef!))
76 handleClose();
77 };
78
79 const handleEscape = (event: KeyboardEvent) => {
80 if (event.key === 'Escape') handleClose();
81 };
82
83 const handleScroll = handleClose;
84
85 // The central check: "Should we close now?"
86 const scheduleCloseCheck = () => {
87 clearTimeout(closeTimer);
88 closeTimer = setTimeout(() => {
89 // Only close if we are NOT on the trigger AND NOT on the content
90 if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave();
91 }, 30); // Small buffer to handle the physical gap between elements
92 };
93
94 const handleTriggerEnter = () => {
95 isTriggerHovered = true;
96 clearTimeout(closeTimer);
97
98 if (!isOpen) {
99 clearTimeout(openTimer);
100 openTimer = setTimeout(() => {
101 if (onMouseEnter) onMouseEnter();
102 }, openDelay);
103 }
104 };
105
106 const handleTriggerLeave = () => {
107 isTriggerHovered = false;
108 clearTimeout(openTimer);
109 scheduleCloseCheck(); // We left the trigger, check if we should close
110 };
111
112 const handleContentEnter = () => {
113 isContentHovered = true;
114 clearTimeout(closeTimer); // We made it to the content, cancel close
115 };
116
117 const handleContentLeave = () => {
118 isContentHovered = false;
119 scheduleCloseCheck(); // We left the content, check if we should close
120 };
121
122 // Reset state if the menu is closed externally
123 $effect(() => {
124 if (!isOpen) {
125 isContentHovered = false;
126 clearTimeout(closeTimer);
127 clearTimeout(openTimer); // Ensure open timer is cleared on external close
128 }
129 });
130
131 $effect(() => {
132 if (isOpen) {
133 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
134 } else if (cleanup) {
135 cleanup();
136 cleanup = null;
137 }
138 });
139
140 onMount(() => () => {
141 if (cleanup) cleanup();
142 clearTimeout(closeTimer);
143 clearTimeout(openTimer); // Cleanup open timer on unmount
144 });
145</script>
146
147<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
148
149<div
150 role="button"
151 tabindex="0"
152 bind:this={triggerRef}
153 onmouseenter={handleTriggerEnter}
154 onmouseleave={handleTriggerLeave}
155>
156 {@render trigger?.()}
157</div>
158
159{#if isOpen}
160 <div
161 use:portal={'#app-root'}
162 bind:this={contentRef}
163 class="fixed z-9999 animate-fade-in-scale-fast overflow-hidden {restProps.class ?? ''}"
164 style={restProps.style}
165 role="menu"
166 tabindex="-1"
167 onmouseenter={handleContentEnter}
168 onmouseleave={handleContentLeave}
169 >
170 {@render children?.()}
171 </div>
172{/if}