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();
+