phonograph/svelte/src/combobox.svelte

91 lines
2.1 KiB
Svelte
Raw Normal View History

2025-09-08 15:56:57 -07:00
<script lang="ts">
type CssClass = string | (string | false | null | undefined)[];
type Props = {
completions: string[];
popover_class?: CssClass;
search_input_class?: CssClass;
search_input_element?: HTMLInputElement;
search_value: string;
value: string;
};
let {
completions,
popover_class,
search_input_class,
search_input_element = $bindable(),
search_value = $bindable(),
value = $bindable(),
}: Props = $props();
let focused = $state(false);
let popover_element = $state<HTMLDivElement | undefined>();
function handle_component_focusin() {
focused = true;
popover_element?.showPopover();
}
function handle_component_focusout() {
focused = false;
setTimeout(() => {
// TODO: There's still an edge case, where a click with a
// mousedown-to-mouseup duration greater than the delay here will cause
// the popover to hide.
if (!focused) {
popover_element?.hidePopover();
}
}, 250);
}
function handle_completion_click(completion: string) {
value = completion;
search_input_element?.focus();
popover_element?.hidePopover();
}
function handle_search_keydown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
popover_element?.hidePopover();
}
}
function handle_search_input() {
popover_element?.showPopover();
}
</script>
<div
class="combobox__container"
onfocusin={handle_component_focusin}
onfocusout={handle_component_focusout}
>
<input
bind:this={search_input_element}
bind:value={search_value}
class={search_input_class}
oninput={handle_search_input}
onkeydown={handle_search_keydown}
type="text"
/>
<div
bind:this={popover_element}
class={popover_class ?? "combobox__popover"}
popover="manual"
role="listbox"
>
{#each completions as completion}
<button
aria-selected={value === completion}
class="combobox__completion"
onclick={() => handle_completion_click(completion)}
role="option"
type="button"
>
{completion}
</button>
{/each}
</div>
</div>