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",