Add LocationSearchCard and add to homepage for testing.
Run `npm run format`
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLInputAttributes {
|
||||
value?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
||||
error?: string;
|
||||
uppercase?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
interface Props extends HTMLInputAttributes {
|
||||
value?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
|
||||
error?: string;
|
||||
uppercase?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
label,
|
||||
placeholder = '',
|
||||
type = 'text',
|
||||
error = '',
|
||||
uppercase = false,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
let {
|
||||
value = $bindable(''),
|
||||
label,
|
||||
placeholder = '',
|
||||
type = 'text',
|
||||
error = '',
|
||||
uppercase = false,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
let isFocussed = $state(false);
|
||||
let isFocussed = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
|
||||
{#if label}
|
||||
<label for="adaptive-input">{label}</label>
|
||||
{/if}
|
||||
{#if label}
|
||||
<label for="adaptive-input">{label}</label>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id="adaptive-input"
|
||||
class:all-caps={uppercase}
|
||||
{type}
|
||||
{placeholder}
|
||||
bind:value={value}
|
||||
onfocus={() => isFocussed = true}
|
||||
onblur={() => isFocussed = false}
|
||||
{...rest}
|
||||
/>
|
||||
<input
|
||||
id="adaptive-input"
|
||||
class:all-caps={uppercase}
|
||||
{type}
|
||||
{placeholder}
|
||||
bind:value
|
||||
onfocus={() => (isFocussed = true)}
|
||||
onblur={() => (isFocussed = false)}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<span class="error-message" transition:fade>{error}</span>
|
||||
{/if}
|
||||
{#if error}
|
||||
<span class="error-message" transition:fade>{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
}
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-title)
|
||||
}
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-title);
|
||||
}
|
||||
|
||||
input {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
background-color: var(--color-title);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 20px;
|
||||
color: var(--color-bg-dark);
|
||||
font-size: 1.5rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
input {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
background-color: var(--color-title);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 20px;
|
||||
color: var(--color-bg-dark);
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.all-caps {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.all-caps {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.focussed input {
|
||||
border-color: var(--color-bg-light);
|
||||
}
|
||||
.focussed input {
|
||||
border-color: var(--color-bg-light);
|
||||
}
|
||||
|
||||
.has-error input {
|
||||
border-color: #ff4d4d;
|
||||
}
|
||||
.has-error input {
|
||||
border-color: #ff4d4d;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4d4d;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
.error-message {
|
||||
color: #ff4d4d;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,94 +1,101 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { IconHelpCircle } from '@tabler/icons-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { IconHelpCircle } from '@tabler/icons-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
header?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
header?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
header = "",
|
||||
helpText,
|
||||
}: Props = $props();
|
||||
let { children, header = '', helpText }: Props = $props();
|
||||
|
||||
let showHelp = $state(false);
|
||||
let showHelp = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
{#if header || helpText}
|
||||
<header class="card-header">
|
||||
<div class="header-content">
|
||||
{header}
|
||||
</div>
|
||||
{#if helpText}
|
||||
<button
|
||||
type="button"
|
||||
class="help-toggle"
|
||||
onclick={() => showHelp = !showHelp}
|
||||
aria-label="Show Help"
|
||||
>
|
||||
<IconHelpCircle size={26} stroke={2.25} color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'} />
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
{#if header || helpText}
|
||||
<header class="card-header">
|
||||
<div class="header-content">
|
||||
{header}
|
||||
</div>
|
||||
{#if helpText}
|
||||
<button
|
||||
type="button"
|
||||
class="help-toggle"
|
||||
onclick={() => (showHelp = !showHelp)}
|
||||
aria-label="Show Help"
|
||||
>
|
||||
<IconHelpCircle
|
||||
size={26}
|
||||
stroke={2.25}
|
||||
color={showHelp ? 'var(--color-brand)' : 'var(--color-title)'}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showHelp && helpText}
|
||||
<div class="help-drawer" transition:slide={{ duration: 400 }}>
|
||||
<p>{helpText}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showHelp && helpText}
|
||||
<div class="help-drawer" transition:slide={{ duration: 400 }}>
|
||||
<p>{helpText}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="card-body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--color-accent);
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
width: 95%;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
color: var(--color-title);
|
||||
}
|
||||
.card {
|
||||
background: var(--color-accent);
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
overflow: visible;
|
||||
width: 95%;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
font-family: 'URW Gothic', sans-serif;
|
||||
color: var(--color-brand);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.header-content { flex: 1;
|
||||
font-size: 1.5rem; font-weight: 600; }
|
||||
.header-content {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-toggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: help;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.help-toggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: help;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
}
|
||||
|
||||
.help-toggle:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.help-toggle:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.help-drawer {
|
||||
background-color: var(--color-accent);
|
||||
padding: 4px 16px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
margin: auto;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
color: var(--color-title);
|
||||
}
|
||||
</style>
|
||||
.help-drawer {
|
||||
background-color: var(--color-accent);
|
||||
padding: 4px 16px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
margin: auto;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: var(--color-title);
|
||||
}
|
||||
</style>
|
||||
|
||||
25
src/lib/components/ui/cards/LocationBoardCard.svelte
Normal file
25
src/lib/components/ui/cards/LocationBoardCard.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
|
||||
import LocationSearchBox from '$lib/components/ui/LocationSearchBox.svelte';
|
||||
|
||||
let locationValue = $state('');
|
||||
|
||||
function resetSearchBox() {
|
||||
value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<BaseCard header={'Live Arrivals & Departures'}>
|
||||
<div class="card-content">
|
||||
<LocationSearchBox bind:value={locationValue} />
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<style>
|
||||
.card-content {
|
||||
text-align: center;
|
||||
width: 90%;
|
||||
margin: auto;
|
||||
padding: 10px 0 10px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user