311 lines
9.9 KiB
Svelte
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>
|