From 48e400939eb937f0195b988b5974fd8fbcaa8b78 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 10 Mar 2026 17:25:46 -0700 Subject: [PATCH] combobox: use popover attachment to manage positioning --- src/lib/Combobox.svelte | 150 +++++++++++++--------------------------- src/routes/+page.svelte | 2 +- 2 files changed, 49 insertions(+), 103 deletions(-) diff --git a/src/lib/Combobox.svelte b/src/lib/Combobox.svelte index 1683d5b..d12316b 100644 --- a/src/lib/Combobox.svelte +++ b/src/lib/Combobox.svelte @@ -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,38 @@ 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 + } + + onopenchange?.(isOpen); + } + }); let valid = $state(true); let searchValue = $state(''); @@ -227,8 +260,6 @@ let searchContainer = $state(null); let pickerContainer = $state(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[] = []; @@ -327,35 +358,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 +404,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 +416,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 +487,19 @@ // close picker if clicked outside const handleWindowClick: MouseEventHandler = (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); - }); - + @@ -567,7 +519,7 @@ role="listbox" onkeydown={(e) => { if (e.key === 'Escape') { - closePicker(); + popover.setOpen(false); searchInput?.focus(); } }} @@ -583,6 +535,7 @@ }} tabindex="0" {@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })} + {...popover.floating()} > {#each filteredItems as opt (opt.value)} {@render option(opt)} @@ -691,7 +644,7 @@ {#snippet searchInputBox(caret: boolean = true)} -
+
{#if iconVisible}
{ - if (!open) { - setTimeout(() => { - searchInput?.select(); - }, 100); - } - - openPicker(); + popover.setOpen(true); }} onkeydown={(e) => { if (!searchInput) return; @@ -747,17 +694,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 +751,7 @@ { - open = !open; - if (open) searchInput?.focus(); + popover.setOpen(!open); }} /> {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 74db7ea..4db1ff9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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}