feat: location selector in route UI
This commit is contained in:
@ -130,5 +130,11 @@
|
||||
"choose-lang": "Wählen Sie Ihre Sprache",
|
||||
"first-vehicle": "Erstellen wir Ihr erstes Fahrzeug."
|
||||
}
|
||||
}
|
||||
},
|
||||
"location-selector": {
|
||||
"current": "Mein Standort",
|
||||
"searching": "Suchen...",
|
||||
"no-results": "Keine Orte gefunden."
|
||||
},
|
||||
"unsave": "Löschen"
|
||||
}
|
||||
|
||||
@ -132,5 +132,10 @@
|
||||
"first-vehicle": "Let's create your first vehicle."
|
||||
}
|
||||
},
|
||||
"unsave": "Unsave"
|
||||
"unsave": "Unsave",
|
||||
"location-selector": {
|
||||
"current": "Current Location",
|
||||
"searching": "Searching...",
|
||||
"no-results": "No locations found."
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,35 +6,56 @@
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { m } from "$lang/messages";
|
||||
import { BriefcaseIcon, HomeIcon, LocateIcon, MapPinIcon, SchoolIcon } from "@lucide/svelte";
|
||||
import { geocode } from "$lib/saved.svelte";
|
||||
import { reverseGeocode, search, type Feature } from "$lib/services/Search";
|
||||
|
||||
const frameworks = [
|
||||
const locations = [
|
||||
{
|
||||
value: "sveltekit",
|
||||
label: "SvelteKit",
|
||||
value: "current",
|
||||
label: m["location-selector.current"](),
|
||||
icon: LocateIcon
|
||||
},
|
||||
{
|
||||
value: "next.js",
|
||||
label: "Next.js",
|
||||
value: "home",
|
||||
label: m["saved.home"](),
|
||||
subtext: geocode("home"),
|
||||
icon: HomeIcon
|
||||
},
|
||||
{
|
||||
value: "nuxt.js",
|
||||
label: "Nuxt.js",
|
||||
value: "school",
|
||||
label: m["saved.school"](),
|
||||
subtext: geocode("school"),
|
||||
icon: SchoolIcon
|
||||
},
|
||||
{
|
||||
value: "remix",
|
||||
label: "Remix",
|
||||
},
|
||||
{
|
||||
value: "astro",
|
||||
label: "Astro",
|
||||
},
|
||||
value: "work",
|
||||
label: m["saved.work"](),
|
||||
subtext: geocode("work"),
|
||||
icon: BriefcaseIcon
|
||||
}
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
let value = $state("");
|
||||
let { value = $bindable() } = $props();
|
||||
let triggerRef = $state<HTMLButtonElement>(null!);
|
||||
let searchbarText = $state("");
|
||||
let searchText = $derived.by(debounce(() => searchbarText, 300));
|
||||
let searching = $state(false);
|
||||
let searchResults: Feature[] = $state([]);
|
||||
|
||||
const selectedValue = $derived(value === "location" ? "My Location" : value);
|
||||
async function getCoordLabel(value: `${number},${number}`) {
|
||||
const splitter = value.split(",");
|
||||
const res = await reverseGeocode({ lat: parseFloat(splitter[0]), lon: parseFloat(splitter[1]) })
|
||||
if(res.length == 0) return "<unknown>";
|
||||
const feature = res[0];
|
||||
return feature.properties.name;
|
||||
}
|
||||
|
||||
const selectedValue = $derived(
|
||||
new Promise(async r => { r(locations.find((f) => f.value === value)?.label || await getCoordLabel(value)) })
|
||||
);
|
||||
|
||||
// We want to refocus the trigger button when the user selects
|
||||
// an item from the list so users can continue navigating the
|
||||
@ -45,61 +66,123 @@
|
||||
triggerRef.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function debounce<T>(getter: () => T, delay: number): () => T | undefined {
|
||||
let value = $state<T>();
|
||||
let timer: NodeJS.Timeout;
|
||||
$effect(() => {
|
||||
const newValue = getter(); // read here to subscribe to it
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (value = newValue), delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
return () => value;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!searchText) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
if (searchText.length > 0) {
|
||||
searching = true;
|
||||
search(searchText, 0, 0).then((results) => {
|
||||
searchResults = results;
|
||||
searching = false;
|
||||
});
|
||||
} else {
|
||||
searchResults = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger bind:ref={triggerRef}>
|
||||
{#snippet child({ props }: { props: Record<string, unknown> })}
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-between"
|
||||
{...props}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
style="width: 100%; flex-shrink: unset; justify-content: space-between;"
|
||||
>
|
||||
{selectedValue || "Select a location..."}
|
||||
{#await selectedValue then selected}
|
||||
{selected || "Select..."}
|
||||
{/await}
|
||||
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[200px] p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder="Search..." />
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input placeholder="Search..." bind:value={searchbarText} />
|
||||
<Command.List>
|
||||
<Command.Empty>No location found.</Command.Empty>
|
||||
<Command.Empty>
|
||||
{#if searching}
|
||||
{m["location-selector.searching"]()}
|
||||
{:else}
|
||||
{m["location-selector.no-results"]()}
|
||||
{/if}
|
||||
</Command.Empty>
|
||||
<Command.Group>
|
||||
{#if searchbarText == ""}
|
||||
{#each locations as location}
|
||||
<Command.Item
|
||||
value="location"
|
||||
value={location.value}
|
||||
onSelect={() => {
|
||||
value = "location";
|
||||
value = location.value;
|
||||
closeAndFocusTrigger();
|
||||
}}
|
||||
style="flex-direction: column; align-items: start;"
|
||||
>
|
||||
<CheckIcon
|
||||
<div style="display: flex; align-items: center; gap: 5px; width: 100%;">
|
||||
<location.icon
|
||||
class={cn(
|
||||
"mr-2 size-4",
|
||||
value !== "location" && "text-transparent",
|
||||
"mr-2 size-4"
|
||||
)}
|
||||
/>
|
||||
My Location
|
||||
{location.label}
|
||||
<CheckIcon
|
||||
class={cn(
|
||||
"mr-2 size-4 ml-auto",
|
||||
value !== location.value && "text-transparent"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{#await location.subtext then subtext}
|
||||
{#if subtext}
|
||||
<span>{subtext}</span>
|
||||
{/if}
|
||||
{/await}
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group>
|
||||
{#each frameworks as framework (framework.value)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#each searchResults as result}
|
||||
{@const resultValue = result.geometry.coordinates[1] + "," + result.geometry.coordinates[0]}
|
||||
<Command.Item
|
||||
value={framework.value}
|
||||
value={resultValue}
|
||||
onSelect={() => {
|
||||
value = framework.value;
|
||||
value = resultValue;
|
||||
closeAndFocusTrigger();
|
||||
}}
|
||||
style="flex-direction: column; align-items: start;"
|
||||
>
|
||||
<CheckIcon
|
||||
<div style="display: flex; align-items: center; gap: 5px; width: 100%;">
|
||||
<MapPinIcon
|
||||
class={cn(
|
||||
"mr-2 size-4",
|
||||
value !== framework.value && "text-transparent",
|
||||
"mr-2 size-4"
|
||||
)}
|
||||
/>
|
||||
{framework.label}
|
||||
{result.properties.name}
|
||||
<CheckIcon
|
||||
class={cn(
|
||||
"mr-2 size-4 ml-auto",
|
||||
value !== resultValue && "text-transparent"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span>{result.properties.street}{result.properties.housenumber ? " " + result.properties.housenumber : ""}, {result.properties.city}</span>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
import { location } from "../location.svelte";
|
||||
import { saved } from "$lib/saved.svelte";
|
||||
import { m } from "$lang/messages";
|
||||
import LocationSelect from "../LocationSelect.svelte";
|
||||
|
||||
let {
|
||||
from,
|
||||
@ -29,7 +30,7 @@
|
||||
to?: string;
|
||||
} = $props();
|
||||
|
||||
let fromLocation = $state(from || "");
|
||||
let fromLocation = $state(from || "current");
|
||||
let toLocation = $state(to || "");
|
||||
|
||||
let routes: Trip[] | null = $state(null);
|
||||
@ -57,29 +58,24 @@
|
||||
<div class="flex flex-col gap-2 w-full mb-2">
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<CircleDotIcon />
|
||||
<Input bind:value={fromLocation} />
|
||||
<LocationSelect bind:value={fromLocation} />
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full">
|
||||
<CircleArrowDown />
|
||||
</div>
|
||||
<div class="flex gap-2 items-center w-full">
|
||||
<CircleDotIcon />
|
||||
<Input bind:value={toLocation} />
|
||||
<LocationSelect bind:value={toLocation} />
|
||||
</div>
|
||||
<span>
|
||||
<!-- eslint-disable-next-line -->
|
||||
{@html m["sidebar.route.help"]()}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onclick={async () => {
|
||||
console.log(fromLocation, toLocation);
|
||||
const FROM: WorldLocation =
|
||||
fromLocation == "current"
|
||||
? { lat: location.lat, lon: location.lng }
|
||||
: fromLocation == "home"
|
||||
? saved.home
|
||||
: fromLocation == "work"
|
||||
? saved.work
|
||||
: saved[fromLocation]
|
||||
? saved[fromLocation]
|
||||
: {
|
||||
lat: parseFloat(fromLocation.split(",")[0]),
|
||||
lon: parseFloat(fromLocation.split(",")[1]),
|
||||
@ -87,10 +83,8 @@
|
||||
const TO: WorldLocation =
|
||||
toLocation == "current"
|
||||
? { lat: location.lat, lon: location.lng }
|
||||
: toLocation == "home"
|
||||
? saved.home
|
||||
: toLocation == "work"
|
||||
? saved.work
|
||||
: saved[toLocation]
|
||||
? saved[toLocation]
|
||||
: {
|
||||
lat: parseFloat(toLocation.split(",")[0]),
|
||||
lon: parseFloat(toLocation.split(",")[1]),
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { reverseGeocode } from "./services/Search";
|
||||
|
||||
export const saved: Record<string, WorldLocation> = $state(
|
||||
JSON.parse(localStorage.getItem("saved") ?? "{}"),
|
||||
);
|
||||
@ -5,3 +7,14 @@ export const saved: Record<string, WorldLocation> = $state(
|
||||
export function saveLocations() {
|
||||
localStorage.setItem("saved", JSON.stringify(saved));
|
||||
}
|
||||
|
||||
export async function geocode(name: string) {
|
||||
const loc = saved[name];
|
||||
if(!loc) return;
|
||||
const geocode = await reverseGeocode(loc);
|
||||
if(geocode.length == 0) {
|
||||
return;
|
||||
}
|
||||
const feature = geocode[0];
|
||||
return `${feature.properties.street}${feature.properties.housenumber ? (" " + feature.properties.housenumber) : ""}, ${feature.properties.city}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user