zenno: make character selects searchable

Fixes #20.
This commit is contained in:
2026-06-16 13:37:59 -04:00
parent f8e7bfbec6
commit ad58edbfe1
4 changed files with 212 additions and 42 deletions
+129 -12
View File
@@ -1,24 +1,141 @@
<script lang="ts">
import { type Character } from '$lib/data/character';
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: number;
value: Character | undefined;
class?: ClassValue | null;
optionClass?: ClassValue | null;
option?: Snippet<[Character]>;
required?: boolean;
}
let { id, characters, value = $bindable(), class: className, optionClass, required = false }: Props = $props();
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>
<select {id} class={className} bind:value {required}>
{#if !required}
<option value={0} class={optionClass}></option>
{/if}
{#each characters ?? [] as c (c.chara_id)}
<option value={c.chara_id} class={optionClass}>{c.name}</option>
{/each}
</select>
<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>
+56
View File
@@ -0,0 +1,56 @@
const WORD_BOUNDARY = " ,!?/-+();#○☆♡'=♪∀゚∴";
function score(s: string, tt: string): number {
let k: number | undefined;
let r = 0;
let run = 0;
for (const c of s) {
const j = tt.indexOf(c, k);
// If the character isn't in the string, there's a major penalty.
if (j < 0) {
// The penalty scales with run length, on the assumption that we're
// typing something else.
// Really this should scale with the longest current run among all
// search terms, but that's infeasible to implement.
r -= 6 + run*run;
run = 0;
continue;
}
run++;
// Characters at word boundaries get extra score.
if (j == 0 || WORD_BOUNDARY.includes(tt[j-1])) {
r += 2;
}
// As do characters that *are* word boundaries.
if (WORD_BOUNDARY.includes(c)) {
r += 2;
}
// And runs of matches scale with run length.
if (j === k) {
r += (run+1) * (run+1);
} else {
run = 0;
}
k = j + 1;
}
return r;
}
/**
* Fuzzy string search.
* @param sub Substring to search for
* @param terms Iterable of values to search among
* @param map Mapping from term to string to search
* @returns Matching terms in decreasing match quality order
*/
export function stringsearch<T>(sub: string, terms: Iterable<T>, map: (t: T) => string): T[] {
const s = sub.toLocaleLowerCase();
const scored: [T, number][] = [];
for (const t of terms) {
const sc = score(s, map(t).toLocaleLowerCase());
if (sc >= 0) {
scored.push([t, sc]);
}
}
return scored.sort(([, a], [, b]) => b - a).map(([t,]) => t);
}