settings: the long lists use the app's own chooser — the sort menu's popover under a pill — instead of native selects the webview draws with the OS's text

This commit is contained in:
KeelrLeek committed 2026-09-08 15:43:13 -05:00
1 parent 7e291cfccc
commit 07ac3d3631
2 files changed
+167 -49

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
<script>
// A chooser: one value out of a list, in the app's own menu vocabulary
// (Library's sort menu ← CatalogDiscography ← the player bar's popover)
// instead of a native <select>, which WebView2 draws with the OS's text
// rendering and looks wrong beside everything else. The trigger is a
// pill with the current label and a chevron; the popover is the same
// dark .menu as the sort menu, with the accent check on the current row.
//
// Portalled to body and positioned fixed: a settings card clips its
// overflow, and the pane is a containing block for anything fixed
// inside it. Closes on a choice, Escape, an outside press, a scroll, or
// a resize — a menu that drifts from its trigger is worse than none.
import Icon from "./Icon.svelte";
/** @type {{ options: [string, string][], value: string, onchange: (v: string) => void, disabled?: boolean, title?: string, width?: number }} */
let { options, value, onchange, disabled = false, title = "", width = 0 } = $props();
let triggerEl = $state(null);
let menuEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0, minWidth: 186 });
const label = $derived(options.find(([v]) => v === value)?.[1] ?? value ?? "");
function portal(node) {
document.body.appendChild(node);
return { destroy: () => node.remove() };
}
function place() {
const r = triggerEl.getBoundingClientRect();
const minWidth = Math.max(186, width || r.width);
const est = 12 + options.length * 30;
let top = r.bottom + 6;
if (top + est > window.innerHeight - 8) top = Math.max(8, r.top - 6 - est);
let left = Math.min(r.right - minWidth, window.innerWidth - minWidth - 8);
left = Math.max(8, left);
pos = { top, left, minWidth };
}
function toggle(e) {
e.stopPropagation();
if (disabled) return;
if (open) {
open = false;
return;
}
place();
open = true;
}
function pick(v) {
open = false;
if (v !== value) onchange(v);
}
$effect(() => {
if (!open) return;
const onDown = (e) => {
if (triggerEl?.contains(e.target) || menuEl?.contains(e.target)) return;
open = false;
};
const onKey = (e) => {
if (e.key === "Escape") open = false;
};
const close = () => (open = false);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
<button bind:this={triggerEl} class="chooser" class:on={open} onclick={toggle} {disabled} {title} aria-haspopup="menu" aria-expanded={open}>
<span class="lab">{label}</span>
<Icon name="chevron-down" size={13} />
</button>
{#if open}
<div class="menu" role="menu" use:portal bind:this={menuEl} style:top="{pos.top}px" style:left="{pos.left}px" style:min-width="{pos.minWidth}px">
{#each options as [v, l] (v)}
<button role="menuitemradio" aria-checked={v === value} class:cur={v === value} onclick={() => pick(v)}>
{l}
{#if v === value}<span class="mcheck"><Icon name="check" size={13} /></span>{/if}
</button>
{/each}
</div>
{/if}
<style>
.chooser {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 11px 0 14px;
border-radius: 99px;
background: var(--bg-highlight);
color: var(--text-primary);
font-size: 12.5px;
font-weight: 800;
white-space: nowrap;
max-width: 320px;
transition: background 0.15s;
}
.chooser .lab { overflow: hidden; text-overflow: ellipsis; }
.chooser :global(svg) { color: var(--text-secondary); flex: none; transition: transform 160ms ease; }
.chooser:hover:not(:disabled), .chooser.on { background: var(--bg-press); }
.chooser.on :global(svg) { transform: rotate(180deg); }
.chooser:disabled { opacity: 0.6; cursor: default; }
/* the sort menu's popover, verbatim — solid on purpose: a menu has to be
readable over whatever it lands on, dark glass included */
.menu {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px;
border-radius: 9px;
background: #0d0d0d;
border: 1px solid var(--border);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
font-family: var(--app-font);
}
.menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.menu button:hover, .menu button.cur { color: var(--text-primary); }
.menu button:hover { background: var(--bg-highlight); }
.menu .mcheck { margin-left: auto; display: inline-flex; color: var(--accent-hover); }
</style>