combobox: use popover attachment to manage positioning
This commit is contained in:
@@ -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,38 @@
|
|||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
onopenchange?.(isOpen);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let valid = $state(true);
|
let valid = $state(true);
|
||||||
let searchValue = $state('');
|
let searchValue = $state('');
|
||||||
@@ -227,8 +260,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 +358,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 +404,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 +416,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 +487,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 +519,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 +535,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 +644,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 +684,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 +694,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 +751,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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user