2 Commits

Author SHA1 Message Date
Elijah Duffy
69ed04b499 1.1.0 2026-03-10 17:29:11 -07:00
Elijah Duffy
48e400939e combobox: use popover attachment to manage positioning 2026-03-10 17:28:45 -07:00
3 changed files with 50 additions and 104 deletions

View File

@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
},
"version": "1.0.4",
"version": "1.1.0",
"scripts": {
"dev": "vite dev",
"build": "vite build && pnpm run prepack",

View File

@@ -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<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 +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<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 +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 @@
<!-- 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 +684,7 @@
autocomplete="off"
bind:ref={searchInput}
onclick={() => {
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 @@
<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}

View File

@@ -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}