1. Match to exact CRS 2. Match to exact Name 3. Match to 'Name startsWith' 4. Match any with valid CRS 5. Match alphabetically
221 lines
5.0 KiB
Svelte
221 lines
5.0 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';
|
|
import type { ApiLocationFilter } from '@owlboard/api-schema-types';
|
|
|
|
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) => {
|
|
// Priority One - Exact CRS Match
|
|
const aExactCrs = a.c?.toLowerCase() === lowerQuery;
|
|
const bExactCrs = b.c?.toLowerCase() === lowerQuery;
|
|
if (aExactCrs && !bExactCrs) return -1;
|
|
if (!aExactCrs && bExactCrs) return 1;
|
|
|
|
// Priority Two - Exact Name Match
|
|
const aNameLow = a.n.toLowerCase();
|
|
const bNameLow = b.n.toLowerCase();
|
|
const aExactName = aNameLow === lowerQuery;
|
|
const bExactName = bNameLow === lowerQuery;
|
|
if (aExactName !== bExactName) return aExactName ? -1 : 1;
|
|
|
|
// Priority Three - Name starts with Query
|
|
const aStarts = aNameLow.startsWith(lowerQuery);
|
|
const bStarts = bNameLow.startsWith(lowerQuery);
|
|
if (aStarts !== bStarts) return aStarts ? -1 : 1;
|
|
|
|
// Priority Four - 'Stations' with CRS
|
|
if (!!a.c && !b.c) return -1;
|
|
if (!a.c && !!b.c) 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: ApiLocationFilter.LocationFilterObject) {
|
|
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;
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
.tiploc {
|
|
text-align: right;
|
|
font-size: 0.8rem;
|
|
}
|
|
</style>
|