combobox: use popover attachment to manage positioning
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Popover } from './floating.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -217,6 +218,39 @@
|
||||
const id = $derived(generateIdentifier('combobox', name));
|
||||
const searchKeySet = $derived(new Set(searchKeys));
|
||||
const conditionalUse = $derived(use ? use : () => {});
|
||||
const popover = new Popover({
|
||||
interaction: 'manual',
|
||||
placement: 'bottom-start',
|
||||
offset: 0,
|
||||
ontoggle: async (isOpen) => {
|
||||
open = isOpen;
|
||||
|
||||
if (isOpen) {
|
||||
scrollToHighlighted();
|
||||
|
||||
// focus & select search input after 100ms
|
||||
setTimeout(() => {
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
}, 100);
|
||||
|
||||
// handle lazy loading behaviour
|
||||
if (lazy === 'always' || (lazy === true && !lazyApplied)) {
|
||||
lazyApplied = true;
|
||||
loading = true;
|
||||
await onlazy?.();
|
||||
loading = false;
|
||||
}
|
||||
} else {
|
||||
searchValue = ''; // clear search value for next time picker opens
|
||||
searching = false; // reset searching state
|
||||
highlighted = value; // reset highlighted item to current value
|
||||
}
|
||||
|
||||
console.log('combobox open', isOpen);
|
||||
onopenchange?.(isOpen);
|
||||
}
|
||||
});
|
||||
|
||||
let valid = $state(true);
|
||||
let searchValue = $state('');
|
||||
@@ -227,8 +261,6 @@
|
||||
let searchContainer = $state<HTMLDivElement | null>(null);
|
||||
let pickerContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// TODO: there's something weird with dropdown placement on narrow screens
|
||||
|
||||
/** options filtered by search value and searchKeys */
|
||||
const filteredItems = $derived.by(() => {
|
||||
let keys: KeyOption<ComboboxOption>[] = [];
|
||||
@@ -327,35 +359,11 @@
|
||||
};
|
||||
|
||||
let lazyApplied = false;
|
||||
/** opens combobox picker and propagates any necessary events */
|
||||
const openPicker = async () => {
|
||||
open = true;
|
||||
|
||||
updatePickerRect(); // update picker position
|
||||
scrollToHighlighted(); // scroll to highlighted item
|
||||
onopenchange?.(true); // trigger onopenchange event if defined
|
||||
|
||||
// handle lazy loading behaviour
|
||||
if (lazy === 'always' || (lazy === true && !lazyApplied)) {
|
||||
lazyApplied = true;
|
||||
loading = true;
|
||||
await onlazy?.();
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
/** closes combobox picker and propagates any necessary events */
|
||||
const closePicker = () => {
|
||||
open = false;
|
||||
searchValue = ''; // clear search value for next time picker opens
|
||||
searching = false; // reset searching state
|
||||
highlighted = value; // reset highlighted item to current value
|
||||
onopenchange?.(false); // trigger onopenchange event if defined
|
||||
};
|
||||
|
||||
/** updates the value of the combobox and triggers any callbacks, including closing the picker */
|
||||
const updateValue = (newValue: ComboboxOption) => {
|
||||
if (!stateless) value = newValue;
|
||||
closePicker();
|
||||
popover.setOpen(false);
|
||||
onchange?.(newValue);
|
||||
};
|
||||
|
||||
@@ -397,7 +405,6 @@
|
||||
if ((opts.mode === 'match' || opts.options.length === 0) && opts.matchElem) {
|
||||
// match width if explicitly enabled, or no options to average
|
||||
elem.style.width = opts.matchElem.offsetWidth + 'px';
|
||||
console.log('matched', elem.style.width, 'to', opts.matchElem.offsetWidth);
|
||||
} else {
|
||||
// otherwise, set width based on average content width
|
||||
let val = '10ch';
|
||||
@@ -410,53 +417,10 @@
|
||||
}
|
||||
}
|
||||
elem.style.width = val;
|
||||
console.log('averaged', val);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/** updates the position of the picker */
|
||||
const updatePickerRect = async () => {
|
||||
if (!searchContainer || !pickerContainer) {
|
||||
await tick();
|
||||
if (!searchContainer || !pickerContainer) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = pickerContainer;
|
||||
const target = searchContainer;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
// choose whether the overlay should be above or below the target
|
||||
const availableSpaceBelow = window.innerHeight - targetRect.bottom;
|
||||
const availableSpaceAbove = targetRect.top;
|
||||
const outerMargin = 24;
|
||||
|
||||
if (availableSpaceBelow < availableSpaceAbove) {
|
||||
// overlay should be above the target
|
||||
overlay.style.bottom = `${window.innerHeight - targetRect.top - window.scrollY}px`;
|
||||
overlay.style.top = 'auto';
|
||||
overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`;
|
||||
pickerPosition = 'top';
|
||||
overlay.dataset.side = 'top';
|
||||
} else {
|
||||
// overlay should be below the target
|
||||
overlay.style.top = `${targetRect.bottom + window.scrollY}px`;
|
||||
overlay.style.bottom = 'auto';
|
||||
overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`;
|
||||
pickerPosition = 'bottom';
|
||||
overlay.dataset.side = 'bottom';
|
||||
}
|
||||
|
||||
// set overlay left position
|
||||
overlay.style.left = `${targetRect.left}px`;
|
||||
};
|
||||
|
||||
/** scrolls the picker to the highlighted item */
|
||||
const scrollToHighlighted = () => {
|
||||
if (!pickerContainer || !highlighted) return;
|
||||
@@ -524,30 +488,19 @@
|
||||
|
||||
// close picker if clicked outside
|
||||
const handleWindowClick: MouseEventHandler<Window> = (e) => {
|
||||
if (!searchContainer || !pickerContainer) {
|
||||
return;
|
||||
}
|
||||
if (!open || !searchContainer || !pickerContainer) return;
|
||||
|
||||
if (
|
||||
!open ||
|
||||
searchContainer.contains(e.target as Node) ||
|
||||
pickerContainer.contains(e.target as Node)
|
||||
e.target instanceof Node &&
|
||||
!searchContainer.contains(e.target) &&
|
||||
!pickerContainer.contains(e.target)
|
||||
) {
|
||||
return;
|
||||
popover.setOpen(false);
|
||||
}
|
||||
|
||||
closePicker();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// set initial picker position after load
|
||||
setTimeout(() => {
|
||||
updatePickerRect();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={updatePickerRect} onclick={handleWindowClick} />
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<!-- Combobox picker -->
|
||||
<Portal target="body">
|
||||
@@ -567,7 +520,7 @@
|
||||
role="listbox"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closePicker();
|
||||
popover.setOpen(false);
|
||||
searchInput?.focus();
|
||||
}
|
||||
}}
|
||||
@@ -583,6 +536,7 @@
|
||||
}}
|
||||
tabindex="0"
|
||||
{@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })}
|
||||
{...popover.floating()}
|
||||
>
|
||||
{#each filteredItems as opt (opt.value)}
|
||||
{@render option(opt)}
|
||||
@@ -691,7 +645,7 @@
|
||||
|
||||
<!-- Search input box -->
|
||||
{#snippet searchInputBox(caret: boolean = true)}
|
||||
<div class="relative">
|
||||
<div class="relative" {...popover.reference()}>
|
||||
<!-- Persistant OR selected option icon, if visible -->
|
||||
{#if iconVisible}
|
||||
<div
|
||||
@@ -731,13 +685,7 @@
|
||||
autocomplete="off"
|
||||
bind:ref={searchInput}
|
||||
onclick={() => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
searchInput?.select();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
openPicker();
|
||||
popover.setOpen(true);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (!searchInput) return;
|
||||
@@ -747,17 +695,17 @@
|
||||
updateValue(highlighted);
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
closePicker();
|
||||
popover.setOpen(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
} else if (e.key === 'Escape') {
|
||||
closePicker();
|
||||
popover.setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// open the picker
|
||||
openPicker();
|
||||
popover.setOpen(true);
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
searching = false;
|
||||
@@ -804,8 +752,7 @@
|
||||
<CaretUpDown
|
||||
class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2"
|
||||
onclick={() => {
|
||||
open = !open;
|
||||
if (open) searchInput?.focus();
|
||||
popover.setOpen(!open);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
]}
|
||||
onchange={(e) => console.log('Selected:', e.value)}
|
||||
onvalidate={(e) => console.log('Validation:', e.detail)}
|
||||
pickerWidth="max-match"
|
||||
pickerWidth="longest"
|
||||
>
|
||||
{#snippet labelRender(opt: ComboboxOption)}
|
||||
Processed {opt.label}
|
||||
|
||||
Reference in New Issue
Block a user