diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 739fe15958..56a98627f1 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -111,6 +111,7 @@ { if (e.detail === false) { onClose(); diff --git a/src/lib/components/common/Dropdown.svelte b/src/lib/components/common/Dropdown.svelte index ecda443993..d0d7381e8e 100644 --- a/src/lib/components/common/Dropdown.svelte +++ b/src/lib/components/common/Dropdown.svelte @@ -26,11 +26,17 @@ /** Side offset in px */ export let sideOffset = 4; - let triggerEl; - let contentEl; + /** Position against the visual viewport, e.g. when the mobile keyboard is open */ + export let visualViewportAware = false; + + let triggerEl: HTMLElement | null = null; + let contentEl: HTMLElement | null = null; + let positionFrame: number | undefined; + let settleTimers: number[] = []; + let resolvedMaxHeight = maxHeight; /** Svelte action: moves the node to document.body */ - function portal(node) { + function portal(node: HTMLElement) { document.body.appendChild(node); return { destroy() { @@ -42,13 +48,13 @@ } /** Svelte action: captures the first child element as the trigger reference */ - function trigger(node) { - triggerEl = node.firstElementChild || node; - function handleClick(e) { + function trigger(node: HTMLElement) { + triggerEl = (node.firstElementChild as HTMLElement | null) || node; + function handleClick(e: MouseEvent) { e.preventDefault(); toggleOpen(); } - function handleKeydown(e) { + function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleOpen(); @@ -64,9 +70,20 @@ }; } - function positionContent() { + function visualViewportRect() { + const viewport = window.visualViewport; + return { + left: viewport?.offsetLeft ?? 0, + top: viewport?.offsetTop ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight + }; + } + + function positionContentDefault() { if (!triggerEl || !contentEl) return; const rect = triggerEl.getBoundingClientRect(); + resolvedMaxHeight = maxHeight; contentEl.style.position = 'fixed'; contentEl.style.zIndex = '9999'; @@ -112,6 +129,74 @@ } } + function positionContentVisualViewport() { + if (!triggerEl || !contentEl) return; + const rect = triggerEl.getBoundingClientRect(); + const viewport = visualViewportRect(); + const viewportRight = viewport.left + viewport.width; + const viewportBottom = viewport.top + viewport.height; + const pad = 8; + + contentEl.style.position = 'fixed'; + contentEl.style.zIndex = '9999'; + contentEl.style.maxHeight = maxHeight; + + const contentHeight = contentEl.offsetHeight || 0; + const spaceBelow = viewportBottom - rect.bottom - sideOffset - pad; + const spaceAbove = rect.top - viewport.top - sideOffset - pad; + + // 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; + } + + const availableHeight = Math.max(0, openAbove ? spaceAbove : spaceBelow); + const constrainedHeight = contentHeight ? Math.min(contentHeight, availableHeight) : contentHeight; + const preferredTop = openAbove ? rect.top - constrainedHeight - sideOffset : rect.bottom + sideOffset; + const contentWidth = contentEl.offsetWidth || 0; + const preferredLeft = align === 'end' && contentWidth ? rect.right - contentWidth : rect.left; + const maxLeft = contentWidth ? viewportRight - contentWidth - pad : preferredLeft; + + contentEl.style.top = `${Math.max( + viewport.top + pad, + Math.min(preferredTop, viewportBottom - pad - constrainedHeight) + )}px`; + contentEl.style.bottom = 'auto'; + contentEl.style.left = `${Math.max(viewport.left + pad, Math.min(preferredLeft, maxLeft))}px`; + contentEl.style.right = 'auto'; + resolvedMaxHeight = + contentHeight > availableHeight ? `min(${maxHeight}, ${availableHeight}px)` : maxHeight; + contentEl.style.maxHeight = resolvedMaxHeight; + } + + function positionContent() { + if (visualViewportAware) { + positionContentVisualViewport(); + } else { + positionContentDefault(); + } + } + + function schedulePositionUpdate() { + if (positionFrame != null) cancelAnimationFrame(positionFrame); + positionFrame = requestAnimationFrame(() => { + positionFrame = undefined; + positionContent(); + }); + } + + function scheduleSettledPositionUpdates() { + for (const timer of settleTimers) window.clearTimeout(timer); + settleTimers = []; + schedulePositionUpdate(); + for (const delay of [50, 150, 300]) { + settleTimers.push(window.setTimeout(schedulePositionUpdate, delay)); + } + } + async function toggleOpen() { show = !show; onOpenChange(show); @@ -119,7 +204,11 @@ await tick(); positionContent(); // Re-check after transition renders real dimensions - setTimeout(positionContent, 50); + if (visualViewportAware) { + scheduleSettledPositionUpdates(); + } else { + setTimeout(positionContent, 50); + } } } @@ -127,19 +216,24 @@ $: if (show) { tick().then(() => { positionContent(); - setTimeout(positionContent, 50); + if (visualViewportAware) { + scheduleSettledPositionUpdates(); + } else { + setTimeout(positionContent, 50); + } }); } - function handleWindowPointerDown(event) { + function handleWindowPointerDown(event: PointerEvent) { if (!show || !closeOnOutsideClick) return; + if (!(event.target instanceof Node)) return; if (triggerEl?.contains(event.target)) return; if (contentEl?.contains(event.target)) return; show = false; onOpenChange(false); } - function handleKeydown(event) { + function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape' && show) { show = false; onOpenChange(false); @@ -154,15 +248,25 @@ import { onMount, onDestroy } from 'svelte'; - let onPointerDown; + let onPointerDown: ((e: PointerEvent) => void) | undefined; onMount(() => { onPointerDown = (e) => handleWindowPointerDown(e); document.addEventListener('pointerdown', onPointerDown, true); + if (visualViewportAware) { + window.visualViewport?.addEventListener('resize', scheduleSettledPositionUpdates); + window.visualViewport?.addEventListener('scroll', schedulePositionUpdate); + } }); onDestroy(() => { + if (positionFrame != null) cancelAnimationFrame(positionFrame); + for (const timer of settleTimers) window.clearTimeout(timer); if (onPointerDown) { document.removeEventListener('pointerdown', onPointerDown, true); } + if (visualViewportAware) { + window.visualViewport?.removeEventListener('resize', scheduleSettledPositionUpdates); + window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate); + } }); @@ -190,7 +294,8 @@ bind:this={contentEl} class={contentClass} role="menu" - style:max-height={maxHeight} + tabindex="-1" + style:max-height={resolvedMaxHeight} style:overflow-y="auto" transition:flyAndScale on:click={(e) => e.stopPropagation()}