Add sidebar navigation and global command search
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user