zenno: refactor CharaPick to generic component
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import type { Character } from '$lib/data/character';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { stringsearch } from './stringsearch';
|
||||
import Pick from './Pick.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -15,139 +15,9 @@
|
||||
|
||||
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();
|
||||
}
|
||||
const key = (v: Character) => ({id: v.chara_id, name: v.name});
|
||||
</script>
|
||||
|
||||
<div class={className} style={`anchor-name: --anchor-${id}`}>
|
||||
<span
|
||||
{id}
|
||||
class="block h-9 content-center bg-mist-300 hover:cursor-pointer 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="skill-tip absolute top-2 flex-col px-2 shadow-lg open:flex"
|
||||
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
|
||||
id="charaOptions"
|
||||
role="listbox"
|
||||
popover
|
||||
bind:this={popover}
|
||||
>
|
||||
<input
|
||||
class="my-2 min-h-8 rounded-md border 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="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
||||
role="option"
|
||||
aria-selected={value == undefined}
|
||||
tabindex="0"
|
||||
data-chara-id=""
|
||||
onmousedown={() => {
|
||||
value = undefined;
|
||||
search = '';
|
||||
popover!.hidePopover();
|
||||
}}
|
||||
onfocus={() => (value = undefined)}
|
||||
{onkeydown}
|
||||
>
|
||||
<span class="text-sm italic">Reset</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each searchedCharas as c (c.chara_id)}
|
||||
<div
|
||||
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
||||
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>
|
||||
<Pick {id} items={charas} {key} bind:value {option} class={className} {required} />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { stringsearch } from './stringsearch';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
items: T[];
|
||||
key: (t: T) => {id: number, name: string};
|
||||
value: T | undefined;
|
||||
option?: Snippet<[T]>;
|
||||
class?: ClassValue | null;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
let {id, items, key, value = $bindable(), option, class: className, required = false}: Props = $props();
|
||||
|
||||
let popover: HTMLElement | undefined = $state();
|
||||
let optionsContainer: HTMLElement | undefined = $state();
|
||||
const expanded = $derived(!popover?.hidden);
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const its = $derived(items ?? []);
|
||||
const searched = $derived(stringsearch(search, its, (v) => key(v).name) || its);
|
||||
|
||||
$effect(() => {
|
||||
if (required && value == null && its.length > 0) {
|
||||
value = its[0];
|
||||
}
|
||||
});
|
||||
|
||||
function setByElem(o: HTMLElement) {
|
||||
const j = parseInt(o.dataset.pickId ?? '');
|
||||
value = its.find((c) => key(c).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) => value != null && parseInt(n.dataset.pickId ?? '') === key(value).id);
|
||||
const o = 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) => value != null && parseInt(n.dataset.pickId ?? '') === key(value).id);
|
||||
const o = 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 bg-mist-300 hover:cursor-pointer dark:bg-mist-900"
|
||||
role="combobox"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="charaOptions"
|
||||
aria-haspopup="listbox"
|
||||
onclick={() => popover?.togglePopover()}
|
||||
{onkeydown}
|
||||
tabindex="0">{value != null ? key(value).name : ''}</span
|
||||
>
|
||||
<div
|
||||
class="skill-tip absolute top-2 flex-col px-2 shadow-lg open:flex"
|
||||
style={`position-anchor: --anchor-${id}; position-area: bottom;`}
|
||||
id="charaOptions"
|
||||
role="listbox"
|
||||
popover
|
||||
bind:this={popover}
|
||||
>
|
||||
<input
|
||||
class="my-2 min-h-8 rounded-md border 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="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
||||
role="option"
|
||||
aria-selected={value == undefined}
|
||||
tabindex="0"
|
||||
data-pick-id=""
|
||||
onmousedown={() => {
|
||||
value = undefined;
|
||||
search = '';
|
||||
popover!.hidePopover();
|
||||
}}
|
||||
onfocus={() => (value = undefined)}
|
||||
{onkeydown}
|
||||
>
|
||||
<span class="text-sm italic">Reset</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each searched as c (key(c).id)}
|
||||
{@const v = value != null ? key(value).id : 0}
|
||||
{@const k = key(c)}
|
||||
<div
|
||||
class="h-8 w-full text-lg hover:cursor-pointer hover:bg-mist-300 hover:dark:bg-mist-900"
|
||||
role="option"
|
||||
aria-selected={v === k.id}
|
||||
tabindex="0"
|
||||
data-pick-id={k.id}
|
||||
onmousedown={() => {
|
||||
value = c;
|
||||
popover!.hidePopover();
|
||||
}}
|
||||
onfocus={() => (value = c)}
|
||||
{onkeydown}
|
||||
>
|
||||
{#if option != null}
|
||||
{@render option(c)}
|
||||
{:else}
|
||||
<span>{k.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-8 text-lg italic">No matches.</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user