Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
11 kB · 467 lines
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468<script lang="ts">import { autoUpdate, computePosition, flip, shift, type VirtualElement,} from "@floating-ui/dom";import { t } from "../languages/translate.svelte.js";
export type SubItem = { label?: string; action?: () => void; disabled?: boolean; type?: "separator";};
export type Item = SubItem & { items?: SubItem[];};
let { x, y, items, keyboard = false, onclose,} = $props<{ x: number; y: number; items: Item[]; keyboard?: boolean; onclose: () => void;}>();const componentId = $props.id();const submenuId = `${componentId}-submenu`;
let active = $state<number | null>(null), subActive = $state<number | null>(null), submenuOpen = $state(false), width = $state(0), height = $state(0), submenuWidth = $state(0), submenuHeight = $state(0), element = $state<HTMLElement>(), submenuElement = $state<HTMLElement>();let submenuTimer: number | undefined;
$effect(() => { const prevFocus = document.activeElement as HTMLElement | null; element?.focus(); return () => { clearTimeout(submenuTimer); prevFocus?.focus(); };});
const exec = (action?: () => void) => { if (action) { action(); onclose(); }};
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));
const activeRow = $derived.by(() => active === null || !element ? undefined : (element.children[active] as HTMLElement | undefined),);const activeItem = $derived(active === null ? undefined : items[active]);
const nextSelectable = ( list: SubItem[], current: number | null, step: 1 | -1,) => { let index = current ?? (step === 1 ? -1 : 0); for (let offset = 0; offset < list.length; offset++) { index = (index + step + list.length) % list.length; if (list[index]?.type !== "separator" && !list[index]?.disabled) return index; } return null;};
const closeSubmenu = () => { clearTimeout(submenuTimer); submenuOpen = false; subActive = null;};
const openSubmenu = (subItems: SubItem[]) => { clearTimeout(submenuTimer); submenuOpen = true; subActive = nextSelectable(subItems, null, 1);};
const selectItem = (index: number | null) => { if (active === index) return; closeSubmenu(); active = index; if (index !== null && items[index]?.items) { submenuTimer = window.setTimeout(() => { submenuOpen = active === index; }, 150); }};
const hoverItem = (event: PointerEvent, isSub: boolean) => { event.stopPropagation(); const button = (event.target as Element).closest<HTMLButtonElement>("button"); const index = button ? Number(button.dataset.index) : null; if (index === null || button?.disabled) { if (isSub) subActive = null; else selectItem(null); return; } if (isSub) { subActive = index; return; } selectItem(index);};
$effect(() => { const container = submenuOpen ? submenuElement : element; const item = submenuOpen && subActive !== null ? (submenuElement?.children[subActive] as HTMLElement | undefined) : activeRow; if (!container || !item) return; const bottom = item.offsetTop + item.offsetHeight; if (item.offsetTop < container.scrollTop) container.scrollTop = item.offsetTop; else if (bottom > container.scrollTop + container.clientHeight) container.scrollTop = bottom - container.clientHeight;});
$effect(() => { const floating = element; const point = { x, y }; if (!floating) return;
const reference: VirtualElement = { getBoundingClientRect: () => DOMRect.fromRect(point), }; const update = () => { void computePosition(reference, floating, { strategy: "fixed", placement: "bottom-start", middleware: [flip(), shift()], }).then(({ x: left, y: computedTop }) => { const menuWidth = floating.offsetWidth; const menuHeight = floating.offsetHeight; const top = point.y < menuHeight && window.innerHeight - point.y < menuHeight ? Math.max(0, (window.innerHeight - menuHeight) / 2) : computedTop; floating.style.left = `${left}px`; floating.style.top = `${top}px`; floating.style.setProperty( "--menu-origin-x", `${keyboard ? menuWidth / 2 : clamp(point.x - left, 0, menuWidth)}px`, ); floating.style.setProperty( "--menu-origin-y", `${ keyboard ? top < point.y ? menuHeight : 0 : clamp(point.y - top, 0, menuHeight) }px`, ); }); };
update(); return autoUpdate(reference, floating, update);});
$effect(() => { const reference = activeRow; const floating = submenuElement; if (!submenuOpen || !reference || !floating) return;
const update = () => { void computePosition(reference, floating, { strategy: "fixed", placement: "right-start", middleware: [ flip({ fallbackPlacements: ["left-start"], }), shift(), ], }).then(({ x: left, y: top, placement }) => { const opensLeft = placement.startsWith("left"); const menuWidth = floating.offsetWidth; const menuHeight = floating.offsetHeight; const referenceRect = reference.getBoundingClientRect(); floating.style.left = `${left}px`; floating.style.top = `${top}px`; floating.style.setProperty( "--menu-origin-x", `${opensLeft ? menuWidth : 0}px`, ); floating.style.setProperty( "--menu-origin-y", `${clamp( referenceRect.top + referenceRect.height / 2 - top, 0, menuHeight, )}px`, ); floating.style.setProperty( "--menu-enter-x", opensLeft ? "0.1875rem" : "-0.1875rem", ); }); };
update(); return autoUpdate(reference, floating, update);});
function onKey(event: KeyboardEvent) { event.stopPropagation(); const key = event.key; if (key === "Escape") { if (submenuOpen) closeSubmenu(); else onclose(); } else if (key === "ArrowDown" || key === "ArrowUp") { event.preventDefault(); const step = key === "ArrowDown" ? 1 : -1; if (subActive !== null && activeItem?.items) { subActive = nextSelectable(activeItem.items, subActive, step); } else { selectItem(nextSelectable(items, active, step)); } } else if ( key === "PageDown" || key === "PageUp" || key === "Home" || key === "End" ) { event.preventDefault(); const subItems = subActive !== null ? activeItem?.items : undefined; const list = subItems ?? items; const current = subItems ? subActive : active; const step = key === "PageDown" || key === "End" ? 1 : -1; const count = key === "Home" || key === "End" ? list.length : 10; let next = current; if (key === "Home" || key === "End") { next = nextSelectable(list, null, step); } else { for (let offset = 0; offset < count; offset++) { const candidate = nextSelectable(list, next, step); if ( candidate === null || candidate === next || (next !== null && ((step === 1 && candidate < next) || (step === -1 && candidate > next))) ) break; next = candidate; } } if (subItems) subActive = next; else selectItem(next); } else if (key === "ArrowRight") { if (activeItem?.items) { event.preventDefault(); openSubmenu(activeItem.items); } } else if (key === "ArrowLeft") { if (submenuOpen) { event.preventDefault(); closeSubmenu(); } } else if (key === "Enter" || key === " ") { event.preventDefault(); if (activeItem?.items) { if (subActive === null) openSubmenu(activeItem.items); else exec(activeItem.items[subActive]?.action); } else { exec(activeItem?.action); } }}</script>
{#snippet menuButton( item: Item, index: number, isSub = false,)} <button type="button" id={`${componentId}-${isSub ? "sub-" : ""}${index}`} class="no-touch-target" class:active={isSub ? subActive === index : active === index} disabled={item.disabled} data-index={index} role="menuitem" aria-haspopup={item.items ? "menu" : undefined} aria-expanded={item.items ? submenuOpen && active === index : undefined} tabindex="-1" onclick={(event) => { event.stopPropagation(); if (item.items) { if (active === index && submenuOpen) closeSubmenu(); else { active = index; openSubmenu(item.items); } } else { exec(item.action); } }} > {item.label} {#if item.items} <span class="text-footnote" aria-hidden="true">→</span> {/if} </button>{/snippet}
<!-- svelte-ignore a11y_click_events_have_key_events --><!-- biome-ignore lint/a11y/noStaticElementInteractions: Pointer backdrop dismissal --><div class="overlay" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) onclose(); }} oncontextmenu={(event) => { event.preventDefault(); onclose(); }}> <div bind:this={element} class="menu" class:ready={width > 0 && height > 0} onpointermove={(event) => hoverItem(event, false)} onkeydown={onKey} bind:clientWidth={width} bind:clientHeight={height} role="menu" aria-label={t("shortcut.context_menu")} aria-owns={submenuOpen ? submenuId : undefined} aria-activedescendant={submenuOpen && subActive !== null ? `${componentId}-sub-${subActive}` : active === null ? undefined : `${componentId}-${active}`} tabindex="0" > {#each items as item, index} {#if item.type === "separator"} <hr> {:else} {@render menuButton(item, index)} {/if} {/each} </div> {#if submenuOpen && activeItem?.items} <div id={submenuId} class="menu submenu" class:ready={submenuWidth > 0 && submenuHeight > 0} onpointermove={(event) => hoverItem(event, true)} bind:this={submenuElement} bind:clientWidth={submenuWidth} bind:clientHeight={submenuHeight} role="menu" aria-label={activeItem.label} tabindex="-1" > {#each activeItem.items as sub, subIndex} {#if sub.type === "separator"} <hr> {:else} {@render menuButton(sub, subIndex, true)} {/if} {/each} </div> {/if}</div>
<style>.overlay { position: fixed; inset: 0; z-index: 1000;}.menu { position: fixed; top: 0; left: 0; transform-origin: var(--menu-origin-x) var(--menu-origin-y); background-color: Canvas; color: CanvasText; border: var(--border); box-shadow: 0 2px 10px rgb(0 0 0 / 0.2); min-inline-size: min(10rem, var(--viewport-inline-size)); max-inline-size: var(--viewport-inline-size); max-block-size: var(--viewport-block-size); overflow-y: auto; display: flex; flex-direction: column; border-radius: var(--radius-md); corner-shape: squircle; overflow-x: hidden;
&:focus-visible { outline: none; }}:global(html.motion) .menu.ready { animation: pop-in 75ms ease-out;}:global(html.motion) .submenu.ready { animation-name: pop-from-side;}:global(html.transparency) .menu { background-color: color-mix(Canvas 80%, transparent); backdrop-filter: blur(16px);}@keyframes pop-from-side { from { opacity: 0; transform: translateX(var(--menu-enter-x)) scale(0.95); }}
button { text-align: start; padding: 0.375rem 0.75rem; inline-size: 100%; display: flex; justify-content: space-between; align-items: center; white-space: nowrap; gap: 1rem;
&:not(:disabled):hover, &.active { background-color: var(--bg-highlight); color: var(--text-highlight); }
&:disabled { opacity: 0.5; }}hr { margin: 0; border: 0; border-block-start: var(--border-subtle);}</style>