Skip to content

Instantly share code, notes, and snippets.

@GauBen
Created March 28, 2024 09:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save GauBen/9f944a67adc3e8b4bbf099bda35c6f8e to your computer and use it in GitHub Desktop.
Save GauBen/9f944a67adc3e8b4bbf099bda35c6f8e to your computer and use it in GitHub Desktop.
<script lang="ts">
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';
import { createEventDispatcher, onMount } from 'svelte';
/** A simple list of strings for now, but we might need more complicated structures in the future. */
export let options: string[];
/** Input element to attach the datalist to. */
export let input: HTMLInputElement;
/** Input value. */
let value: string | undefined;
const updateValue = () => {
value = input.value;
};
$: filtered = options.filter((option) => !value || option.includes(value));
let datalist: HTMLDivElement;
let width: number;
let top: number;
let left: number;
/** Is the datalist visible? */
let visible = false;
/** Can the datalist receive inputs? */
let interactive = false;
/** Option currently in focus. */
let index: number | undefined;
const show = () => {
visible = true;
};
const hide = () => {
visible = false;
// Hiding the datalist also resets the option focus
index = undefined;
};
const keydown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown': {
// On arrow down, show the datalist and select the next option
event.preventDefault();
show();
index = index === undefined ? 0 : Math.min(index + 1, filtered.length - 1);
break;
}
case 'ArrowUp': {
// On arrow up, select the previous option
event.preventDefault();
if (index !== undefined) index = Math.max(index - 1, 0);
break;
}
case 'Enter': {
// On enter, pick the selected option and hide the datalist
if (index !== undefined) {
event.preventDefault();
dispatch('pick', filtered[index]);
}
hide();
break;
}
case 'Escape': {
// On escape, hide the datalist
hide();
break;
}
}
};
/** Updates the datalist positions with floating-ui. */
const updatePosition = async () => {
({ width } = input.getBoundingClientRect());
({ x: left, y: top } = await computePosition(input, datalist, {
placement: 'bottom',
middleware: [flip(), offset(8)],
}));
};
onMount(() => {
input.addEventListener('focus', show, { passive: true });
input.addEventListener('blur', hide, { passive: true });
input.addEventListener('keydown', keydown);
input.addEventListener('input', updateValue, { passive: true });
const cleanup = autoUpdate(input, datalist, updatePosition);
return () => {
cleanup();
input?.removeEventListener('focus', show);
input?.removeEventListener('blur', hide);
input?.removeEventListener('keydown', keydown);
input?.removeEventListener('input', updateValue);
};
});
const dispatch = createEventDispatcher<{ pick: string }>();
</script>
<div
bind:this={datalist}
class="datalist"
data-hidden={!visible || filtered.length === 0}
style:--top="{top}px"
style:--left="{left}px"
style:--width="{width}px"
style:pointer-events={interactive ? 'auto' : 'none'}
on:transitionend={() => {
interactive = visible;
}}
>
{#each filtered as option, i}
<!-- Keyboard events are properly handled above -->
<!-- svelte-ignore a11y-mouse-events-have-key-events a11y-no-static-element-interactions -->
<div
class="row"
class:focus={index === i}
on:mousedown={() => dispatch('pick', option)}
on:mouseover={() => {
index = i;
}}
>
<slot {option} />
</div>
{/each}
</div>
<style lang="scss">
.datalist {
position: absolute;
top: var(--top);
left: var(--left);
z-index: 10;
width: var(--width);
max-height: 12em;
overflow-y: auto;
background: var(--bg-card); // TODO: SASS mixins could be put to use here
border: var(--border-width-card) solid var(--border-color-card);
border-radius: var(--border-radius-card);
box-shadow: var(--shadow-toast);
transition:
opacity 100ms ease-in-out,
transform 100ms ease-in-out;
}
// We use `data-hidden` because Windi overloads `.hidden`
[data-hidden='true'] {
opacity: 0;
transform: translateY(-0.5em);
}
.row {
padding: 0.5em 1em;
cursor: default;
// We use `.focus` rather than `:focus` because states are managed in JS
&.focus {
background: var(--bg-table-row-hover);
}
}
.row + .row {
border-top: var(--border-width-card) solid var(--border-color-card);
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment