mirror of
https://github.com/immich-app/immich.git
synced 2025-01-28 06:32:44 +01:00
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 cac29bac0d
.
* 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>
This commit is contained in:
parent
46fe60693e
commit
8d515adac5
5 changed files with 134 additions and 26 deletions
web/src/lib
components
asset-viewer
shared-components
i18n
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import TagAssetForm from '$lib/components/forms/tag-asset-form.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 { AppRoute } from '$lib/constants';
|
||||||
import { isSharedLink } from '$lib/utils';
|
import { isSharedLink } from '$lib/utils';
|
||||||
import { removeTag, tagAssets } from '$lib/utils/asset-utils';
|
import { removeTag, tagAssets } from '$lib/utils/asset-utils';
|
||||||
|
@ -76,5 +77,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
|
<Portal>
|
||||||
|
<TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
|
||||||
|
</Portal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import AlbumListItemDetails from './album-list-item-details.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 asset: AssetResponseDto;
|
||||||
export let albums: AlbumResponseDto[] = [];
|
export let albums: AlbumResponseDto[] = [];
|
||||||
|
@ -325,12 +326,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isShowChangeDate}
|
{#if isShowChangeDate}
|
||||||
<ChangeDate
|
<Portal>
|
||||||
initialDate={dateTime}
|
<ChangeDate
|
||||||
initialTimeZone={timeZone ?? ''}
|
initialDate={dateTime}
|
||||||
onConfirm={handleConfirmChangeDate}
|
initialTimeZone={timeZone ?? ''}
|
||||||
onCancel={() => (isShowChangeDate = false)}
|
onConfirm={handleConfirmChangeDate}
|
||||||
/>
|
onCancel={() => (isShowChangeDate = false)}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if asset.exifInfo?.fileSizeInByte}
|
{#if asset.exifInfo?.fileSizeInByte}
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
|
||||||
import { tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import type { FormEventHandler } from 'svelte/elements';
|
import type { FormEventHandler } from 'svelte/elements';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { focusOutside } from '$lib/actions/focus-outside';
|
import { focusOutside } from '$lib/actions/focus-outside';
|
||||||
|
@ -53,8 +53,28 @@
|
||||||
let selectedIndex: number | undefined;
|
let selectedIndex: number | undefined;
|
||||||
let optionRefs: HTMLElement[] = [];
|
let optionRefs: HTMLElement[] = [];
|
||||||
let input: HTMLInputElement;
|
let input: HTMLInputElement;
|
||||||
|
let bounds: DOMRect | undefined;
|
||||||
|
let dropdownDirection: 'bottom' | 'top' = 'bottom';
|
||||||
|
|
||||||
const inputId = `combobox-${id}`;
|
const inputId = `combobox-${id}`;
|
||||||
const listboxId = `listbox-${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()));
|
$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
|
|
||||||
|
@ -62,6 +82,23 @@
|
||||||
searchQuery = selectedOption ? selectedOption.label : '';
|
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 = () => {
|
const activate = () => {
|
||||||
isActive = true;
|
isActive = true;
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
|
@ -76,6 +113,7 @@
|
||||||
|
|
||||||
const openDropdown = () => {
|
const openDropdown = () => {
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
|
bounds = getInputPosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDropdown = () => {
|
const closeDropdown = () => {
|
||||||
|
@ -116,8 +154,67 @@
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
onSelect(selectedOption);
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:resize={onPositionChange} />
|
||||||
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
|
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
|
||||||
<div
|
<div
|
||||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||||
|
@ -150,7 +247,8 @@
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:this={input}
|
bind:this={input}
|
||||||
class:!pl-8={isActive}
|
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:cursor-pointer={!isActive}
|
||||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||||
id={inputId}
|
id={inputId}
|
||||||
|
@ -217,8 +315,16 @@
|
||||||
role="listbox"
|
role="listbox"
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
transition:fly={{ duration: 250 }}
|
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}
|
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"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
|
@ -228,7 +334,7 @@
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selectedIndex === 0}
|
aria-selected={selectedIndex === 0}
|
||||||
aria-disabled={true}
|
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}`}
|
id={`${listboxId}-${0}`}
|
||||||
on:click={() => closeDropdown()}
|
on:click={() => closeDropdown()}
|
||||||
>
|
>
|
||||||
|
@ -240,7 +346,7 @@
|
||||||
<li
|
<li
|
||||||
aria-selected={index === selectedIndex}
|
aria-selected={index === selectedIndex}
|
||||||
bind:this={optionRefs[index]}
|
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}`}
|
id={`${listboxId}-${index}`}
|
||||||
on:click={() => handleSelect(option)}
|
on:click={() => handleSelect(option)}
|
||||||
role="option"
|
role="option"
|
||||||
|
|
|
@ -68,28 +68,24 @@
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
>
|
>
|
||||||
<div
|
<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 }}
|
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
<div
|
<div class="immich-scrollbar overflow-y-auto pt-1" class:pb-4={isStickyBottom}>
|
||||||
class="immich-scrollbar overflow-y-auto max-h-[min(92dvh,64rem)] py-1"
|
|
||||||
class:scroll-pb-40={isStickyBottom}
|
|
||||||
class:sm:scroll-p-24={isStickyBottom}
|
|
||||||
>
|
|
||||||
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
||||||
<div class="px-5 pt-0">
|
<div class="px-5 pt-0">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -1194,7 +1194,7 @@
|
||||||
"tag_assets": "Tag assets",
|
"tag_assets": "Tag assets",
|
||||||
"tag_created": "Created tag: {tag}",
|
"tag_created": "Created tag: {tag}",
|
||||||
"tag_feature_description": "Browsing photos and videos grouped by logical tag topics",
|
"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}",
|
"tag_updated": "Updated tag: {tag}",
|
||||||
"tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
|
"tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
|
Loading…
Reference in a new issue