mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Dropdown in Settings
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./selectPortal";
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
|
||||
/**
|
||||
* {@attach selectPortal(triggerEl)}
|
||||
*
|
||||
* Moves the decorated element to <body> and positions it below `triggerEl`.
|
||||
* The element stays reactive — Svelte still owns its DOM, we just re-parent it.
|
||||
*
|
||||
* The portalled menu element is stored on `triggerEl.__selectMenuEl` so that
|
||||
* the outside-click guard in Settings.svelte can exclude it from dismissal.
|
||||
*/
|
||||
export function selectPortal(triggerEl: HTMLElement & { __selectMenuEl?: HTMLElement | null }): Attachment {
|
||||
return (menuEl: HTMLElement) => {
|
||||
// Position & move to body
|
||||
function position() {
|
||||
const r = triggerEl.getBoundingClientRect();
|
||||
menuEl.style.position = "fixed";
|
||||
menuEl.style.top = `${r.bottom + 4}px`;
|
||||
menuEl.style.left = `${r.right - menuEl.offsetWidth}px`;
|
||||
// clamp to viewport left edge
|
||||
const left = parseFloat(menuEl.style.left);
|
||||
if (left < 8) menuEl.style.left = "8px";
|
||||
}
|
||||
|
||||
document.body.appendChild(menuEl);
|
||||
triggerEl.__selectMenuEl = menuEl;
|
||||
position();
|
||||
|
||||
// Reposition on scroll / resize while open
|
||||
window.addEventListener("scroll", position, true);
|
||||
window.addEventListener("resize", position);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", position, true);
|
||||
window.removeEventListener("resize", position);
|
||||
triggerEl.__selectMenuEl = null;
|
||||
menuEl.remove();
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
|
||||
*::-webkit-scrollbar-thumb { background: transparent; border-radius: 99px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: transparent; }
|
||||
@@ -1,9 +1,10 @@
|
||||
/* ── Animations ───────────────────────────────────────────────────── */
|
||||
@keyframes s-fade-in { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes s-scale-in { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
@keyframes s-pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.55 } }
|
||||
@keyframes s-icon-down { from { transform: translateY(-5px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
||||
@keyframes s-icon-up { from { transform: translateY( 5px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
||||
@keyframes s-fade-in { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes s-scale-in { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
@keyframes s-pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.55 } }
|
||||
@keyframes s-icon-down { from { transform: translateY(-5px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
||||
@keyframes s-icon-up { from { transform: translateY( 5px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
||||
@keyframes s-dropdown-in { from { transform: translateY(-6px) scale(0.98); opacity: 0 } to { transform: translateY(0) scale(1); opacity: 1 } }
|
||||
|
||||
|
||||
/* ── Backdrop & Modal Shell ───────────────────────────────────────── */
|
||||
@@ -139,12 +140,7 @@
|
||||
.s-content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
}
|
||||
.s-content-body::-webkit-scrollbar { width: 4px; }
|
||||
.s-content-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.s-content-body::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 2px; }
|
||||
|
||||
|
||||
/* ── Panel & Section ──────────────────────────────────────────────── */
|
||||
@@ -319,19 +315,17 @@
|
||||
.s-select-caret.open { transform: rotate(180deg); }
|
||||
|
||||
.s-select-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 100%;
|
||||
position: fixed; /* portal sets top/left via inline style */
|
||||
min-width: 140px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-1);
|
||||
z-index: 9999;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
|
||||
animation: s-scale-in 0.1s ease both;
|
||||
transform-origin: top right;
|
||||
}
|
||||
.s-select-menu.anims { animation: s-dropdown-in 0.15s cubic-bezier(0.22,1,0.36,1) both; }
|
||||
|
||||
.s-select-option {
|
||||
display: block;
|
||||
@@ -972,12 +966,7 @@
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--sp-2);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
}
|
||||
.s-release-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.s-release-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.s-release-scroll::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 2px; }
|
||||
|
||||
.s-release-row {
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -85,11 +85,31 @@
|
||||
|
||||
// Shared select dropdown state (passed to sections that need it)
|
||||
let selectOpen: string | null = $state(null);
|
||||
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
||||
let closingSelect: string | null = $state(null);
|
||||
|
||||
const CLOSE_ANIM_MS = 120;
|
||||
|
||||
function closeSelect() {
|
||||
if (!selectOpen) return;
|
||||
closingSelect = selectOpen;
|
||||
selectOpen = null;
|
||||
setTimeout(() => { closingSelect = null; }, CLOSE_ANIM_MS);
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
if (selectOpen === id) { closeSelect(); }
|
||||
else { closingSelect = null; selectOpen = id; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (selectOpen && !(e.target as HTMLElement).closest(".s-select")) selectOpen = null;
|
||||
if (!selectOpen) return;
|
||||
const t = e.target as HTMLElement;
|
||||
// Keep open if click is inside the trigger wrapper (.s-select)
|
||||
if (t.closest(".s-select")) return;
|
||||
// Keep open if click landed inside the portalled menu (appended to <body>)
|
||||
if (t.closest(".s-select-menu")) return;
|
||||
closeSelect();
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
@@ -140,25 +160,25 @@
|
||||
|
||||
<div class="s-content-body" bind:this={contentBodyEl}>
|
||||
{#if tab === "general"}
|
||||
<GeneralSettings {selectOpen} {toggleSelect} />
|
||||
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "appearance"}
|
||||
<AppearanceSettings {onOpenThemeEditor} />
|
||||
{:else if tab === "reader"}
|
||||
<ReaderSettings {selectOpen} {toggleSelect} />
|
||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "library"}
|
||||
<LibrarySettings {selectOpen} {toggleSelect} />
|
||||
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
||||
{:else if tab === "performance"}
|
||||
<PerformanceSettings />
|
||||
{:else if tab === "keybinds"}
|
||||
<KeybindsSettings bind:listeningKey />
|
||||
{:else if tab === "storage"}
|
||||
<StorageSettings {selectOpen} {toggleSelect} />
|
||||
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
|
||||
{:else if tab === "folders"}
|
||||
<FoldersSettings />
|
||||
{:else if tab === "tracking"}
|
||||
<TrackingSettings />
|
||||
{:else if tab === "security"}
|
||||
<SecuritySettings {selectOpen} {toggleSelect} />
|
||||
<SecuritySettings {selectOpen} {closingSelect} {toggleSelect} />
|
||||
{:else if tab === "content"}
|
||||
<ContentSettings />
|
||||
{:else if tab === "about"}
|
||||
|
||||
@@ -444,9 +444,6 @@
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
display: flex; flex-direction: column; gap: var(--sp-6);
|
||||
}
|
||||
.editor-pane::-webkit-scrollbar { width: 4px; }
|
||||
.editor-pane::-webkit-scrollbar-track { background: transparent; }
|
||||
.editor-pane::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; }
|
||||
|
||||
.group { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.group-label {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { selectPortal } from "@core/actions/selectPortal";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
closingSelect: string | null;
|
||||
toggleSelect: (id: string) => void;
|
||||
anims: boolean;
|
||||
}
|
||||
|
||||
let { selectOpen, onToggleSelect }: Props = $props();
|
||||
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props();
|
||||
|
||||
let triggerIdleTimeout: HTMLButtonElement;
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
@@ -54,15 +59,15 @@
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Idle screen timeout</span><span class="s-desc">Show the Moku idle splash after this much inactivity</span></div>
|
||||
<div class="s-select" id="idle-timeout">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("idle-timeout")}>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerIdleTimeout} class="s-select-btn" onclick={() => toggleSelect("idle-timeout")}>
|
||||
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "idle-timeout"}
|
||||
<div class="s-select-menu">
|
||||
{#if selectOpen === "idle-timeout" || closingSelect === "idle-timeout"}
|
||||
<div class="s-select-menu" class:anims class:closing={closingSelect === "idle-timeout"} {@attach selectPortal(triggerIdleTimeout)}>
|
||||
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); onToggleSelect("idle-timeout"); }}>{l}</button>
|
||||
<button class="s-select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect("idle-timeout"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings, clearHistory, wipeAllData } from "@store/state.svelte";
|
||||
import type { Settings } from "@types/settings";
|
||||
import { selectPortal } from "@core/actions/selectPortal";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
toggleSelect: (id: string) => void;
|
||||
anims: boolean;
|
||||
}
|
||||
|
||||
let { selectOpen, onToggleSelect }: Props = $props();
|
||||
let { selectOpen, toggleSelect, anims }: Props = $props();
|
||||
|
||||
let triggerSortDir: HTMLButtonElement;
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
@@ -31,15 +35,15 @@
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default sort direction</span><span class="s-desc">Initial chapter list order when opening a manga</span></div>
|
||||
<div class="s-select" id="sort-dir">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("sort-dir")}>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerSortDir} class="s-select-btn" onclick={() => toggleSelect("sort-dir")}>
|
||||
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "sort-dir"}
|
||||
<div class="s-select-menu">
|
||||
<div class="s-select-menu" class:anims {@attach selectPortal(triggerSortDir)}>
|
||||
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); onToggleSelect("sort-dir"); }}>{l}</button>
|
||||
<button class="s-select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); toggleSelect("sort-dir"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import type { Settings, FitMode } from "@types/settings";
|
||||
import { selectPortal } from "@core/actions/selectPortal";
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null;
|
||||
onToggleSelect: (id: string) => void;
|
||||
toggleSelect: (id: string) => void;
|
||||
anims: boolean;
|
||||
}
|
||||
|
||||
let { selectOpen, onToggleSelect }: Props = $props();
|
||||
let { selectOpen, toggleSelect, anims }: Props = $props();
|
||||
|
||||
let triggerPageStyle: HTMLButtonElement;
|
||||
let triggerReadingDir: HTMLButtonElement;
|
||||
let triggerFitMode: HTMLButtonElement;
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
@@ -17,15 +23,15 @@
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default layout</span><span class="s-desc">How chapters open by default</span></div>
|
||||
<div class="s-select" id="page-style">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("page-style")}>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerPageStyle} class="s-select-btn" onclick={() => toggleSelect("page-style")}>
|
||||
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "page-style"}
|
||||
<div class="s-select-menu">
|
||||
<div class="s-select-menu" class:anims {@attach selectPortal(triggerPageStyle)}>
|
||||
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); onToggleSelect("page-style"); }}>{l}</button>
|
||||
<button class="s-select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); toggleSelect("page-style"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -33,15 +39,15 @@
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reading direction</span><span class="s-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
||||
<div class="s-select" id="reading-dir">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("reading-dir")}>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerReadingDir} class="s-select-btn" onclick={() => toggleSelect("reading-dir")}>
|
||||
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "reading-dir"}
|
||||
<div class="s-select-menu">
|
||||
<div class="s-select-menu" class:anims {@attach selectPortal(triggerReadingDir)}>
|
||||
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); onToggleSelect("reading-dir"); }}>{l}</button>
|
||||
<button class="s-select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); toggleSelect("reading-dir"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -67,15 +73,15 @@
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
|
||||
<div class="s-select" id="fit-mode">
|
||||
<button class="s-select-btn" onclick={() => onToggleSelect("fit-mode")}>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerFitMode} class="s-select-btn" onclick={() => toggleSelect("fit-mode")}>
|
||||
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "fit-mode"}
|
||||
<div class="s-select-menu">
|
||||
<div class="s-select-menu" class:anims {@attach selectPortal(triggerFitMode)}>
|
||||
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
|
||||
<button class="s-select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); onToggleSelect("fit-mode"); }}>{l}</button>
|
||||
<button class="s-select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect("fit-mode"); }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user