mirror of
https://github.com/immich-app/immich.git
synced 2026-02-14 04:47:57 +03:00
feat: fixed position dropdown menu
This commit is contained in:
@@ -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();
|
||||
</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"
|
||||
@@ -153,7 +238,8 @@
|
||||
autocomplete="off"
|
||||
bind:this={input}
|
||||
class:!pl-8={isActive}
|
||||
class:!rounded-b-none={isOpen}
|
||||
class:!rounded-b-none={isOpen && direction === 'bottom'}
|
||||
class:!rounded-t-none={isOpen && direction === 'top'}
|
||||
class:cursor-pointer={!isActive}
|
||||
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
|
||||
id={inputId}
|
||||
@@ -220,8 +306,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={direction === 'bottom'}
|
||||
class:rounded-t-xl={direction === 'top'}
|
||||
class:shadow={direction === 'bottom'}
|
||||
class:border={isOpen}
|
||||
style:top={position?.top}
|
||||
style:bottom={position?.bottom}
|
||||
style:left={position?.left}
|
||||
style:width={position?.width}
|
||||
style:max-height="min({position?.maxHeight},18rem)"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#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 @@
|
||||
<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={() => onSelect(option)}
|
||||
role="option"
|
||||
|
||||
@@ -68,13 +68,13 @@
|
||||
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(85dvh,44rem)] py-1">
|
||||
<div class="immich-scrollbar overflow-y-auto py-1">
|
||||
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
||||
<div class="px-5 pt-0">
|
||||
<slot />
|
||||
|
||||
Reference in New Issue
Block a user