Files
electric-cherry/src/lib/ui/components/CommandPalette.svelte
T

311 lines
9.9 KiB
Svelte

<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import type { UserRole } from '$lib/auth.js';
import Dialog from './primitives/Dialog.svelte';
import {
loadPaletteDirectory,
paletteDirectoryResults,
paletteGroups,
paletteMatchJump,
palettePageResults,
searchPaletteRecords,
type PaletteDirectory,
type PaletteItem
} from '$ui/command-palette.js';
let { open = $bindable(false), role }: { open?: boolean; role: UserRole } = $props();
const id = $props.id();
let input = $state<HTMLInputElement>();
let query = $state('');
let directory = $state<PaletteDirectory | null>(null);
let records = $state<PaletteItem[]>([]);
let directoryLoading = $state(false);
let recordsLoading = $state(false);
let recordErrors = $state<string[]>([]);
let selected = $state('');
let navigationError = $state('');
let navigating = $state(false);
const items = $derived.by(() => {
const unique = new Map<string, PaletteItem>();
for (const item of [
...palettePageResults(role, query),
...paletteDirectoryResults(query, directory),
...paletteMatchJump(query),
...records
])
unique.set(item.key, item);
return paletteGroups.flatMap((group) =>
[...unique.values()].filter((item) => item.group === group)
);
});
const activeIndex = $derived(
Math.max(
0,
items.findIndex((item) => item.key === selected)
)
);
const unavailable = $derived([...(directory?.unavailable ?? []), ...recordErrors]);
const searching = $derived(directoryLoading || recordsLoading);
$effect(() => {
if (!open) {
query = '';
records = [];
directory = null;
navigationError = '';
return;
}
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
directoryLoading = true;
void loadPaletteDirectory(controller.signal)
.then((result) => {
if (!cancelled) directory = result;
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) directoryLoading = false;
});
void tick().then(() => {
if (!cancelled) input?.focus();
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
});
$effect(() => {
const term = query.trim();
selected = '';
records = [];
recordErrors = [];
if (!open || term.length < 2) {
recordsLoading = false;
return;
}
let cancelled = false;
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
recordsLoading = true;
const debounce = setTimeout(() => {
timeout = setTimeout(() => controller.abort(), 15_000);
void searchPaletteRecords(term, controller.signal)
.then((result) => {
if (!cancelled) {
records = result.items;
recordErrors = result.unavailable;
}
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) recordsLoading = false;
});
}, 220);
return () => {
cancelled = true;
clearTimeout(debounce);
clearTimeout(timeout);
controller.abort();
};
});
$effect(() => {
const option = activeIndex;
if (!open || !items.length) return;
void tick().then(() =>
document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' })
);
});
async function choose(item: PaletteItem) {
if (navigating) return;
navigating = true;
navigationError = '';
try {
await goto(item.href);
open = false;
} catch {
navigationError = 'Could not open that result. Try again.';
} finally {
navigating = false;
}
}
function keydown(event: KeyboardEvent) {
if (!items.length || navigating) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const movement = event.key === 'ArrowDown' ? 1 : -1;
selected = items[(activeIndex + movement + items.length) % items.length].key;
} else if (event.key === 'Enter') {
event.preventDefault();
void choose(items[activeIndex]);
}
}
</script>
<Dialog
bind:open
title="Search and commands"
description="Jump to a page, server, host, player, or match."
>
<div class="palette">
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
<input
bind:this={input}
bind:value={query}
id={`${id}-search`}
type="search"
maxlength="100"
placeholder="Search Electric Cherry…"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={`${id}-results`}
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined}
aria-describedby={`${id}-hint`}
autocomplete="off"
onkeydown={keydown}
/>
<p class="hint" id={`${id}-hint`}>
Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span>
</p>
<div
class="results"
id={`${id}-results`}
role="listbox"
aria-label="Search results"
aria-busy={searching || navigating}
>
{#each paletteGroups as group}
{@const groupItems = items.filter((item) => item.group === group)}
{#if groupItems.length}
<div role="group" aria-labelledby={`${id}-${group}`}>
<div class="group-label" id={`${id}-${group}`}>{group}</div>
{#each groupItems as item (item.key)}
{@const index = items.indexOf(item)}
<button
type="button"
id={`${id}-option-${index}`}
role="option"
tabindex="-1"
aria-selected={index === activeIndex}
class:active={index === activeIndex}
disabled={navigating}
onclick={() => void choose(item)}
onpointermove={() => (selected = item.key)}
>
<span class="result-label">{item.label}</span><span
class="result-detail">{item.detail}</span
>
</button>
{/each}
</div>
{/if}
{/each}
</div>
<p class="status" role="status">
{searching
? 'Searching…'
: items.length
? `${items.length} results`
: 'No matches. Try a different name or a match number.'}
</p>
{#if unavailable.length}<p class="error">
{unavailable.join(', ')} search is unavailable. Other results are still available.
</p>{/if}
{#if navigationError}<p class="error" role="alert">{navigationError}</p>{/if}
</div>
</Dialog>
<style>
.palette {
display: grid;
gap: var(--space-3);
min-width: 0;
}
input {
width: 100%;
min-height: 44px;
padding: var(--space-3);
font: inherit;
font-size: var(--text-base);
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.hint,
.status,
.error {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.5;
}
.hint span {
display: block;
}
.results {
min-height: 40px;
max-height: min(55dvh, 480px);
overflow: auto;
overscroll-behavior: contain;
position: relative;
}
.group-label {
padding: var(--space-3) var(--space-2) var(--space-1);
font-size: var(--text-xs);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
button {
display: grid;
gap: var(--space-1);
width: 100%;
min-height: 48px;
text-align: left;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
background: transparent;
color: var(--text-primary);
font: inherit;
cursor: pointer;
}
button.active {
background: var(--surface-raised);
border-color: var(--accent-primary);
}
button:disabled {
cursor: wait;
}
.result-label {
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.result-detail {
color: var(--text-secondary);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.error {
color: var(--state-warning);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
</style>