From 8d515adac517c4871b33fba48cf37e25580e96e3 Mon Sep 17 00:00:00 2001
From: Ben <45583362+ben-basten@users.noreply.github.com>
Date: Wed, 25 Sep 2024 12:04:53 -0400
Subject: [PATCH] feat(web): fixed combobox positioning (#12848)

* fix(web): modal sticky bottom scrolling

* chore: minor styling tweaks

* wip: add portal so modals show on Safari in detail panel

* feat: fixed position dropdown menu

* chore: refactoring and cleanup

* feat: zooming and virtual keyboard working for iPadOS/Safari

* Revert "feat: zooming and virtual keyboard working for iPadOS/Safari"

This reverts commit cac29bac0df9112cec1d4c66af82dd343081e08a.

* wip: minor code cleanup

* wip: recover from visual viewport changes

* wip: ease in a little more visualviewport magic

* wip: code cleanup

* fix: only show dropdown above when viewport is zoomed out

* fix: code review suggestions for code style

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* fix: better variable naming

* chore: better documentation for the bottom breakpoint

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
---
 .../asset-viewer/detail-panel-tags.svelte     |   5 +-
 .../asset-viewer/detail-panel.svelte          |  15 ++-
 .../shared-components/combobox.svelte         | 116 +++++++++++++++++-
 .../full-screen-modal.svelte                  |  22 ++--
 web/src/lib/i18n/en.json                      |   2 +-
 5 files changed, 134 insertions(+), 26 deletions(-)

diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
index 434682f73e..449f61183f 100644
--- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
   import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
+  import Portal from '$lib/components/shared-components/portal/portal.svelte';
   import { AppRoute } from '$lib/constants';
   import { isSharedLink } from '$lib/utils';
   import { removeTag, tagAssets } from '$lib/utils/asset-utils';
@@ -76,5 +77,7 @@
 {/if}
 
 {#if isOpen}
-  <TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
+  <Portal>
+    <TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
+  </Portal>
 {/if}
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index ee2da9fc3f..9e32927fc3 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -44,6 +44,7 @@
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import UserAvatar from '../shared-components/user-avatar.svelte';
   import AlbumListItemDetails from './album-list-item-details.svelte';
+  import Portal from '$lib/components/shared-components/portal/portal.svelte';
 
   export let asset: AssetResponseDto;
   export let albums: AlbumResponseDto[] = [];
@@ -325,12 +326,14 @@
     {/if}
 
     {#if isShowChangeDate}
-      <ChangeDate
-        initialDate={dateTime}
-        initialTimeZone={timeZone ?? ''}
-        onConfirm={handleConfirmChangeDate}
-        onCancel={() => (isShowChangeDate = false)}
-      />
+      <Portal>
+        <ChangeDate
+          initialDate={dateTime}
+          initialTimeZone={timeZone ?? ''}
+          onConfirm={handleConfirmChangeDate}
+          onCancel={() => (isShowChangeDate = false)}
+        />
+      </Portal>
     {/if}
 
     {#if asset.exifInfo?.fileSizeInByte}
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index 241f937be0..c89d0d34f2 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 { tick } from 'svelte';
+  import { onMount, tick } from 'svelte';
   import type { FormEventHandler } from 'svelte/elements';
   import { shortcuts } from '$lib/actions/shortcut';
   import { focusOutside } from '$lib/actions/focus-outside';
@@ -53,8 +53,28 @@
   let selectedIndex: number | undefined;
   let optionRefs: HTMLElement[] = [];
   let input: HTMLInputElement;
+  let bounds: DOMRect | undefined;
+  let dropdownDirection: 'bottom' | 'top' = 'bottom';
+
   const inputId = `combobox-${id}`;
   const listboxId = `listbox-${id}`;
+  /**
+   * Buffer distance between the dropdown and top/bottom of the viewport.
+   */
+  const dropdownOffset = 15;
+  /**
+   * Minimum space required for the dropdown to be displayed at the bottom of the input.
+   */
+  const bottomBreakpoint = 225;
+  const observer = new IntersectionObserver(
+    (entries) => {
+      const inputEntry = entries[0];
+      if (inputEntry.intersectionRatio < 1) {
+        isOpen = false;
+      }
+    },
+    { threshold: 0.5 },
+  );
 
   $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
 
@@ -62,6 +82,23 @@
     searchQuery = selectedOption ? selectedOption.label : '';
   }
 
+  $: position = calculatePosition(bounds);
+
+  onMount(() => {
+    observer.observe(input);
+    const scrollableAncestor = input.closest('.overflow-y-auto, .overflow-y-scroll');
+    scrollableAncestor?.addEventListener('scroll', onPositionChange);
+    window.visualViewport?.addEventListener('resize', onPositionChange);
+    window.visualViewport?.addEventListener('scroll', onPositionChange);
+
+    return () => {
+      observer.disconnect();
+      scrollableAncestor?.removeEventListener('scroll', onPositionChange);
+      window.visualViewport?.removeEventListener('resize', onPositionChange);
+      window.visualViewport?.removeEventListener('scroll', onPositionChange);
+    };
+  });
+
   const activate = () => {
     isActive = true;
     searchQuery = '';
@@ -76,6 +113,7 @@
 
   const openDropdown = () => {
     isOpen = true;
+    bounds = getInputPosition();
   };
 
   const closeDropdown = () => {
@@ -116,8 +154,67 @@
     searchQuery = '';
     onSelect(selectedOption);
   };
+
+  const calculatePosition = (boundary: DOMRect | undefined) => {
+    const visualViewport = window.visualViewport;
+    dropdownDirection = getComboboxDirection(boundary, visualViewport);
+
+    if (!boundary) {
+      return;
+    }
+
+    const left = boundary.left + (visualViewport?.offsetLeft || 0);
+    const offsetTop = visualViewport?.offsetTop || 0;
+
+    if (dropdownDirection === 'top') {
+      return {
+        bottom: `${window.innerHeight - boundary.top - offsetTop}px`,
+        left: `${left}px`,
+        width: `${boundary.width}px`,
+        maxHeight: maxHeight(boundary.top - dropdownOffset),
+      };
+    }
+
+    const viewportHeight = visualViewport?.height || 0;
+    const availableHeight = viewportHeight - boundary.bottom;
+    return {
+      top: `${boundary.bottom + offsetTop}px`,
+      left: `${left}px`,
+      width: `${boundary.width}px`,
+      maxHeight: maxHeight(availableHeight - dropdownOffset),
+    };
+  };
+
+  const maxHeight = (size: number) => `min(${size}px,18rem)`;
+
+  const onPositionChange = () => {
+    if (!isOpen) {
+      return;
+    }
+    bounds = getInputPosition();
+  };
+
+  const getComboboxDirection = (
+    boundary: DOMRect | undefined,
+    visualViewport: VisualViewport | null,
+  ): 'bottom' | 'top' => {
+    if (!boundary) {
+      return 'bottom';
+    }
+
+    const visualHeight = visualViewport?.height || 0;
+    const heightBelow = visualHeight - boundary.bottom;
+    const heightAbove = boundary.top;
+
+    const isViewportScaled = visualHeight && Math.floor(visualHeight) !== Math.floor(window.innerHeight);
+
+    return heightBelow <= bottomBreakpoint && heightAbove > heightBelow && !isViewportScaled ? 'top' : 'bottom';
+  };
+
+  const getInputPosition = () => input?.getBoundingClientRect();
 </script>
 
+<svelte:window on:resize={onPositionChange} />
 <label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
 <div
   class="relative w-full dark:text-gray-300 text-gray-700 text-base"
@@ -150,7 +247,8 @@
       autocomplete="off"
       bind:this={input}
       class:!pl-8={isActive}
-      class:!rounded-b-none={isOpen}
+      class:!rounded-b-none={isOpen && dropdownDirection === 'bottom'}
+      class:!rounded-t-none={isOpen && dropdownDirection === 'top'}
       class:cursor-pointer={!isActive}
       class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
       id={inputId}
@@ -217,8 +315,16 @@
     role="listbox"
     id={listboxId}
     transition:fly={{ duration: 250 }}
-    class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-[10000]"
+    class="fixed text-left text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900 z-[10000]"
+    class:rounded-b-xl={dropdownDirection === 'bottom'}
+    class:rounded-t-xl={dropdownDirection === 'top'}
+    class:shadow={dropdownDirection === 'bottom'}
     class:border={isOpen}
+    style:top={position?.top}
+    style:bottom={position?.bottom}
+    style:left={position?.left}
+    style:width={position?.width}
+    style:max-height={position?.maxHeight}
     tabindex="-1"
   >
     {#if isOpen}
@@ -228,7 +334,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()}
         >
@@ -240,7 +346,7 @@
         <li
           aria-selected={index === selectedIndex}
           bind:this={optionRefs[index]}
-          class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer 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 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
           id={`${listboxId}-${index}`}
           on:click={() => handleSelect(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 b5b21f0c23..ececa25b1e 100644
--- a/web/src/lib/components/shared-components/full-screen-modal.svelte
+++ b/web/src/lib/components/shared-components/full-screen-modal.svelte
@@ -68,28 +68,24 @@
   use:focusTrap
 >
   <div
-    class="z-[9999] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
+    class="flex flex-col max-h-[min(95dvh,60rem)] z-[9999] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
     use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
     tabindex="-1"
     aria-modal="true"
     aria-labelledby={titleId}
   >
-    <div
-      class="immich-scrollbar overflow-y-auto max-h-[min(92dvh,64rem)] py-1"
-      class:scroll-pb-40={isStickyBottom}
-      class:sm:scroll-p-24={isStickyBottom}
-    >
+    <div class="immich-scrollbar overflow-y-auto pt-1" class:pb-4={isStickyBottom}>
       <ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
       <div class="px-5 pt-0">
         <slot />
       </div>
-      {#if isStickyBottom}
-        <div
-          class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky -bottom-[4px] py-2 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500 shadow z-[9999]"
-        >
-          <slot name="sticky-bottom" />
-        </div>
-      {/if}
     </div>
+    {#if isStickyBottom}
+      <div
+        class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky pt-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500"
+      >
+        <slot name="sticky-bottom" />
+      </div>
+    {/if}
   </div>
 </section>
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index aaa3c77e2b..534ac08636 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -1194,7 +1194,7 @@
   "tag_assets": "Tag assets",
   "tag_created": "Created tag: {tag}",
   "tag_feature_description": "Browsing photos and videos grouped by logical tag topics",
-  "tag_not_found_question": "Cannot find a tag? Create one <link>here</link>",
+  "tag_not_found_question": "Cannot find a tag? <link>Create a new tag.</link>",
   "tag_updated": "Updated tag: {tag}",
   "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
   "tags": "Tags",