| <script lang="ts"> |
| import { flyAndScale } from '$lib/utils/transitions'; |
| import { tick } from 'svelte'; |
| |
| |
| export let show = false; |
| |
| |
| export let side = 'bottom'; |
| |
| |
| export let align = 'start'; |
| |
| |
| export let closeOnOutsideClick = true; |
| |
| |
| export let onOpenChange: (state: boolean) => void = () => {}; |
| |
| |
| export let contentClass = ''; |
| |
| |
| export let sideOffset = 4; |
| |
| let triggerEl; |
| let contentEl; |
| |
| |
| function portal(node) { |
| document.body.appendChild(node); |
| return { |
| destroy() { |
| if (node.parentNode) { |
| node.parentNode.removeChild(node); |
| } |
| } |
| }; |
| } |
| |
| |
| function trigger(node) { |
| triggerEl = node.firstElementChild || node; |
| function handleClick(e) { |
| e.preventDefault(); |
| toggleOpen(); |
| } |
| function handleKeydown(e) { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| toggleOpen(); |
| } |
| } |
| node.addEventListener('click', handleClick); |
| node.addEventListener('keydown', handleKeydown); |
| return { |
| destroy() { |
| node.removeEventListener('click', handleClick); |
| node.removeEventListener('keydown', handleKeydown); |
| } |
| }; |
| } |
| |
| function positionContent() { |
| if (!triggerEl || !contentEl) return; |
| const rect = triggerEl.getBoundingClientRect(); |
| |
| contentEl.style.position = 'fixed'; |
| contentEl.style.zIndex = '9999'; |
| |
| const contentHeight = contentEl.offsetHeight || 0; |
| const spaceBelow = window.innerHeight - rect.bottom - sideOffset; |
| const spaceAbove = rect.top - sideOffset; |
| |
| // Auto-flip: prefer the requested side, but flip if not enough space |
| let openAbove = side === 'top'; |
| if (side === 'bottom' && spaceBelow < contentHeight && spaceAbove > spaceBelow) { |
| openAbove = true; |
| } else if (side === 'top' && spaceAbove < contentHeight && spaceBelow > spaceAbove) { |
| openAbove = false; |
| } |
| |
| if (openAbove) { |
| contentEl.style.bottom = `${window.innerHeight - rect.top + sideOffset}px`; |
| contentEl.style.top = 'auto'; |
| } else { |
| contentEl.style.top = `${rect.bottom + sideOffset}px`; |
| contentEl.style.bottom = 'auto'; |
| } |
| |
| if (align === 'end') { |
| let right = window.innerWidth - rect.right; |
| // Shift if overflowing left edge |
| const contentWidth = contentEl.offsetWidth || 0; |
| if (right + contentWidth > window.innerWidth) { |
| right = window.innerWidth - contentWidth - 16; |
| } |
| contentEl.style.right = `${Math.max(16, right)}px`; |
| contentEl.style.left = 'auto'; |
| } else { |
| let left = rect.left; |
| // Shift if overflowing right edge |
| const contentWidth = contentEl.offsetWidth || 0; |
| if (left + contentWidth + 16 > window.innerWidth) { |
| left = window.innerWidth - contentWidth - 16; |
| } |
| contentEl.style.left = `${Math.max(16, left)}px`; |
| contentEl.style.right = 'auto'; |
| } |
| } |
| |
| async function toggleOpen() { |
| show = !show; |
| onOpenChange(show); |
| if (show) { |
| await tick(); |
| positionContent(); |
| // Re-check after transition renders real dimensions |
| setTimeout(positionContent, 50); |
| } |
| } |
| |
| |
| $: if (show) { |
| tick().then(() => { |
| positionContent(); |
| setTimeout(positionContent, 50); |
| }); |
| } |
| |
| function handleWindowPointerDown(event) { |
| if (!show || !closeOnOutsideClick) return; |
| if (triggerEl?.contains(event.target)) return; |
| if (contentEl?.contains(event.target)) return; |
| show = false; |
| onOpenChange(false); |
| } |
| |
| function handleKeydown(event) { |
| if (event.key === 'Escape' && show) { |
| show = false; |
| onOpenChange(false); |
| } |
| } |
| |
| |
| export function close() { |
| show = false; |
| onOpenChange(false); |
| } |
| |
| import { onMount, onDestroy } from 'svelte'; |
| |
| let onPointerDown; |
| onMount(() => { |
| onPointerDown = (e) => handleWindowPointerDown(e); |
| document.addEventListener('pointerdown', onPointerDown, true); |
| }); |
| onDestroy(() => { |
| if (onPointerDown) { |
| document.removeEventListener('pointerdown', onPointerDown, true); |
| } |
| }); |
| </script> |
|
|
| <svelte:window |
| on:keydown={handleKeydown} |
| on:scroll|capture={positionContent} |
| on:resize={positionContent} |
| /> |
|
|
| <span |
| use:trigger |
| style="display: contents; cursor: pointer;" |
| role="button" |
| aria-haspopup="true" |
| aria-expanded={show} |
| > |
| <slot /> |
| </span> |
|
|
| {#if show} |
| <!-- svelte-ignore a11y-click-events-have-key-events --> |
| <!-- svelte-ignore a11y-no-static-element-interactions --> |
| <div |
| use:portal |
| bind:this={contentEl} |
| class={contentClass} |
| role="menu" |
| transition:flyAndScale |
| on:click={(e) => e.stopPropagation()} |
| on:pointerdown={(e) => e.stopPropagation()} |
| > |
| <slot name="content" /> |
| </div> |
| {/if} |
|
|