feat: replace sidebar with svelte alternative

This commit is contained in:
2026-07-20 16:33:23 +09:30
parent 824cedca2f
commit c231a00db3
25 changed files with 1884 additions and 187 deletions
+371
View File
@@ -0,0 +1,371 @@
<script lang="ts">
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import {
restoreCustomMenuActive,
sidebarState,
} from "./sidebarState.svelte";
import SidebarItem from "./SidebarItem.svelte";
import type {
SidebarDrillFrame,
SidebarItem as SidebarItemModel,
} from "./types";
type Props = {
menuEl: HTMLElement;
};
let { menuEl }: Props = $props();
let dragKey = $state<string | null>(null);
let coverEl: HTMLElement | null = null;
const BACK_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><g style="fill: currentcolor;"><path d="M15.422 16.078l-1.406 1.406-6-6 6-6 1.406 1.406-4.594 4.594z"></path></g></svg>`;
function itemVisible(key: string): boolean {
return (
(settingsState.menuitems as Record<string, { toggle?: boolean }>)[key]
?.toggle !== false
);
}
function onActivate(item: SidebarItemModel) {
sidebarState.activateItem(item, menuEl);
}
function closeEdit() {
sidebarState.setEditMode(false);
menuEl.classList.remove("bsplus-sidebar-edit-mode");
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
mod.setMenuOptionsOpen(false);
});
}
function findFolder(
items: SidebarItemModel[],
key: string,
): SidebarItemModel | null {
for (const item of items) {
if (item.key === key) return item;
if (item.children.length) {
const nested = findFolder(item.children, key);
if (nested) return nested;
}
}
return null;
}
/** Folder meta for each drill frame (icon/label from the live tree). */
const drillFolders = $derived.by(() => {
return sidebarState.drillStack.map((frame) => {
const folder = findFolder(sidebarState.items, frame.key);
return { frame, folder };
});
});
function onToggleVisible(key: string, visible: boolean) {
sidebarState.setItemVisibility(key, visible);
}
function onDragStart(key: string) {
dragKey = key;
}
function onDrop(key: string) {
if (dragKey && sidebarState.editMode) {
sidebarState.reorderRoot(dragKey, key);
}
dragKey = null;
}
$effect(() => {
const editing = sidebarState.editMode;
const container = document.getElementById("container");
if (editing && container && !coverEl) {
const cover = document.createElement("div");
cover.className = "notMenuCover bsplus-sidebar-edit-cover";
cover.addEventListener("click", closeEdit);
container.appendChild(cover);
menuEl.style.zIndex = "20";
menuEl.classList.add("bsplus-sidebar-edit-mode");
coverEl = cover;
}
if (!editing) {
coverEl?.remove();
coverEl = null;
menuEl.style.removeProperty("z-index");
menuEl.classList.remove("bsplus-sidebar-edit-mode");
}
return () => {
coverEl?.remove();
coverEl = null;
menuEl.style.removeProperty("z-index");
};
});
// SEQTA strips `.active` from `#menu li` after clicks — keep it on our custom
// rows so theme animations (palm/sand) and drill `.sub` chrome stay applied.
$effect(() => {
// Re-run when the route-active key changes so we re-bind the observer target.
void sidebarState.activeKey;
void sidebarState.isDrilling;
const root = document.getElementById("bsplus-sidebar-root");
if (!root) return;
restoreCustomMenuActive();
const observer = new MutationObserver(() => restoreCustomMenuActive());
observer.observe(root, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
});
</script>
<!--
Direct `#menu > ul.logo-link` child so theme selectors like
`#menu > ul > li.item` / `#menu .sub` / `:has(> ul > li.hasChildren.active)` match.
-->
<ul
id="bsplus-sidebar-root"
class="logo-link bsplus-sidebar-list"
class:noscroll={sidebarState.isDrilling}
class:drilling={sidebarState.isDrilling}
class:compact={sidebarState.compact}
class:edit-mode={sidebarState.editMode}
aria-label="Main"
>
{#if sidebarState.editMode}
<li class="item bsplus-sidebar-edit-header" aria-hidden="true">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label><span class="label">Edit Sidebar</span></label>
</li>
{#each sidebarState.items as item (item.key)}
<SidebarItem
{item}
active={sidebarState.activeKey === item.key}
compact={sidebarState.compact}
editMode={sidebarState.editMode}
visible={itemVisible(item.key)}
drillEnter={false}
{onActivate}
{onToggleVisible}
{onDragStart}
{onDrop}
/>
{/each}
<li class="item bsplus-sidebar-edit-actions">
<button
type="button"
class="edit-btn"
onclick={() => sidebarState.restoreDefaultOrder()}
>
Restore Default
</button>
<button type="button" class="edit-btn primary" onclick={closeEdit}>
Save
</button>
</li>
{:else if drillFolders.length}
<!--
Keep all root rows in the DOM (like native SEQTA) so sidebar-animation.scss
can slide sibling labels away when a folder opens.
-->
{#each sidebarState.visibleRootItems as item (item.key)}
{#if item.key === drillFolders[0].frame.key}
{@render drillLevel(drillFolders, 0)}
{:else}
<SidebarItem
{item}
active={sidebarState.activeKey === item.key}
compact={false}
editMode={false}
visible={itemVisible(item.key)}
drillEnter={false}
{onActivate}
/>
{/if}
{/each}
{:else}
{#each sidebarState.visibleRootItems as item (item.key)}
<SidebarItem
{item}
active={sidebarState.activeKey === item.key}
compact={sidebarState.compact}
editMode={sidebarState.editMode}
visible={itemVisible(item.key)}
drillEnter={false}
{onActivate}
{onToggleVisible}
{onDragStart}
{onDrop}
/>
{/each}
{/if}
</ul>
{#snippet drillLevel(
frames: Array<{ frame: SidebarDrillFrame; folder: SidebarItemModel | null }>,
depth: number,
)}
{@const current = frames[depth]}
{@const isLast = depth === frames.length - 1}
{@const nextKey = isLast ? null : frames[depth + 1].frame.key}
{@const folder = current.folder}
<li
class="item hasChildren bsplus-sidebar-item"
class:active={true}
data-key={current.frame.key}
data-path={folder?.path ?? undefined}
style:--item-colour={folder?.itemColour || undefined}
>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
{#if folder?.iconHtml}
{@html folder.iconHtml}
{/if}
<span class="label">{current.frame.label}</span>
</label>
<div class="sub">
<div class="nav">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="back"
role="button"
tabindex="0"
aria-label="Back"
onclick={() => sidebarState.goBack()}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
sidebarState.goBack();
}
}}
>
{@html BACK_SVG}
<div class="backLabel">{current.frame.label}</div>
</div>
</div>
<ul>
{#if isLast}
{#each current.frame.items as item (item.key)}
<SidebarItem
{item}
active={sidebarState.activeKey === item.key}
compact={false}
editMode={false}
visible={itemVisible(item.key)}
drillEnter={true}
{onActivate}
/>
{/each}
{:else}
<!-- Keep nested siblings for the same slide-away animation. -->
{#each current.frame.items as item (item.key)}
{#if item.key === nextKey}
{@render drillLevel(frames, depth + 1)}
{:else}
<SidebarItem
{item}
active={sidebarState.activeKey === item.key}
compact={false}
editMode={false}
visible={itemVisible(item.key)}
drillEnter={false}
{onActivate}
/>
{/if}
{/each}
{/if}
</ul>
</div>
</li>
{/snippet}
<style>
.bsplus-sidebar-list {
list-style: none;
margin: 0;
padding: 0 0 16px;
flex: 1 1 auto;
min-height: 0;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
box-sizing: border-box;
color: var(--text-color, #fff);
font-family: Rubik, sans-serif;
scrollbar-width: thin;
z-index: 2;
}
.bsplus-sidebar-list.noscroll,
.bsplus-sidebar-list.drilling {
overflow: hidden;
}
.bsplus-sidebar-list.compact {
display: flex;
flex-direction: column;
align-items: center;
}
.bsplus-sidebar-edit-header {
pointer-events: none;
width: 85%;
margin: 8px auto 4px;
font-weight: 700;
font-size: 16px;
}
.bsplus-sidebar-edit-header > label {
display: block;
padding: 12px 16px 4px;
}
.bsplus-sidebar-edit-actions {
display: flex;
gap: 8px;
width: 85%;
margin: 12px auto 16px;
padding: 0;
list-style: none;
cursor: default;
}
.edit-btn {
flex: 1;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
color: inherit;
font: inherit;
font-weight: 700;
cursor: pointer;
transition:
background-color 0.2s ease,
transform 0.2s ease;
}
.edit-btn:hover {
background: rgba(0, 0, 0, 0.32);
transform: scale(1.02);
}
.edit-btn:active {
transform: scale(0.98);
}
.edit-btn.primary {
background: rgba(0, 0, 0, 0.4);
}
</style>