Files
wanderer/web/src/lib/components/base/single_select.svelte
slothful-vassal 485ec53f6d feat: add plugin system (#1034)
* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
2026-06-22 15:00:44 +02:00

258 lines
8.1 KiB
Svelte

<script module lang="ts">
export type SingleSelectItem = {
text: string;
value: any;
};
</script>
<script lang="ts">
import { onMount, tick } from "svelte";
interface Props {
name?: string;
items?: SingleSelectItem[];
value?: any;
label?: string;
ariaLabel?: string;
placeholder?: string;
disabled?: boolean;
onchange?: (value: any) => void;
}
let {
name = "",
items = [],
value = $bindable(""),
label = "",
ariaLabel = "",
placeholder = "",
disabled = false,
onchange,
}: Props = $props();
let open = $state(false);
let trigger: HTMLButtonElement | undefined = $state();
let menu: HTMLUListElement | undefined = $state();
let menuStyle = $state("");
let activeIndex = $state(-1);
let selectedItem = $derived(items.find((item) => item.value === value));
let selectedIndex = $derived(items.findIndex((item) => item.value === value));
async function positionMenu() {
await tick();
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const gap = 4;
const viewportPadding = 12;
const maxHeight = 256;
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const openAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const availableHeight = Math.max(
80,
Math.min(maxHeight, openAbove ? spaceAbove - gap : spaceBelow - gap),
);
const top = openAbove ? rect.top - gap - availableHeight : rect.bottom + gap;
menuStyle = [
`left: ${rect.left}px`,
`top: ${top}px`,
`width: ${rect.width}px`,
`max-height: ${availableHeight}px`,
].join("; ");
}
async function scrollActiveItemIntoView() {
await tick();
menu?.querySelector<HTMLElement>(`[data-single-select-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}
function selectItem(item: SingleSelectItem) {
if (disabled) return;
value = item.value;
open = false;
onchange?.(item.value);
}
async function openMenu(index = selectedIndex >= 0 ? selectedIndex : 0) {
if (disabled) return;
activeIndex = items.length ? Math.max(0, Math.min(index, items.length - 1)) : -1;
open = true;
await positionMenu();
await scrollActiveItemIntoView();
}
function closeMenu() {
open = false;
activeIndex = -1;
}
async function toggle() {
if (disabled) return;
if (open) {
closeMenu();
} else {
await openMenu();
}
}
async function setActiveIndex(index: number) {
if (!items.length) return;
activeIndex = (index + items.length) % items.length;
await scrollActiveItemIntoView();
}
async function handleKeydown(event: KeyboardEvent) {
if (disabled) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (!open) {
await openMenu(selectedIndex >= 0 ? selectedIndex : 0);
} else {
await setActiveIndex(activeIndex + 1);
}
break;
case "ArrowUp":
event.preventDefault();
if (!open) {
await openMenu(selectedIndex >= 0 ? selectedIndex : items.length - 1);
} else {
await setActiveIndex(activeIndex - 1);
}
break;
case "Home":
if (!open) return;
event.preventDefault();
await setActiveIndex(0);
break;
case "End":
if (!open) return;
event.preventDefault();
await setActiveIndex(items.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
if (!open) {
await openMenu();
} else if (activeIndex >= 0 && items[activeIndex]) {
selectItem(items[activeIndex]);
}
break;
case "Escape":
if (!open) return;
event.preventDefault();
closeMenu();
break;
}
}
onMount(() => {
function handleDocumentMouseDown(event: MouseEvent) {
const target = event.target as Node | null;
if (!target || trigger?.contains(target) || menu?.contains(target)) {
return;
}
open = false;
}
function handleDocumentKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeMenu();
}
}
function handleDocumentScroll(event: Event) {
if (!open) return;
const target = event.target as Node | null;
if (target && menu?.contains(target)) {
return;
}
closeMenu();
}
function handleWindowResize() {
if (!open) return;
positionMenu();
}
document.addEventListener("mousedown", handleDocumentMouseDown);
document.addEventListener("keydown", handleDocumentKeyDown);
document.addEventListener("scroll", handleDocumentScroll, true);
window.addEventListener("resize", handleWindowResize);
return () => {
document.removeEventListener("mousedown", handleDocumentMouseDown);
document.removeEventListener("keydown", handleDocumentKeyDown);
document.removeEventListener("scroll", handleDocumentScroll, true);
window.removeEventListener("resize", handleWindowResize);
};
});
</script>
<div class="relative">
{#if label.length}
<label for={name} class="text-sm font-medium pb-1">
{label}
</label>
{/if}
<button
bind:this={trigger}
id={name}
type="button"
aria-label={ariaLabel || label || name}
aria-haspopup="listbox"
aria-expanded={open}
class="relative flex h-10 w-full items-center rounded-md border border-input-border bg-input-background px-3 text-left transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
class:opacity-75={disabled}
class:cursor-default={disabled}
disabled={disabled}
onclick={toggle}
onkeydown={handleKeydown}
>
{#if selectedItem}
<span class="truncate">{selectedItem.text}</span>
{:else}
<span class="truncate text-gray-400">{placeholder}</span>
{/if}
<i
class="fa fa-caret-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
class:rotate-180={open}
></i>
</button>
{#if open}
<ul
bind:this={menu}
role="listbox"
class="fixed z-[1001] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg"
style={menuStyle}
>
{#each items as item, i}
<li
role="option"
aria-selected={item.value === value}
tabindex="-1"
class="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus"
class:bg-menu-item-background-focus={activeIndex === i}
data-single-select-index={i}
onmousedown={(event) => {
event.preventDefault();
selectItem(item);
}}
>
<span class="truncate">{item.text}</span>
{#if item.value === value}
<i class="fa fa-check ml-3"></i>
{/if}
</li>
{/each}
</ul>
{/if}
</div>