From e63c7581b8da94773b8ee0a46bda10ec383d07b5 Mon Sep 17 00:00:00 2001 From: ben-basten <45583362+ben-basten@users.noreply.github.com> Date: Tue, 17 Sep 2024 07:42:01 -0400 Subject: [PATCH] feat: fixed position dropdown menu --- .../shared-components/combobox.svelte | 104 +++++++++++++++++- .../full-screen-modal.svelte | 4 +- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7c71fe8aea..09377362db 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -21,7 +21,7 @@ import { fly } from 'svelte/transition'; import Icon from '$lib/components/elements/icon.svelte'; import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; - import { createEventDispatcher, tick } from 'svelte'; + import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'; import type { FormEventHandler } from 'svelte/elements'; import { shortcuts } from '$lib/actions/shortcut'; import { focusOutside } from '$lib/actions/focus-outside'; @@ -52,8 +52,25 @@ let selectedIndex: number | undefined; let optionRefs: HTMLElement[] = []; let input: HTMLInputElement; + let bounds: DOMRect | undefined; + let scrollable: Element | null; + let direction: 'bottom' | 'top' = 'bottom'; const inputId = `combobox-${id}`; const listboxId = `listbox-${id}`; + const dropdownOffset = 15; + const observer = new IntersectionObserver( + (entries) => { + if (!isOpen) { + return; + } + for (const entry of entries) { + if (entry.intersectionRatio < 1) { + isOpen = false; + } + } + }, + { threshold: 0.5 }, + ); $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); @@ -61,6 +78,25 @@ searchQuery = selectedOption ? selectedOption.label : ''; } + $: position = calculatePosition(bounds); + + $: { + if (input) { + scrollable?.removeEventListener('scroll', onPositionChange); + scrollable = input.closest('.overflow-y-auto, .overflow-y-scroll'); + scrollable?.addEventListener('scroll', onPositionChange); + } + } + + onMount(() => { + observer.observe(input); + }); + + onDestroy(() => { + scrollable?.removeEventListener('scroll', onPositionChange); + observer.disconnect(); + }); + const dispatch = createEventDispatcher<{ select: ComboBoxOption | undefined; }>(); @@ -79,6 +115,7 @@ const openDropdown = () => { isOpen = true; + bounds = getInputPosition(); }; const closeDropdown = () => { @@ -119,8 +156,56 @@ searchQuery = ''; dispatch('select', selectedOption); }; + + const calculatePosition = (boundary: DOMRect | undefined) => { + direction = getComboboxDirection(boundary); + + if (!boundary) { + return undefined; + } + + const viewportHeight = window.innerHeight; + + if (direction === 'top') { + return { + bottom: `${viewportHeight - boundary.top}px`, + left: `${boundary.left}px`, + width: `${boundary.width}px`, + maxHeight: `${boundary.top - dropdownOffset}px`, + }; + } + + const availableHeight = viewportHeight - boundary.bottom; + return { + top: `${boundary.bottom}px`, + left: `${boundary.left}px`, + width: `${boundary.width}px`, + maxHeight: `${availableHeight - dropdownOffset}px`, + }; + }; + + const onPositionChange = () => { + if (!isOpen) { + return; + } + bounds = getInputPosition(); + }; + + const getComboboxDirection = (boundary: DOMRect | undefined): 'bottom' | 'top' => { + if (!boundary) { + return 'bottom'; + } + + const viewportHeight = window.innerHeight; + const availableHeight = viewportHeight - boundary.bottom; + + return availableHeight > 150 ? 'bottom' : 'top'; + }; + + const getInputPosition = () => input?.getBoundingClientRect(); +
{#if isOpen} @@ -231,7 +325,7 @@ role="option" aria-selected={selectedIndex === 0} aria-disabled={true} - class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" + class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} on:click={() => closeDropdown()} > @@ -243,7 +337,7 @@
  • onSelect(option)} role="option" diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index 1726720825..d1a1d514e5 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -68,13 +68,13 @@ use:focusTrap >
    -
    +