209 lines
4.3 KiB
Svelte
209 lines
4.3 KiB
Svelte
<script lang="ts">
|
|
import Textbox from '$lib/components/ui/Textbox.svelte';
|
|
import { onMount } from 'svelte';
|
|
import { fade } from 'svelte/transition';
|
|
import { goto } from '$app/navigation';
|
|
|
|
import { LOCATIONS } from '$lib/locations-object.svelte.ts';
|
|
|
|
|
|
|
|
let { value = $bindable() } = $props();
|
|
|
|
let showResults = $state(false);
|
|
let selectedIndex = $state(-1);
|
|
|
|
const MAX_RESULTS = 5;
|
|
|
|
|
|
function tokenize(query: string) {
|
|
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
let results = $derived.by(() => {
|
|
if (value.length < 3) return [];
|
|
|
|
const tokens = tokenize(value);
|
|
const lowerQuery = value.toLowerCase().trim();
|
|
|
|
return LOCATIONS.data
|
|
.filter((r) => tokens.every((t) => r.s.includes(t)))
|
|
.sort((a, b) => {
|
|
// Check if query matches CRS
|
|
const aIsCrs = a.c?.toLowerCase() === lowerQuery;
|
|
const bIsCrs = b.c?.toLowerCase() === lowerQuery;
|
|
|
|
// Sort matching CRS first
|
|
if (aIsCrs && !bIsCrs) return -1;
|
|
if (!aIsCrs && bIsCrs) return 1;
|
|
|
|
// Alphabetical Sort
|
|
return a.n.localeCompare(b.n);
|
|
})
|
|
.slice(0, MAX_RESULTS);
|
|
})
|
|
|
|
|
|
$effect(() => {
|
|
if (results) selectedIndex = -1;
|
|
});
|
|
|
|
// Hide results when click outside of container
|
|
$effect(() => {
|
|
if (showResults) {
|
|
const onClick = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (!target.closest('.location-search')) {
|
|
showResults = false;
|
|
}
|
|
};
|
|
|
|
document.addEventListener('click', onClick);
|
|
return () => document.removeEventListener('click', onClick);
|
|
}
|
|
});
|
|
|
|
function choose(loc: LocationRecord) {
|
|
showResults = false;
|
|
selectedIndex = -1;
|
|
value = '';
|
|
console.log('Selected Location: ', JSON.stringify(loc));
|
|
const queryString = loc.c || loc.t
|
|
goto(`/board?loc=${queryString.toLowerCase()}`);
|
|
}
|
|
|
|
function handleKey(e: KeyboardEvent) {
|
|
if (!results.length) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
|
}
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
selectedIndex = Math.max(selectedIndex - 1, 0);
|
|
}
|
|
|
|
if (e.key === 'Enter' && selectedIndex >= 0) {
|
|
choose(results[selectedIndex]);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="location-search">
|
|
<Textbox
|
|
bind:value
|
|
placeholder="Enter Location"
|
|
oninput={() => (showResults = true)}
|
|
onkeydown={handleKey}
|
|
capital
|
|
/>
|
|
|
|
{#if showResults && results.length}
|
|
<ul
|
|
id="location-results"
|
|
popover={showResults && results.length ? 'manual' : null}
|
|
role="listbox"
|
|
class="suggestions"
|
|
transition:fade={{ duration: 200 }}
|
|
>
|
|
{#each results as loc, i}
|
|
<li class="result-item" class:selected={i === selectedIndex} onclick={() => choose(loc)}>
|
|
<div class="crs-badge-container">
|
|
{#if loc.c}
|
|
<span class="crs-badge">{loc.c}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="details">
|
|
<span class="name">{loc.n || loc.t}</span>
|
|
<span class="tiploc">{loc.t}</span>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.location-search {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.suggestions[popover] {
|
|
position: absolute;
|
|
inset: unset;
|
|
margin: 0;
|
|
margin-top: 3px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
padding: 0;
|
|
width: 100%;
|
|
max-height: 350px;
|
|
top: 100%;
|
|
background-color: var(--color-title);
|
|
color: var(--color-bg-dark);
|
|
box-shadow: var(--shadow-std);
|
|
display: block;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.suggestions:not([popover]) {
|
|
display: none;
|
|
}
|
|
|
|
.result-item {
|
|
font-family: 'URW Gothic', sans-serif;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 1rem;
|
|
cursor: pointer;
|
|
min-height: 48px;
|
|
transition: all 0.15s;
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.result-item.selected,
|
|
.result-item:hover {
|
|
background-color: var(--color-accent);
|
|
color: var(--color-title);
|
|
}
|
|
|
|
.crs-badge {
|
|
font-family: ui-monospace, monospace;
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
background: var(--color-accent);
|
|
color: var(--color-title);
|
|
padding: 3px 6px;
|
|
border-radius: 10px;
|
|
transition: all 0.1s;
|
|
}
|
|
|
|
.crs-badge.empty {
|
|
filter: opacity(0);
|
|
}
|
|
|
|
.result-item:hover .crs-badge {
|
|
filter: brightness(1.3);
|
|
}
|
|
|
|
.details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.name {
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
text-align: right;
|
|
}
|
|
|
|
.tiploc {
|
|
text-align: right;
|
|
font-size: 0.8rem;
|
|
}
|
|
</style>
|