replies timeline only, appview-less bluesky client
at main 2.5 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 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}