leaflet_svelte/src/lib/components/SelectSearchable.svelte
2025-07-05 23:04:29 +08:00

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>