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