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 position?: { x: number; y: number };
23 }
24
25 let {
26 isOpen = $bindable(false),
27 trigger,
28 children,
29 placement = 'bottom-start',
30 offsetDistance = 2,
31 position = $bindable(),
32 ...restProps
33 }: Props = $props();
34
35 let triggerRef: HTMLElement | undefined = $state();
36 let contentRef: HTMLElement | undefined = $state();
37 let cleanup: (() => void) | null = null;
38
39 const updatePosition = async () => {
40 const { x, y } = await computePosition(triggerRef!, contentRef!, {
41 placement,
42 middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })],
43 strategy: 'fixed'
44 });
45
46 Object.assign(contentRef!.style, {
47 left: `${x}px`,
48 top: `${y}px`
49 });
50 };
51
52 const handleClose = () => (isOpen = false);
53
54 const isEventInElement = (event: MouseEvent, element: HTMLElement) => {
55 let rect = element.getBoundingClientRect();
56 let x = event.clientX;
57 let y = event.clientY;
58 return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
59 };
60
61 const handleClickOutside = (event: MouseEvent) => {
62 if (!isOpen) return;
63 if (!isEventInElement(event, triggerRef!) && !isEventInElement(event, contentRef!))
64 handleClose();
65 };
66
67 const handleEscape = (event: KeyboardEvent) => {
68 if (event.key === 'Escape') handleClose();
69 };
70
71 const handleScroll = handleClose;
72
73 $effect(() => {
74 if (isOpen) {
75 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
76 } else if (cleanup) {
77 cleanup();
78 cleanup = null;
79 }
80 });
81
82 onMount(() => {
83 return () => {
84 if (cleanup) cleanup();
85 };
86 });
87</script>
88
89<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
90
91<div role="button" tabindex="0" bind:this={triggerRef}>
92 {@render trigger?.()}
93</div>
94
95{#if isOpen}
96 <div
97 use:portal={'#app-root'}
98 bind:this={contentRef}
99 class="fixed z-9999 animate-fade-in-scale-fast overflow-hidden {restProps.class ?? ''}"
100 style={restProps.style}
101 role="menu"
102 tabindex="-1"
103 >
104 {@render children?.()}
105 </div>
106{/if}