replies timeline only, appview-less bluesky client
at main 4.3 kB view raw
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}