Add LocationSearchCard and add to homepage for testing.

Run `npm run format`
This commit is contained in:
2026-03-16 20:31:28 +00:00
parent f5c3775f59
commit 35877ae8ac
11 changed files with 427 additions and 283 deletions

View File

@@ -1,112 +1,221 @@
<script lang="ts">
import Textbox from '$lib/components/ui/Textbox.svelte';
import { onMount } from 'svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
interface LocationRecord {
n: string; // name
t: string; // tiploc
c?: string; // crs
s: string; // search string
}
interface LocationRecord {
n: string; // name
t: string; // tiploc
c?: string; // crs
s: string; // search string
}
let value = $state("");
let results = $state<LocationRecord[]>([]);
let locations: LocationRecord[] = [];
let { value = $bindable() } = $props();
let showResults = $state(false);
let selectedIndex = $state(-1);
let results = $state<LocationRecord[]>([]);
let locations: LocationRecord[] = [];
const MAX_RESULTS = 10;
let showResults = $state(false);
let selectedIndex = $state(-1);
async function loadLocations() {
const res = await fetch("/api/tiplocs");
locations = await res.json();
}
const MAX_RESULTS = 5;
onMount(loadLocations);
async function loadLocations() {
const res = await fetch('/api/tiplocs');
locations = await res.json();
}
function tokenize(query: string) {
return query
.toLowerCase()
.trim()
.split(/\s+/)
.filter(Boolean);
}
onMount(loadLocations);
function search(query: string) {
if (query.length < 3) {
results = [];
return;
}
function tokenize(query: string) {
return query.toLowerCase().trim().split(/\s+/).filter(Boolean);
}
const tokens = tokenize(query);
function search(query: string) {
if (query.length < 3) {
results = [];
return;
}
results = locations
.filter(r => tokens.every(t => r.s.includes(t)))
.slice(0, MAX_RESULTS);
}
const tokens = tokenize(query);
const lowerQuery = query.toLowerCase().trim();
$effect(() => {
search(value);
});
results = locations
.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;
function choose(loc: LocationRecord) {
value = loc.n;
showResults = false;
selectedIndex = -1;
// Sort matching CRS first
if (aIsCrs && !bIsCrs) return -1;
if (!aIsCrs && bIsCrs) return 1;
console.log("Selected Location: ", JSON.stringify(loc))
}
// Alphabetical Sort
return a.n.localeCompare(b.n);
})
.slice(0, MAX_RESULTS);
}
function handleKey(e: KeyboardEvent) {
if (!results.length) return;
$effect(() => {
search(value);
});
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 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;
}
};
if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
}
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
}
});
if (e.key === "Enter" && selectedIndex >= 0) {
choose(results[selectedIndex]);
}
}
function choose(loc: LocationRecord) {
showResults = false;
selectedIndex = -1;
value = '';
console.log('Selected Location: ', JSON.stringify(loc));
goto(`/board?stn=${loc.c.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
/>
<Textbox
bind:value
placeholder="Enter Location"
oninput={() => (showResults = true)}
onkeydown={handleKey}
capital
/>
{#if showResults && results.length}
<ul class="suggestions">
{#each results as loc, i}
<li
class:selected={i === selectedIndex}
onclick={() => choose(loc)}
>
<span class="name">{loc.n}</span>
{#if loc.c}
<span class="crs">{loc.c}</span>
{/if}
</li>
{/each}
</ul>
{/if}
{#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>
.suggestions {
background-color: var(--color-title);
color: var(--color-bg-dark);
}
</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>