173 lines
5.1 KiB
Svelte
173 lines
5.1 KiB
Svelte
<script lang="ts">
|
|
import { createEventDispatcher } from 'svelte';
|
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
import { onMount } from 'svelte';
|
|
import { on } from 'svelte/events';
|
|
|
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
options?: { value: any; label:string }[];
|
|
selected?: any;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
disabled?: boolean;
|
|
class?: string;
|
|
onChange?: (value: any) => void;
|
|
}
|
|
|
|
let {
|
|
id = 'select-searchable',
|
|
options = [],
|
|
selected = $bindable(null),
|
|
placeholder = 'Select an option...',
|
|
searchPlaceholder = 'Search...',
|
|
disabled = false,
|
|
class: className = '',
|
|
...restProps
|
|
}: Props = $props();
|
|
|
|
const dispatch = createEventDispatcher<{ change: any }>();
|
|
|
|
let isOpen = $state(false);
|
|
let searchTerm = $state('');
|
|
let dropdownElement = $state<HTMLElement>();
|
|
let selectElement = $state<HTMLElement>();
|
|
let dropdownStyle = $state('');
|
|
|
|
let filteredOptions = $derived(
|
|
options.filter(option =>
|
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
);
|
|
|
|
let selectedLabel = $derived(
|
|
options.find(opt => opt.value === selected)?.label || ''
|
|
);
|
|
|
|
onMount(() => {
|
|
// Update dropdown position on mount
|
|
updateDropdownPosition();
|
|
});
|
|
|
|
function updateDropdownPosition() {
|
|
if (!selectElement) return;
|
|
const rect = selectElement.getBoundingClientRect();
|
|
dropdownStyle = `
|
|
position: fixed;
|
|
top: ${rect.bottom}px;
|
|
left: ${rect.left}px;
|
|
min-width: ${rect.width}px;
|
|
`;
|
|
}
|
|
|
|
function toggleDropdown() {
|
|
if (!disabled) {
|
|
isOpen = !isOpen;
|
|
if (isOpen) {
|
|
searchTerm = '';
|
|
// Use next tick to ensure the element is rendered before getting its position
|
|
Promise.resolve().then(updateDropdownPosition);
|
|
}
|
|
}
|
|
}
|
|
|
|
function selectOption(option: { value: any; label: string }) {
|
|
selected = option.value;
|
|
isOpen = false;
|
|
searchTerm = '';
|
|
dispatch('change', selected);
|
|
if (restProps.onChange) {
|
|
restProps.onChange(selected);
|
|
}
|
|
}
|
|
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (selectElement && !selectElement.contains(event.target as Node)) {
|
|
isOpen = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (isOpen) {
|
|
window.addEventListener('scroll', updateDropdownPosition, true);
|
|
window.addEventListener('resize', updateDropdownPosition);
|
|
}
|
|
return () => {
|
|
window.removeEventListener('scroll', updateDropdownPosition, true);
|
|
window.removeEventListener('resize', updateDropdownPosition);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onclick={handleClickOutside} />
|
|
|
|
<div
|
|
bind:this={selectElement}
|
|
{id}
|
|
class="form-control form-select select-container {className}"
|
|
class:disabled
|
|
class:show={isOpen}
|
|
onclick={toggleDropdown}
|
|
onkeydown={(e) => e.key === 'Enter' && toggleDropdown()}
|
|
role="combobox"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={isOpen}
|
|
tabindex={disabled ? -1 : 0}
|
|
{...restProps}
|
|
>
|
|
<span class:text-muted={!selected}>{selectedLabel || placeholder}</span>
|
|
|
|
{#if isOpen}
|
|
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
|
|
<div class="p-2">
|
|
<input
|
|
type="text"
|
|
class="form-control form-control-sm"
|
|
placeholder={searchPlaceholder}
|
|
bind:value={searchTerm}
|
|
onclick={(e) => e.stopPropagation()}
|
|
autofocus
|
|
/>
|
|
</div>
|
|
|
|
<div class="options-list">
|
|
{#each filteredOptions as option}
|
|
<button
|
|
type="button"
|
|
class="dropdown-item small"
|
|
class:active={option.value === selected}
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
selectOption(option);
|
|
}}
|
|
onkeydown={(e) => e.key === 'Enter' && selectOption(option)}
|
|
role="option"
|
|
aria-selected={option.value === selected}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
{/each}
|
|
{#if filteredOptions.length === 0}
|
|
<div class="dropdown-item text-muted disabled">No options found</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.select-container {
|
|
position: relative;
|
|
cursor: default;
|
|
}
|
|
|
|
.dropdown-menu {
|
|
/* position is now set dynamically */
|
|
z-index: 1000;
|
|
width: max-content; /* Allow dropdown to grow with its content */
|
|
}
|
|
|
|
.options-list {
|
|
max-height: 40vh;
|
|
overflow-y: auto;
|
|
}
|
|
</style>
|