combobox: use popover attachment to manage positioning

This commit is contained in:
Elijah Duffy
2026-03-10 17:25:46 -07:00
parent 9fbc6f6301
commit 465cfe2c14
2 changed files with 50 additions and 103 deletions

View File

@@ -52,6 +52,7 @@
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import type { Attachment } from 'svelte/attachments'; import type { Attachment } from 'svelte/attachments';
import { Popover } from './floating.svelte';
interface Props { interface Props {
/** /**
@@ -217,6 +218,39 @@
const id = $derived(generateIdentifier('combobox', name)); const id = $derived(generateIdentifier('combobox', name));
const searchKeySet = $derived(new Set(searchKeys)); const searchKeySet = $derived(new Set(searchKeys));
const conditionalUse = $derived(use ? use : () => {}); 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 valid = $state(true);
let searchValue = $state(''); let searchValue = $state('');
@@ -227,8 +261,6 @@
let searchContainer = $state<HTMLDivElement | null>(null); let searchContainer = $state<HTMLDivElement | null>(null);
let pickerContainer = $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 */ /** options filtered by search value and searchKeys */
const filteredItems = $derived.by(() => { const filteredItems = $derived.by(() => {
let keys: KeyOption<ComboboxOption>[] = []; let keys: KeyOption<ComboboxOption>[] = [];
@@ -327,35 +359,11 @@
}; };
let lazyApplied = false; 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 */ /** updates the value of the combobox and triggers any callbacks, including closing the picker */
const updateValue = (newValue: ComboboxOption) => { const updateValue = (newValue: ComboboxOption) => {
if (!stateless) value = newValue; if (!stateless) value = newValue;
closePicker(); popover.setOpen(false);
onchange?.(newValue); onchange?.(newValue);
}; };
@@ -397,7 +405,6 @@
if ((opts.mode === 'match' || opts.options.length === 0) && opts.matchElem) { if ((opts.mode === 'match' || opts.options.length === 0) && opts.matchElem) {
// match width if explicitly enabled, or no options to average // match width if explicitly enabled, or no options to average
elem.style.width = opts.matchElem.offsetWidth + 'px'; elem.style.width = opts.matchElem.offsetWidth + 'px';
console.log('matched', elem.style.width, 'to', opts.matchElem.offsetWidth);
} else { } else {
// otherwise, set width based on average content width // otherwise, set width based on average content width
let val = '10ch'; let val = '10ch';
@@ -410,53 +417,10 @@
} }
} }
elem.style.width = val; 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 */ /** scrolls the picker to the highlighted item */
const scrollToHighlighted = () => { const scrollToHighlighted = () => {
if (!pickerContainer || !highlighted) return; if (!pickerContainer || !highlighted) return;
@@ -524,30 +488,19 @@
// close picker if clicked outside // close picker if clicked outside
const handleWindowClick: MouseEventHandler<Window> = (e) => { const handleWindowClick: MouseEventHandler<Window> = (e) => {
if (!searchContainer || !pickerContainer) { if (!open || !searchContainer || !pickerContainer) return;
return;
}
if ( if (
!open || e.target instanceof Node &&
searchContainer.contains(e.target as Node) || !searchContainer.contains(e.target) &&
pickerContainer.contains(e.target as Node) !pickerContainer.contains(e.target)
) { ) {
return; popover.setOpen(false);
} }
closePicker();
}; };
onMount(() => {
// set initial picker position after load
setTimeout(() => {
updatePickerRect();
}, 500);
});
</script> </script>
<svelte:window onresize={updatePickerRect} onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<!-- Combobox picker --> <!-- Combobox picker -->
<Portal target="body"> <Portal target="body">
@@ -567,7 +520,7 @@
role="listbox" role="listbox"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closePicker(); popover.setOpen(false);
searchInput?.focus(); searchInput?.focus();
} }
}} }}
@@ -583,6 +536,7 @@
}} }}
tabindex="0" tabindex="0"
{@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })} {@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })}
{...popover.floating()}
> >
{#each filteredItems as opt (opt.value)} {#each filteredItems as opt (opt.value)}
{@render option(opt)} {@render option(opt)}
@@ -691,7 +645,7 @@
<!-- Search input box --> <!-- Search input box -->
{#snippet searchInputBox(caret: boolean = true)} {#snippet searchInputBox(caret: boolean = true)}
<div class="relative"> <div class="relative" {...popover.reference()}>
<!-- Persistant OR selected option icon, if visible --> <!-- Persistant OR selected option icon, if visible -->
{#if iconVisible} {#if iconVisible}
<div <div
@@ -731,13 +685,7 @@
autocomplete="off" autocomplete="off"
bind:ref={searchInput} bind:ref={searchInput}
onclick={() => { onclick={() => {
if (!open) { popover.setOpen(true);
setTimeout(() => {
searchInput?.select();
}, 100);
}
openPicker();
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (!searchInput) return; if (!searchInput) return;
@@ -747,17 +695,17 @@
updateValue(highlighted); updateValue(highlighted);
} }
if (e.key === 'Enter') { if (e.key === 'Enter') {
closePicker(); popover.setOpen(false);
e.preventDefault(); e.preventDefault();
} }
return; return;
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
closePicker(); popover.setOpen(false);
return; return;
} }
// open the picker // open the picker
openPicker(); popover.setOpen(true);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
searching = false; searching = false;
@@ -804,8 +752,7 @@
<CaretUpDown <CaretUpDown
class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2" class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2"
onclick={() => { onclick={() => {
open = !open; popover.setOpen(!open);
if (open) searchInput?.focus();
}} }}
/> />
{/if} {/if}

View File

@@ -184,7 +184,7 @@
]} ]}
onchange={(e) => console.log('Selected:', e.value)} onchange={(e) => console.log('Selected:', e.value)}
onvalidate={(e) => console.log('Validation:', e.detail)} onvalidate={(e) => console.log('Validation:', e.detail)}
pickerWidth="max-match" pickerWidth="longest"
> >
{#snippet labelRender(opt: ComboboxOption)} {#snippet labelRender(opt: ComboboxOption)}
Processed {opt.label} Processed {opt.label}