appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: broken dropdown refactor for now

ptr.pet 6dbd17ca bc545eab

verified
+303 -140
+37
deno.lock
··· 11 11 "npm:@atcute/tid@^1.0.3": "1.0.3", 12 12 "npm:@eslint/compat@^1.4.1": "1.4.1_eslint@9.39.0", 13 13 "npm:@eslint/js@^9.39.0": "9.39.0", 14 + "npm:@floating-ui/dom@^1.7.4": "1.7.4", 14 15 "npm:@iconify/svelte@^5.1.0": "5.1.0_svelte@5.43.2__acorn@8.15.0", 15 16 "npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3", 16 17 "npm:@sveltejs/adapter-static@^3.0.10": "3.0.10_@sveltejs+kit@2.48.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.43.2____acorn@8.15.0___vite@7.1.12____@types+node@24.10.0____picomatch@4.0.3___@types+node@24.10.0__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.10.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0", ··· 31 32 "npm:prettier@^3.6.2": "3.6.2", 32 33 "npm:svelte-awesome-color-picker@^4.1.0": "4.1.0_svelte@5.43.2__acorn@8.15.0", 33 34 "npm:svelte-check@^4.3.3": "4.3.3_svelte@5.43.2__acorn@8.15.0_typescript@5.9.3", 35 + "npm:svelte-device-info@^1.0.6": "1.0.6", 36 + "npm:svelte-floating-ui@^1.6.2": "1.6.2", 34 37 "npm:svelte-infinite@~0.5.1": "0.5.1_svelte@5.43.2__acorn@8.15.0", 35 38 "npm:svelte@^5.43.2": "5.43.2_acorn@8.15.0", 36 39 "npm:tailwindcss@^4.1.16": "4.1.16", ··· 311 314 "@eslint/core", 312 315 "levn" 313 316 ] 317 + }, 318 + "@floating-ui/core@1.7.3": { 319 + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", 320 + "dependencies": [ 321 + "@floating-ui/utils" 322 + ] 323 + }, 324 + "@floating-ui/dom@1.7.4": { 325 + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", 326 + "dependencies": [ 327 + "@floating-ui/core", 328 + "@floating-ui/utils" 329 + ] 330 + }, 331 + "@floating-ui/utils@0.2.10": { 332 + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" 314 333 }, 315 334 "@humanfs/core@0.19.1": { 316 335 "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" ··· 1621 1640 ], 1622 1641 "bin": true 1623 1642 }, 1643 + "svelte-device-info@1.0.6": { 1644 + "integrity": "sha512-G13YYkxnlz5AryOps8KFHFt8+5Ne7JiZgTxtYEXLVBF4UAwu9I1F+Xcd9rfhTZqUUtF9fm4qJpSi3I6p1JUt6Q==", 1645 + "dependencies": [ 1646 + "tslib" 1647 + ] 1648 + }, 1624 1649 "svelte-eslint-parser@1.4.0_svelte@5.43.2__acorn@8.15.0_postcss@8.5.6": { 1625 1650 "integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==", 1626 1651 "dependencies": [ ··· 1636 1661 "svelte" 1637 1662 ] 1638 1663 }, 1664 + "svelte-floating-ui@1.6.2": { 1665 + "integrity": "sha512-EC+DZtBey50P6l3NSzNQWon3cip8a1bzwdpmCdc45kymqEWL4BKhPemAq7SQ9QLebDPaMECW6YodxFbs2d+O/w==", 1666 + "dependencies": [ 1667 + "@floating-ui/dom" 1668 + ] 1669 + }, 1639 1670 "svelte-infinite@0.5.1_svelte@5.43.2__acorn@8.15.0": { 1640 1671 "integrity": "sha512-NvpYWrHPcLHZQMnqUXgKGpOSMq9kMQ6sa8+WO80jLrgBFX+LWoKvAsrc1d1g+eiaagNAE9HalWWJ4KDtYi/+sw==", 1641 1672 "dependencies": [ ··· 1688 1719 "dependencies": [ 1689 1720 "typescript" 1690 1721 ] 1722 + }, 1723 + "tslib@2.8.1": { 1724 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 1691 1725 }, 1692 1726 "type-check@0.4.0": { 1693 1727 "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", ··· 1783 1817 "npm:@atcute/tid@^1.0.3", 1784 1818 "npm:@eslint/compat@^1.4.1", 1785 1819 "npm:@eslint/js@^9.39.0", 1820 + "npm:@floating-ui/dom@^1.7.4", 1786 1821 "npm:@iconify/svelte@^5.1.0", 1787 1822 "npm:@soffinal/websocket@~0.2.1", 1788 1823 "npm:@sveltejs/adapter-static@^3.0.10", ··· 1803 1838 "npm:prettier@^3.6.2", 1804 1839 "npm:svelte-awesome-color-picker@^4.1.0", 1805 1840 "npm:svelte-check@^4.3.3", 1841 + "npm:svelte-device-info@^1.0.6", 1842 + "npm:svelte-floating-ui@^1.6.2", 1806 1843 "npm:svelte-infinite@~0.5.1", 1807 1844 "npm:svelte@^5.43.2", 1808 1845 "npm:tailwindcss@^4.1.16",
+2
package.json
··· 22 22 "@atcute/lexicons": "^1.2.2", 23 23 "@atcute/oauth-browser-client": "^2.0.1", 24 24 "@atcute/tid": "^1.0.3", 25 + "@floating-ui/dom": "^1.7.4", 25 26 "@soffinal/websocket": "^0.2.1", 26 27 "@wora/cache-persist": "^2.2.1", 27 28 "hash-wasm": "^4.12.0", 28 29 "lru-cache": "^11.2.2", 30 + "svelte-device-info": "^1.0.6", 29 31 "svelte-infinite": "^0.5.1" 30 32 }, 31 33 "devDependencies": {
+70 -78
src/components/AccountSelector.svelte
··· 5 5 import ProfilePicture from './ProfilePicture.svelte'; 6 6 import PfpPlaceholder from './PfpPlaceholder.svelte'; 7 7 import Popup from './Popup.svelte'; 8 + import Dropdown from './Dropdown.svelte'; 8 9 import { flow } from '$lib/at/oauth'; 9 10 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 10 11 import Icon from '@iconify/svelte'; ··· 31 32 let loginError = $state(''); 32 33 let isLoggingIn = $state(false); 33 34 34 - const toggleDropdown = (e: MouseEvent) => { 35 - e.stopPropagation(); 36 - isDropdownOpen = !isDropdownOpen; 37 - }; 35 + const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen); 36 + const closeDropdown = () => (isDropdownOpen = false); 38 37 39 38 const selectAccount = (did: AtprotoDid) => { 40 39 onAccountSelected(did); 41 - isDropdownOpen = false; 40 + closeDropdown(); 42 41 }; 43 42 44 43 const openLoginModal = () => { 45 44 isLoginModalOpen = true; 46 - isDropdownOpen = false; 45 + closeDropdown(); 47 46 loginHandle = ''; 48 47 loginError = ''; 49 48 // HACK: i hate this but it works so it doesnt really matter ··· 89 88 const handleKeydown = (event: KeyboardEvent) => { 90 89 if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 91 90 }; 92 - 93 - const closeDropdown = () => { 94 - isDropdownOpen = false; 95 - }; 96 91 </script> 97 92 98 - <svelte:window onclick={closeDropdown} /> 93 + <Dropdown bind:isOpen={isDropdownOpen}> 94 + {#snippet trigger()} 95 + <button 96 + onclick={toggleDropdown} 97 + class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150" 98 + > 99 + {#if selectedDid} 100 + <ProfilePicture {client} did={selectedDid} size={13} /> 101 + {:else} 102 + <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> 103 + {/if} 104 + </button> 105 + {/snippet} 99 106 100 - <div class="relative"> 101 - <button 102 - onclick={toggleDropdown} 103 - class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150" 107 + <div 108 + class="min-w-52 animate-fade-in-scale-fast overflow-hidden rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg)/94 shadow-2xl backdrop-blur-lg transition-all" 104 109 > 105 - {#if selectedDid} 106 - <ProfilePicture {client} did={selectedDid} size={13} /> 107 - {:else} 108 - <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> 109 - {/if} 110 - </button> 111 - 112 - {#if isDropdownOpen} 113 - <!-- svelte-ignore a11y_click_events_have_key_events --> 114 - <!-- svelte-ignore a11y_no_static_element_interactions --> 115 - <div 116 - class="absolute bottom-full z-20 mb-1 min-w-52 animate-fade-in-scale-fast overflow-hidden rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg)/94 shadow-2xl backdrop-blur-lg transition-all" 117 - onclick={(e) => e.stopPropagation()} 118 - > 119 - {#if accounts.length > 0} 120 - <div class="p-2"> 121 - {#each accounts as account (account.did)} 122 - {@const color = generateColorForDid(account.did)} 123 - {#snippet action(name: string, icon: string, onClick: () => void)} 124 - <div 125 - title={name} 126 - onclick={onClick} 127 - class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 128 - > 129 - <Icon class="h-5 w-5" {icon} /> 130 - </div> 131 - {/snippet} 132 - <button 133 - onclick={() => selectAccount(account.did)} 134 - class=" 135 - group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 136 - {account.did === selectedDid ? 'shadow-lg' : ''} 137 - " 138 - style="color: {color}; background: {account.did === selectedDid 139 - ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 140 - : 'transparent'};" 110 + {#if accounts.length > 0} 111 + <div class="p-2"> 112 + {#each accounts as account (account.did)} 113 + {@const color = generateColorForDid(account.did)} 114 + {#snippet action(name: string, icon: string, onClick: () => void)} 115 + <!-- svelte-ignore a11y_click_events_have_key_events --> 116 + <!-- svelte-ignore a11y_no_static_element_interactions --> 117 + <div 118 + title={name} 119 + onclick={onClick} 120 + class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 141 121 > 142 - <span>@{account.handle}</span> 122 + <Icon class="h-5 w-5" {icon} /> 123 + </div> 124 + {/snippet} 125 + <button 126 + onclick={() => selectAccount(account.did)} 127 + class=" 128 + group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 129 + {account.did === selectedDid ? 'shadow-lg' : ''} 130 + " 131 + style="color: {color}; background: {account.did === selectedDid 132 + ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 133 + : 'transparent'};" 134 + > 135 + <span>@{account.handle}</span> 143 136 144 - <div class="grow"></div> 137 + <div class="grow"></div> 145 138 146 - {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 147 - initiateLogin(account.did, account.handle) 148 - )} 149 - {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 139 + {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 140 + initiateLogin(account.did, account.handle) 141 + )} 142 + {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 150 143 151 - {#if account.did === selectedDid} 152 - <Icon 153 - icon="heroicons:check-16-solid" 154 - class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 155 - /> 156 - {/if} 157 - </button> 158 - {/each} 159 - </div> 160 - <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 161 - {/if} 162 - <button 163 - onclick={openLoginModal} 164 - class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 165 - > 166 - <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 167 - <span>add account</span> 168 - </button> 169 - </div> 170 - {/if} 171 - </div> 144 + {#if account.did === selectedDid} 145 + <Icon 146 + icon="heroicons:check-16-solid" 147 + class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 148 + /> 149 + {/if} 150 + </button> 151 + {/each} 152 + </div> 153 + <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 154 + {/if} 155 + <button 156 + onclick={openLoginModal} 157 + class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]" 158 + > 159 + <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 160 + <span>add account</span> 161 + </button> 162 + </div> 163 + </Dropdown> 172 164 173 165 <Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 174 166 <!-- svelte-ignore a11y_no_static_element_interactions -->
+102 -62
src/components/BskyPost.svelte
··· 29 29 import { onMount } from 'svelte'; 30 30 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 31 31 import { derived } from 'svelte/store'; 32 + import Device from 'svelte-device-info'; 33 + import Dropdown from './Dropdown.svelte'; 32 34 33 35 interface Props { 34 36 client: AtpClient; ··· 211 213 } 212 214 return link; 213 215 }; 216 + 217 + let actionsOpen = $state(false); 214 218 </script> 215 219 216 220 {#snippet embedBadge(record: AppBskyFeedPost.Main)} 217 221 {#if record.embed} 218 222 <span 219 223 class="rounded-full px-2.5 py-0.5 text-xs font-medium" 220 - style="background: color-mix(in srgb, {mini 221 - ? 'var(--nucleus-fg)' 222 - : color} 10%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" 224 + style=" 225 + background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent); 226 + color: {mini ? 'var(--nucleus-fg)' : color}; 227 + " 223 228 > 224 229 {getEmbedText(record.embed.$type)} 225 230 </span> ··· 254 259 style="background: {color}18; border-color: {color}66;" 255 260 > 256 261 <div 257 - class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) border-l-transparent" 262 + class=" 263 + inline-block h-6 w-6 animate-spin rounded-full 264 + border-3 border-(--nucleus-accent) border-l-transparent 265 + " 258 266 ></div> 259 267 <p class="mt-3 text-sm font-medium opacity-60">loading post...</p> 260 268 </div> ··· 263 271 {@const record = post.value.record} 264 272 <div 265 273 id="timeline-post-{post.value.uri}-{quoteDepth}" 266 - class="rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all {$isPulsing 267 - ? 'animate-pulse-highlight' 268 - : ''}" 269 - style="background: {color}{isOnPostComposer 270 - ? '36' 271 - : '18'}; border-color: {color}{isOnPostComposer ? '99' : '66'};" 274 + class=" 275 + group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all 276 + {$isPulsing ? 'animate-pulse-highlight' : ''} 277 + " 278 + style=" 279 + background: {color}{isOnPostComposer ? '36' : '18'}; 280 + border-color: {color}{isOnPostComposer ? '99' : '66'}; 281 + " 272 282 > 273 283 <div 274 - class="group mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" 284 + class="mb-3 flex w-fit max-w-full items-center gap-1.5 rounded-sm pr-1" 275 285 style="background: {color}33;" 276 286 > 277 287 <ProfilePicture {client} {did} size={8} /> ··· 381 391 {/if} 382 392 383 393 {#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 384 - <div 385 - class="group mt-3 flex w-fit max-w-full items-center rounded-sm" 386 - style="background: {color}1f;" 387 - > 388 - {#snippet label( 389 - name: string, 390 - icon: string, 391 - onClick: (link: Backlink | null | undefined) => void, 392 - backlink?: Backlink | null, 393 - hasSolid?: boolean 394 - )} 395 - <button 396 - class="px-2 py-1.5 text-(--nucleus-fg)/90 hover:[backdrop-filter:brightness(120%)]" 397 - onclick={() => onClick(backlink)} 398 - style="color: {backlink ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 399 - title={name} 400 - > 401 - <Icon icon={hasSolid && backlink ? `${icon}-solid` : icon} width={20} /> 402 - </button> 403 - {/snippet} 404 - {@render label('reply', 'heroicons:chat-bubble-left', () => { 405 - onReply?.(post); 406 - })} 407 - {@render label( 408 - 'repost', 409 - 'heroicons:arrow-path-rounded-square-20-solid', 410 - async (link) => { 411 - if (link === undefined) return; 412 - postActions.set(`${selectedDid!}:${aturi}`, { 413 - ...backlinks!, 414 - repost: await toggleLink(link, 'app.bsky.feed.repost') 415 - }); 416 - }, 417 - backlinks?.repost 418 - )} 419 - {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 420 - onQuote?.(post); 421 - })} 422 - {@render label( 423 - 'like', 424 - 'heroicons:star', 425 - async (link) => { 426 - if (link === undefined) return; 427 - postActions.set(`${selectedDid!}:${aturi}`, { 428 - ...backlinks!, 429 - like: await toggleLink(link, 'app.bsky.feed.like') 430 - }); 431 - }, 432 - backlinks?.like, 433 - true 434 - )} 394 + {#snippet control( 395 + name: string, 396 + icon: string, 397 + onClick: (e: MouseEvent) => void, 398 + isFull?: boolean, 399 + hasSolid?: boolean 400 + )} 401 + <button 402 + class=" 403 + px-2 py-1.5 text-(--nucleus-fg)/90 transition-all 404 + duration-100 hover:[backdrop-filter:brightness(120%)] 405 + " 406 + onclick={(e) => onClick(e)} 407 + style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}" 408 + title={name} 409 + > 410 + <Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} /> 411 + </button> 412 + {/snippet} 413 + <div class="mt-3 flex w-full items-center justify-between"> 414 + <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 415 + {#snippet label( 416 + name: string, 417 + icon: string, 418 + onClick: (link: Backlink | null | undefined) => void, 419 + backlink?: Backlink | null, 420 + hasSolid?: boolean 421 + )} 422 + {@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)} 423 + {/snippet} 424 + {@render label('reply', 'heroicons:chat-bubble-left', () => { 425 + onReply?.(post); 426 + })} 427 + {@render label( 428 + 'repost', 429 + 'heroicons:arrow-path-rounded-square-20-solid', 430 + async (link) => { 431 + if (link === undefined) return; 432 + postActions.set(`${selectedDid!}:${aturi}`, { 433 + ...backlinks!, 434 + repost: await toggleLink(link, 'app.bsky.feed.repost') 435 + }); 436 + }, 437 + backlinks?.repost 438 + )} 439 + {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 440 + onQuote?.(post); 441 + })} 442 + {@render label( 443 + 'like', 444 + 'heroicons:star', 445 + async (link) => { 446 + if (link === undefined) return; 447 + postActions.set(`${selectedDid!}:${aturi}`, { 448 + ...backlinks!, 449 + like: await toggleLink(link, 'app.bsky.feed.like') 450 + }); 451 + }, 452 + backlinks?.like, 453 + true 454 + )} 455 + </div> 456 + <div 457 + class=" 458 + w-fit items-center rounded-sm transition-opacity 459 + duration-100 ease-in-out group-hover:opacity-100 460 + {!actionsOpen && !Device.isMobile ? 'opacity-0' : ''} 461 + " 462 + style="background: {color}1f;" 463 + > 464 + <Dropdown bind:isOpen={actionsOpen}> 465 + {#snippet trigger()} 466 + {@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => { 467 + e.stopPropagation(); 468 + actionsOpen = !actionsOpen; 469 + })} 470 + {/snippet} 471 + 472 + woof 473 + </Dropdown> 474 + </div> 435 475 </div> 436 476 {/snippet}
+92
src/components/Dropdown.svelte
··· 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 + 12 + interface Props { 13 + isOpen?: boolean; 14 + trigger?: import('svelte').Snippet; 15 + children?: import('svelte').Snippet; 16 + placement?: Placement; 17 + offsetDistance?: number; 18 + } 19 + 20 + let { 21 + isOpen = $bindable(false), 22 + trigger, 23 + children, 24 + placement = 'bottom-start', 25 + offsetDistance = 8 26 + }: Props = $props(); 27 + 28 + let triggerRef: HTMLElement | undefined = $state(); 29 + let contentRef: HTMLElement | undefined = $state(); 30 + let cleanup: (() => void) | null = null; 31 + 32 + const updatePosition = async () => { 33 + const { x, y } = await computePosition(triggerRef!, contentRef!, { 34 + placement, 35 + middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })], 36 + strategy: 'fixed' 37 + }); 38 + 39 + Object.assign(contentRef!.style, { 40 + left: `${x}px`, 41 + top: `${y}px` 42 + }); 43 + }; 44 + 45 + const handleClose = () => (isOpen = false); 46 + 47 + const isEventInElement = (event: MouseEvent, element: HTMLElement) => { 48 + let rect = element.getBoundingClientRect(); 49 + let x = event.clientX; 50 + let y = event.clientY; 51 + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; 52 + }; 53 + 54 + const handleClickOutside = (event: MouseEvent) => { 55 + if (!isOpen) return; 56 + if (!isEventInElement(event, triggerRef!) && !isEventInElement(event, contentRef!)) 57 + handleClose(); 58 + }; 59 + 60 + const handleEscape = (event: KeyboardEvent) => { 61 + if (event.key === 'Escape') handleClose(); 62 + }; 63 + 64 + const handleScroll = handleClose; 65 + 66 + $effect(() => { 67 + if (isOpen) { 68 + cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); 69 + } else if (cleanup) { 70 + cleanup(); 71 + cleanup = null; 72 + } 73 + }); 74 + 75 + onMount(() => { 76 + return () => { 77 + if (cleanup) cleanup(); 78 + }; 79 + }); 80 + </script> 81 + 82 + <svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} /> 83 + 84 + <div role="button" tabindex="0" bind:this={triggerRef}> 85 + {@render trigger?.()} 86 + </div> 87 + 88 + {#if isOpen} 89 + <div bind:this={contentRef} class="fixed! z-9999!" role="menu" tabindex="-1"> 90 + {@render children?.()} 91 + </div> 92 + {/if}