ad58edbfe1
Fixes #20.
142 lines
4.0 KiB
Svelte
142 lines
4.0 KiB
Svelte
<script lang="ts">
|
|
import type { Character } from '$lib/data/character';
|
|
import type { Snippet } from 'svelte';
|
|
import type { ClassValue } from 'svelte/elements';
|
|
import { stringsearch } from './stringsearch';
|
|
|
|
interface Props {
|
|
id: string;
|
|
characters: Character[] | null;
|
|
value: Character | undefined;
|
|
class?: ClassValue | null;
|
|
option?: Snippet<[Character]>;
|
|
required?: boolean;
|
|
}
|
|
|
|
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
|
|
|
|
let popover: HTMLElement | undefined = $state();
|
|
let optionsContainer: HTMLElement | undefined = $state();
|
|
const expanded = $derived(!popover?.hidden);
|
|
|
|
let search = $state('');
|
|
|
|
const charas = $derived(characters ?? []);
|
|
const searchedCharas = $derived(stringsearch(search, charas, ({name}) => name) || charas);
|
|
|
|
$effect(() => {
|
|
if (required && value == null && charas.length > 0) {
|
|
value = charas[0];
|
|
}
|
|
});
|
|
|
|
function setByElem(o: HTMLElement) {
|
|
const j = parseInt(o.dataset.charaId ?? '');
|
|
value = charas.find((c) => c.chara_id === j) ?? value;
|
|
o.focus();
|
|
}
|
|
|
|
function onkeydown(evt: KeyboardEvent) {
|
|
switch (evt.key) {
|
|
case 'ArrowDown': {
|
|
const opts = [...optionsContainer!.children] as HTMLElement[];
|
|
const i = opts.findIndex((n) => parseInt(n.dataset.charaId ?? '') === value?.chara_id);
|
|
const o = i != null && i >= 0 ? opts[Math.min(i+1, opts.length - 1)] : opts[0];
|
|
setByElem(o);
|
|
break;
|
|
}
|
|
case 'ArrowUp': {
|
|
const opts = [...optionsContainer!.children] as HTMLElement[];
|
|
const i = opts.findIndex((n) => parseInt(n.dataset.charaId ?? '') === value?.chara_id);
|
|
const o = i != null && i > 0 ? opts[i-1] : opts[0];
|
|
setByElem(o);
|
|
break;
|
|
}
|
|
case 'Home': {
|
|
search = '';
|
|
setByElem(optionsContainer!.children[0] as HTMLElement);
|
|
break;
|
|
}
|
|
case 'End': {
|
|
search = '';
|
|
setByElem(optionsContainer!.children[optionsContainer!.childElementCount - 1] as HTMLElement);
|
|
break;
|
|
}
|
|
case ' ':
|
|
popover!.showPopover();
|
|
break;
|
|
case 'Escape':
|
|
// TODO(zeph): restore chara that was selected when the popup opened?
|
|
// for now just hide
|
|
popover!.hidePopover();
|
|
break;
|
|
case 'Enter':
|
|
popover!.togglePopover();
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
evt.preventDefault();
|
|
}
|
|
</script>
|
|
|
|
<div class={className} style={`anchor-name: --anchor-${id}`}>
|
|
<span
|
|
{id}
|
|
class="block h-9 content-center hover:cursor-pointer bg-mist-300 dark:bg-mist-900"
|
|
role="combobox"
|
|
aria-expanded={expanded}
|
|
aria-controls="charaOptions"
|
|
aria-haspopup="listbox"
|
|
onclick={() => popover?.togglePopover()}
|
|
{onkeydown}
|
|
tabindex="0"
|
|
>{value?.name ?? ''}</span>
|
|
<div
|
|
class="absolute top-2 shadow-lg open:flex flex-col px-2 skill-tip"
|
|
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
|
|
id="charaOptions"
|
|
role="listbox"
|
|
popover
|
|
bind:this={popover}
|
|
>
|
|
<input class="my-2 border rounded-md min-h-8 pointer-coarse:min-h-12" placeholder=" Search" role="searchbox" bind:value={search} />
|
|
<div class="max-h-72 overflow-y-scroll" bind:this={optionsContainer}>
|
|
{#if !required}
|
|
<div
|
|
class="w-full h-8 hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900 text-lg"
|
|
role="option"
|
|
aria-selected={value == undefined}
|
|
tabindex="0"
|
|
data-chara-id=""
|
|
onmousedown={() => {value = undefined; search = ''; popover!.hidePopover()}}
|
|
onfocus={() => value = undefined}
|
|
{onkeydown}
|
|
>
|
|
<span class="italic text-sm">Reset</span>
|
|
</div>
|
|
{/if}
|
|
{#each searchedCharas as c (c.chara_id)}
|
|
<div
|
|
class="w-full h-8 hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900 text-lg"
|
|
role="option"
|
|
aria-selected={value?.chara_id === c.chara_id}
|
|
tabindex="0"
|
|
data-chara-id={c.chara_id}
|
|
onmousedown={() => {value = c; popover!.hidePopover()}}
|
|
onfocus={() => value = c}
|
|
{onkeydown}
|
|
>
|
|
{#if option != null}
|
|
{@render option(c)}
|
|
{:else}
|
|
<span>{c.name}</span>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="w-full h-8 text-lg italic">No matches.</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|