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
+18 -1
View File
@@ -144,6 +144,7 @@ export async function AddBetterSEQTAElements() {
setupEventListeners();
await addDarkLightToggle();
customizeMenuToggle();
// Kept as fallback if the custom Svelte sidebar fails to mount.
setupSidebarAccessibility();
}
@@ -672,6 +673,20 @@ function updateSidebarAccessibility() {
const menu = document.getElementById("menu");
if (!menu) return;
// Custom Svelte sidebar owns its own a11y / drill UI — do not mark its
// `#bsplus-sidebar-root` items offscreen based on the hidden native list.
if (menu.classList.contains("bsplus-custom-sidebar")) {
const root = document.getElementById("bsplus-sidebar-root");
if (root) {
for (const entry of root.querySelectorAll(`.${BSPLUS_SIDEBAR_OFFSCREEN}`)) {
if (entry instanceof HTMLElement) {
entry.classList.remove(BSPLUS_SIDEBAR_OFFSCREEN);
}
}
}
return;
}
const visibleList = getVisibleSidebarList(menu);
const visibleEntries = new Set(
visibleList ? getDirectSidebarEntries(visibleList) : [],
@@ -717,7 +732,9 @@ function getDirectSidebarEntries(list: HTMLElement) {
}
function getVisibleSidebarList(menu: HTMLElement) {
let currentList = menu.querySelector(":scope > ul") as HTMLElement | null;
let currentList = menu.querySelector(
":scope > ul:not(#bsplus-sidebar-root)",
) as HTMLElement | null;
while (currentList) {
const activeSubmenuParent = currentList.querySelector(
+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>
+236
View File
@@ -0,0 +1,236 @@
<script lang="ts">
import { fly } from "svelte/transition";
import type { SidebarItem } from "./types";
type Props = {
item: SidebarItem;
active: boolean;
compact: boolean;
editMode: boolean;
visible: boolean;
drillEnter?: boolean;
onActivate: (item: SidebarItem) => void;
onToggleVisible?: (key: string, visible: boolean) => void;
onDragStart?: (key: string) => void;
onDrop?: (key: string) => void;
};
let {
item,
active,
compact,
editMode,
visible,
drillEnter = false,
onActivate,
onToggleVisible,
onDragStart,
onDrop,
}: Props = $props();
const CHEVRON_SVG = `<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><g style="fill: currentcolor;"><path d="M8.578 16.359l4.594-4.594-4.594-4.594 1.406-1.406 6 6-6 6z"></path></g></svg>`;
</script>
<!-- SEQTA class names (item / hasChildren / active) so theme CSS keeps matching. -->
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<li
class="item bsplus-sidebar-item"
class:active={active}
class:hasChildren={item.hasChildren}
class:edit-mode={editMode}
class:hidden-item={editMode && !visible}
class:compact={compact}
class:draggable={editMode}
style:--item-colour={item.itemColour || undefined}
data-key={item.key}
data-path={item.path ?? undefined}
data-betterseqta={item.betterseqta ? "true" : undefined}
id={item.id ?? undefined}
role="button"
tabindex={editMode ? -1 : 0}
aria-label={item.label}
aria-current={active ? "page" : undefined}
draggable={editMode}
in:fly={{ x: drillEnter ? 24 : 0, duration: 180 }}
ondragstart={() => onDragStart?.(item.key)}
ondragover={(e) => e.preventDefault()}
ondrop={() => onDrop?.(item.key)}
onclick={() => {
if (!editMode) onActivate(item);
}}
onkeydown={(e) => {
if (editMode) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onActivate(item);
}
}}
>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>
{#if item.iconHtml}
<!-- svelte-ignore a11y_distracting_elements -->
{@html item.iconHtml}
{/if}
<span class="label">{item.label}</span>
{#if item.hasChildren && !compact && !editMode}
<span class="chevron" aria-hidden="true">{@html CHEVRON_SVG}</span>
{/if}
</label>
{#if editMode}
<label class="toggle">
<input
type="checkbox"
checked={visible}
onclick={(e) => e.stopPropagation()}
onchange={(e) =>
onToggleVisible?.(
item.key,
(e.currentTarget as HTMLInputElement).checked,
)}
/>
</label>
{/if}
{#if item.itemColour && !item.hasChildren}
<span class="colour-bar" aria-hidden="true"></span>
{/if}
</li>
<style>
/* Defaults only — theme #menu rules (often !important) win for look.
Keep folder + leaf rows left-aligned (no 85% centred width). */
.bsplus-sidebar-item {
position: relative;
list-style: none;
width: auto;
margin: 2px 6px;
/* Match leaf vertical spacing so folders don't sit lower than neighbours. */
padding: 0;
border-radius: 12px;
color: var(--text-color, #fff);
font-family: Rubik, sans-serif;
font-size: 16px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
box-sizing: border-box;
transition:
background-color 0.2s ease,
transform 0.2s ease,
opacity 0.2s ease;
user-select: none;
}
.bsplus-sidebar-item > label {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
margin: 0;
padding: 12px;
cursor: inherit;
color: inherit;
font: inherit;
}
.bsplus-sidebar-item:hover {
background: rgba(0, 0, 0, 0.15);
}
.bsplus-sidebar-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--theme-primary, #fff);
}
.bsplus-sidebar-item.active:not(.hasChildren) {
background: rgba(0, 0, 0, 0.35);
color: #fff;
}
.bsplus-sidebar-item.compact {
width: auto;
justify-content: center;
}
.bsplus-sidebar-item.compact > label {
padding: 8px;
justify-content: center;
}
.bsplus-sidebar-item.hidden-item {
opacity: 0.45;
}
.bsplus-sidebar-item.edit-mode {
cursor: grab;
}
.bsplus-sidebar-item :global(label > svg) {
flex-shrink: 0;
margin: 0 10px 0 4px;
width: 28px !important;
height: 28px !important;
}
.compact :global(label > svg) {
margin: 0;
}
.label {
flex: 1;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.2;
}
.compact .label,
.compact .chevron {
display: none;
}
.chevron {
display: inline-flex;
flex-shrink: 0;
margin-left: auto;
opacity: 0.85;
}
.chevron :global(svg) {
width: 24px;
height: 24px;
}
.toggle {
margin-right: 12px;
display: inline-flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.toggle input {
width: 18px;
height: 18px;
accent-color: var(--theme-primary, #fff);
}
.colour-bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3px;
border-radius: 8px 0 0 8px;
background: var(--item-colour, transparent);
transition: width 100ms ease;
pointer-events: none;
}
.bsplus-sidebar-item:hover .colour-bar,
.bsplus-sidebar-item.active .colour-bar {
width: 6px;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
export {
prepareCustomSidebarEarly,
mountCustomSidebar,
unmountCustomSidebar,
openCustomSidebarEditor,
closeCustomSidebarEditor,
isCustomSidebarMounted,
} from "./mountCustomSidebar";
export { sidebarState } from "./sidebarState.svelte";
export type { SidebarItem, SidebarDrillFrame } from "./types";
+284
View File
@@ -0,0 +1,284 @@
import { mount, unmount } from "svelte";
import type { SettingsState } from "@/types/storage";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { waitForElm } from "@/seqta/utils/waitForElm";
import Sidebar from "./Sidebar.svelte";
import { getNativeMenuList } from "./parseNativeMenu";
import {
clearNativeDrillActive,
sidebarState,
} from "./sidebarState.svelte";
const ROOT_ID = "bsplus-sidebar-root";
const MENU_CLASS = "bsplus-custom-sidebar";
const PENDING_CLASS = "bsplus-custom-sidebar-pending";
type ChangeListener = (newValue: unknown, oldValue: unknown) => void;
let app: ReturnType<typeof mount> | null = null;
let menuEl: HTMLElement | null = null;
let menuObserver: MutationObserver | null = null;
let syncTimer: ReturnType<typeof setTimeout> | null = null;
let hashListenerAttached = false;
let earlyPrepareStarted = false;
let catchupTimer: ReturnType<typeof setInterval> | null = null;
let nativeMenuListenerAttached = false;
function onNativeMenuUpdated() {
if (menuEl) sidebarState.syncFromNative(menuEl);
}
const settingsListeners: Array<{
key: keyof SettingsState;
listener: ChangeListener;
}> = [];
function startCatchupSync() {
if (catchupTimer) clearInterval(catchupTimer);
let attempts = 0;
catchupTimer = setInterval(() => {
attempts += 1;
if (menuEl) sidebarState.syncFromNative(menuEl);
// Plugins (Analytics, Overview, icons) inject shortly after first paint.
if (attempts >= 60) {
if (catchupTimer) clearInterval(catchupTimer);
catchupTimer = null;
}
}, 50);
}
function scheduleSync() {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(() => {
syncTimer = null;
if (menuEl) sidebarState.syncFromNative(menuEl);
}, 50);
}
function onHashChange() {
sidebarState.syncActiveFromLocation();
if (menuEl) clearNativeDrillActive(menuEl);
if (!sidebarState.isDrilling) return;
const path = sidebarState.activePath;
if (!path) return;
const openKey = sidebarState.drillStack[0]?.key;
const top = sidebarState.visibleRootItems.find(
(item) =>
item.key === openKey ||
item.path === path ||
(item.path != null && path.startsWith(`${item.path}/`)),
);
if (openKey && top?.key === openKey) return;
if (!path.includes("/")) sidebarState.resetDrill();
}
function ensureDefaultMenuOrder(menu: HTMLElement) {
const list = getNativeMenuList(menu);
if (!list) return;
const keys = [...list.children]
.map((node) => (node as HTMLElement).dataset.key)
.filter((key): key is string => !!key);
if (!settingsState.defaultmenuorder?.length) {
settingsState.defaultmenuorder = keys;
return;
}
for (const key of keys) {
if (!settingsState.defaultmenuorder.includes(key)) {
settingsState.defaultmenuorder = [
...settingsState.defaultmenuorder,
key,
];
}
}
}
function registerSetting(
key: keyof SettingsState,
listener: ChangeListener,
) {
settingsState.register(key, listener);
settingsListeners.push({ key, listener });
}
function clearSettingListeners() {
for (const { key, listener } of settingsListeners) {
settingsState.unregister(key, listener);
}
settingsListeners.length = 0;
}
function clearPendingClass() {
document.documentElement.classList.remove(PENDING_CLASS);
}
/**
* Hide the native SEQTA menu immediately (even under the loading overlay)
* and begin mounting the Svelte sidebar as soon as `#menu` exists.
*/
export function prepareCustomSidebarEarly() {
if (isSeqtaEngageExperience()) return;
if (!settingsState.onoff) return;
if (earlyPrepareStarted) return;
earlyPrepareStarted = true;
document.documentElement.classList.add(PENDING_CLASS);
void mountCustomSidebar();
}
export function openCustomSidebarEditor() {
if (!app || !menuEl) return false;
sidebarState.setEditMode(true);
menuEl.classList.add("bsplus-sidebar-edit-mode");
return true;
}
export function closeCustomSidebarEditor() {
sidebarState.setEditMode(false);
menuEl?.classList.remove("bsplus-sidebar-edit-mode");
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
mod.setMenuOptionsOpen(false);
});
}
export function isCustomSidebarMounted(): boolean {
return app != null;
}
export async function mountCustomSidebar(): Promise<boolean> {
if (isSeqtaEngageExperience()) return false;
if (!settingsState.onoff) return false;
// Already mounted — re-sync after Home/News/Analytics injections.
if (app && menuEl) {
ensureDefaultMenuOrder(menuEl);
sidebarState.syncSettings();
sidebarState.syncFromNative(menuEl);
startCatchupSync();
clearPendingClass();
return true;
}
document.documentElement.classList.add(PENDING_CLASS);
const menu = (await waitForElm("#menu", true, 50, 200)) as HTMLElement | null;
if (!menu) return false;
// Prefer a populated native list, but don't block forever during loading.
let list = getNativeMenuList(menu);
if (!list) {
await waitForElm("#menu > ul:not(#bsplus-sidebar-root)", true, 50, 120);
list = getNativeMenuList(menu);
}
if (!list) return false;
if (app) {
menuEl = menu;
ensureDefaultMenuOrder(menu);
sidebarState.syncFromNative(menu);
clearPendingClass();
return true;
}
menuEl = menu;
menu.classList.add(MENU_CLASS);
// Remove a stale root from a previous HMR / partial mount.
document.getElementById(ROOT_ID)?.remove();
ensureDefaultMenuOrder(menu);
sidebarState.syncSettings();
sidebarState.syncFromNative(menu);
// Mount as a direct child of `#menu` so root is `#menu > ul#bsplus-sidebar-root`.
app = mount(Sidebar, {
target: menu,
props: { menuEl: menu },
});
menu
.querySelectorAll(".bsplus-sidebar-offscreen")
.forEach((node) => node.classList.remove("bsplus-sidebar-offscreen"));
menuObserver?.disconnect();
menuObserver = new MutationObserver((mutations) => {
const ours = document.getElementById(ROOT_ID);
if (ours && mutations.every((m) => ours.contains(m.target))) return;
scheduleSync();
});
menuObserver.observe(menu, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["class", "style", "data-key", "data-path", "data-colour"],
});
if (!hashListenerAttached) {
window.addEventListener("hashchange", onHashChange);
hashListenerAttached = true;
}
if (!nativeMenuListenerAttached) {
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated);
nativeMenuListenerAttached = true;
}
clearSettingListeners();
registerSetting("iconOnlySidebar", () => {
sidebarState.syncSettings();
});
registerSetting("menuorder", () => {
if (menuEl) sidebarState.syncFromNative(menuEl);
});
registerSetting("menuitems", () => {
if (menuEl) sidebarState.syncFromNative(menuEl);
});
startCatchupSync();
clearPendingClass();
return true;
}
export function unmountCustomSidebar() {
menuObserver?.disconnect();
menuObserver = null;
if (syncTimer) clearTimeout(syncTimer);
syncTimer = null;
if (catchupTimer) clearInterval(catchupTimer);
catchupTimer = null;
clearSettingListeners();
if (hashListenerAttached) {
window.removeEventListener("hashchange", onHashChange);
hashListenerAttached = false;
}
if (nativeMenuListenerAttached) {
window.removeEventListener(
"bsplus-native-menu-updated",
onNativeMenuUpdated,
);
nativeMenuListenerAttached = false;
}
if (app) {
unmount(app);
app = null;
}
document.getElementById(ROOT_ID)?.remove();
menuEl?.classList.remove(MENU_CLASS, "bsplus-sidebar-edit-mode");
menuEl = null;
sidebarState.resetDrill();
sidebarState.setEditMode(false);
sidebarState.ready = false;
earlyPrepareStarted = false;
clearPendingClass();
}
@@ -0,0 +1,70 @@
/**
* @jest-environment jsdom
*/
import { getPagePathFromHash, parseNativeMenu } from "./parseNativeMenu";
describe("parseNativeMenu", () => {
it("parses top-level items and nested folders", () => {
document.body.innerHTML = `
<div id="menu">
<ul>
<li class="item" data-key="home" id="homebutton" data-path="/home" data-betterseqta="true">
<label><svg></svg><span>Home</span></label>
</li>
<li class="item hasChildren" data-key="assessments" data-path="/assessments">
<label><svg></svg><span>Assessments</span></label>
<div class="sub"><ul>
<li class="item betterseqta-assessments-overview-item" data-betterseqta="true">
<label>Overview</label>
</li>
<li class="item" data-key="upcoming" data-path="/assessments/upcoming">
<label>Upcoming</label>
</li>
</ul></div>
</li>
</ul>
</div>
`;
const menu = document.getElementById("menu")!;
const items = parseNativeMenu(menu);
expect(items.map((i) => i.key)).toEqual(["home", "assessments"]);
expect(items[0].betterseqta).toBe(true);
expect(items[1].hasChildren).toBe(true);
expect(items[1].children.map((c) => c.key)).toEqual([
"assessments-overview",
"upcoming",
]);
expect(items[1].children[0].label).toBe("Overview");
});
it("reads page path from hash", () => {
expect(getPagePathFromHash("#?page=/timetable")).toBe("/timetable");
expect(getPagePathFromHash("#?page=/assessments/upcoming&x=1")).toBe(
"/assessments/upcoming",
);
});
it("ignores the custom Svelte sidebar list when parsing", () => {
document.body.innerHTML = `
<div id="menu">
<ul class="logo-link">
<li class="item" data-key="home" data-path="/home">
<label>Home</label>
</li>
</ul>
<ul id="bsplus-sidebar-root" class="logo-link bsplus-sidebar-list">
<li class="item" data-key="fake" data-path="/fake">
<label>Fake</label>
</li>
</ul>
</div>
`;
const menu = document.getElementById("menu")!;
const items = parseNativeMenu(menu);
expect(items.map((i) => i.key)).toEqual(["home"]);
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { SidebarItem } from "./types";
/** Native SEQTA list only — never the custom Svelte `#bsplus-sidebar-root`. */
export function getNativeMenuList(menu: HTMLElement): HTMLElement | null {
return (
(menu.querySelector(
":scope > ul:not(#bsplus-sidebar-root)",
) as HTMLElement | null) ?? null
);
}
function readLabelText(label: HTMLElement | null): string {
if (!label) return "";
const clone = label.cloneNode(true) as HTMLElement;
clone.querySelectorAll("svg").forEach((svg) => svg.remove());
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
}
function readIconHtml(label: HTMLElement | null): string {
if (!label) return "";
const svg = label.querySelector(":scope > svg");
return svg instanceof SVGElement ? svg.outerHTML : "";
}
function parseEntry(entry: HTMLElement): SidebarItem | null {
if (!entry.matches("li.item, section.item, li, section")) return null;
const label = entry.querySelector(":scope > label") as HTMLElement | null;
const labelText = readLabelText(label);
let key =
entry.dataset.key ??
entry.id ??
labelText.toLowerCase().replace(/\s+/g, "-") ??
"";
// Stable key for BS+ Overview injection (no data-key on the native node).
if (
!entry.dataset.key &&
entry.classList.contains("betterseqta-assessments-overview-item")
) {
key = "assessments-overview";
}
if (!key && !labelText) return null;
const childList = entry.querySelector(
":scope > .sub > ul",
) as HTMLElement | null;
const children = childList
? [...childList.children]
.filter((node): node is HTMLElement => node instanceof HTMLElement)
.map(parseEntry)
.filter((item): item is SidebarItem => item != null)
: [];
return {
key: key || labelText,
path: entry.dataset.path ?? null,
id: entry.id || null,
label: labelText,
iconHtml: readIconHtml(label),
hasChildren: entry.classList.contains("hasChildren") || children.length > 0,
colour: entry.getAttribute("data-colour"),
itemColour: entry.style.getPropertyValue("--item-colour") || null,
betterseqta: entry.dataset.betterseqta === "true",
children,
};
}
/** Parse the native SEQTA `#menu` list into a plain tree for the Svelte sidebar. */
export function parseNativeMenu(menu: HTMLElement): SidebarItem[] {
const list = getNativeMenuList(menu);
if (!list) return [];
return [...list.children]
.filter((node): node is HTMLElement => node instanceof HTMLElement)
.map(parseEntry)
.filter((item): item is SidebarItem => item != null);
}
export function findNativeMenuEntry(
menu: HTMLElement,
item: Pick<SidebarItem, "key" | "id" | "path" | "label">,
): HTMLElement | null {
const list = getNativeMenuList(menu);
if (!list) return null;
if (item.key === "assessments-overview") {
const overview = list.querySelector(
".betterseqta-assessments-overview-item",
);
if (overview instanceof HTMLElement) return overview;
}
if (item.id) {
const byId = list.querySelector(`#${CSS.escape(item.id)}`);
if (byId instanceof HTMLElement) return byId;
}
if (item.key) {
const byKey = list.querySelector(
`li[data-key="${CSS.escape(item.key)}"], section[data-key="${CSS.escape(item.key)}"]`,
);
if (byKey instanceof HTMLElement) return byKey;
}
if (item.path) {
const byPath = list.querySelector(
`li[data-path="${CSS.escape(item.path)}"], section[data-path="${CSS.escape(item.path)}"]`,
);
if (byPath instanceof HTMLElement) return byPath;
}
if (item.label) {
const candidates = list.querySelectorAll<HTMLElement>(
"li.item, section.item",
);
for (const candidate of candidates) {
const label = candidate.querySelector(":scope > label");
if (
label instanceof HTMLElement &&
readLabelText(label) === item.label
) {
return candidate;
}
}
}
return null;
}
export function getPagePathFromHash(hash = location.hash): string {
const match = hash.match(/[?&]page=([^&]*)/);
if (!match) return "";
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}
+311
View File
@@ -0,0 +1,311 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import {
findNativeMenuEntry,
getNativeMenuList,
getPagePathFromHash,
parseNativeMenu,
} from "./parseNativeMenu";
import type { SidebarDrillFrame, SidebarItem } from "./types";
function orderItems(items: SidebarItem[], order: string[]): SidebarItem[] {
if (!order.length) return items;
const byKey = new Map(items.map((item) => [item.key, item]));
const seen = new Set<string>();
const ordered: SidebarItem[] = [];
for (const key of order) {
const item = byKey.get(key);
if (!item) continue;
ordered.push(item);
seen.add(key);
}
for (const item of items) {
if (!seen.has(item.key)) ordered.push(item);
}
return ordered;
}
function filterVisible(items: SidebarItem[]): SidebarItem[] {
const menuItems = settingsState.menuitems as Record<
string,
{ toggle?: boolean } | undefined
>;
return items.filter((item) => menuItems[item.key]?.toggle !== false);
}
/**
* SEQTA (and some themes) strip `.active` from `#menu li` after navigation.
* Theme decorations (e.g. beach palm/sand) and drill `.sub` chrome all depend
* on that class staying on our custom list — re-apply it from known state.
*/
export function restoreCustomMenuActive() {
const root = document.getElementById("bsplus-sidebar-root");
if (!root) return;
// Open drill folders (must keep `.active` for `.sub` layout + theme selectors).
for (const li of root.querySelectorAll("li.hasChildren")) {
if (!(li instanceof HTMLElement)) continue;
if (!li.querySelector(":scope > .sub")) continue;
if (!li.classList.contains("active")) li.classList.add("active");
}
// Route-active row — Svelte sets aria-current, but SEQTA often removes `.active`.
const activeKey = sidebarState.activeKey;
if (activeKey) {
const activeLi = root.querySelector(
`li.item[data-key="${CSS.escape(activeKey)}"]`,
);
if (
activeLi instanceof HTMLElement &&
!activeLi.classList.contains("active")
) {
activeLi.classList.add("active");
}
}
for (const li of root.querySelectorAll('li.item[aria-current="page"]')) {
if (li instanceof HTMLElement && !li.classList.contains("active")) {
li.classList.add("active");
}
}
}
/** @deprecated Use restoreCustomMenuActive */
export const restoreCustomDrillActive = restoreCustomMenuActive;
/** Clear native drill state so it cannot steal pointer-events from the custom list. */
export function clearNativeDrillActive(menu: HTMLElement) {
const nativeList = getNativeMenuList(menu);
nativeList
?.querySelectorAll("li.hasChildren.active, section.hasChildren.active")
.forEach((node) => node.classList.remove("active"));
}
function findItemByPath(
items: SidebarItem[],
path: string,
): SidebarItem | null {
for (const item of items) {
if (item.path === path) return item;
if (item.children.length) {
const nested = findItemByPath(item.children, path);
if (nested) return nested;
}
}
return null;
}
function findItemByKey(
items: SidebarItem[],
key: string,
): SidebarItem | null {
for (const item of items) {
if (item.key === key) return item;
if (item.children.length) {
const nested = findItemByKey(item.children, key);
if (nested) return nested;
}
}
return null;
}
class SidebarState {
items = $state.raw<SidebarItem[]>([]);
drillStack = $state.raw<SidebarDrillFrame[]>([]);
activeKey = $state<string | null>(null);
activePath = $state("");
editMode = $state(false);
iconOnly = $state(false);
ready = $state(false);
visibleRootItems = $derived(
filterVisible(orderItems(this.items, settingsState.menuorder ?? [])),
);
currentItems = $derived(
this.drillStack.length > 0
? this.drillStack[this.drillStack.length - 1].items
: this.visibleRootItems,
);
currentTitle = $derived(
this.drillStack.length > 0
? this.drillStack[this.drillStack.length - 1].label
: null,
);
isDrilling = $derived(this.drillStack.length > 0);
compact = $derived(this.iconOnly && !this.isDrilling && !this.editMode);
syncFromNative(menu: HTMLElement) {
this.items = parseNativeMenu(menu);
this.ready = this.items.length > 0;
this.syncActiveFromLocation();
this.pruneDrillStack();
}
syncSettings() {
this.iconOnly = settingsState.iconOnlySidebar === true;
}
syncActiveFromLocation() {
const path = getPagePathFromHash();
this.activePath = path;
if (!path) {
this.activeKey = null;
return;
}
const exact = findItemByPath(this.items, path);
if (exact) {
this.activeKey = exact.key;
return;
}
// Nested routes like /assessments/upcoming → assessments
const segments = path.split("/").filter(Boolean);
while (segments.length > 1) {
segments.pop();
const parentPath = `/${segments.join("/")}`;
const parent = findItemByPath(this.items, parentPath);
if (parent) {
this.activeKey = parent.key;
return;
}
}
this.activeKey = null;
}
pruneDrillStack() {
if (!this.drillStack.length) return;
const next: SidebarDrillFrame[] = [];
let cursor = this.visibleRootItems;
for (const frame of this.drillStack) {
const folder = cursor.find((item) => item.key === frame.key);
if (!folder?.hasChildren) break;
const children = filterVisible(folder.children);
next.push({ key: folder.key, label: folder.label, items: children });
cursor = children;
}
this.drillStack = next;
}
openFolder(item: SidebarItem) {
if (!item.hasChildren) return;
this.drillStack = [
...this.drillStack,
{
key: item.key,
label: item.label,
items: filterVisible(item.children),
},
];
}
goBack() {
if (!this.drillStack.length) return;
this.drillStack = this.drillStack.slice(0, -1);
}
resetDrill() {
this.drillStack = [];
}
setEditMode(enabled: boolean) {
this.editMode = enabled;
if (enabled) this.resetDrill();
}
reorderRoot(fromKey: string, toKey: string) {
if (fromKey === toKey) return;
const keys = this.visibleRootItems.map((item) => item.key);
const from = keys.indexOf(fromKey);
const to = keys.indexOf(toKey);
if (from < 0 || to < 0) return;
const next = [...keys];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
settingsState.menuorder = next;
}
setItemVisibility(key: string, visible: boolean) {
const current = {
...(settingsState.menuitems as Record<string, { toggle: boolean }>),
};
current[key] = { toggle: visible };
settingsState.menuitems = current as typeof settingsState.menuitems;
}
restoreDefaultOrder() {
const defaults = settingsState.defaultmenuorder;
if (defaults?.length) {
settingsState.menuorder = [...defaults];
}
const restored: Record<string, { toggle: boolean }> = {};
for (const item of this.items) {
restored[item.key] = { toggle: true };
}
settingsState.menuitems = restored as typeof settingsState.menuitems;
}
activateItem(item: SidebarItem, menu: HTMLElement) {
if (this.editMode) return;
if (item.hasChildren) {
this.openFolder(item);
return;
}
this.activeKey = item.key;
if (item.path) this.activePath = item.path;
const native = findNativeMenuEntry(menu, item);
if (native) {
// Never strip `.active` from custom `#bsplus-sidebar-root` folders.
clearNativeDrillActive(menu);
native.click();
// SEQTA re-opens native drill and strips `.active` after click — undo both.
clearNativeDrillActive(menu);
restoreCustomMenuActive();
requestAnimationFrame(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
});
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 0);
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 50);
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 100);
return;
}
if (item.path) {
location.hash = `?page=${item.path}`;
}
}
findByKey(key: string) {
return findItemByKey(this.items, key);
}
}
export const sidebarState = new SidebarState();
+19
View File
@@ -0,0 +1,19 @@
export type SidebarItem = {
key: string;
path: string | null;
id: string | null;
label: string;
/** Outer HTML of the label's leading SVG (if any). */
iconHtml: string;
hasChildren: boolean;
colour: string | null;
itemColour: string | null;
betterseqta: boolean;
children: SidebarItem[];
};
export type SidebarDrillFrame = {
key: string;
label: string;
items: SidebarItem[];
};
+19 -36
View File
@@ -1,11 +1,17 @@
import type { SettingsState } from "@/types/storage";
import { settingsState } from "../listeners/SettingsState";
import { applyMenuItemVisibility } from "../menuItemVisibility";
import { ensureAnalyticsMenuOrder } from "@/seqta/utils/sidebarMenuIcons";
import { ensureAnalyticsMenuOrder } from "@/seqta/utils/Openers/analyticsMenuOrder";
import {
isMenuOptionsOpen,
setMenuOptionsOpen,
} from "@/seqta/utils/Openers/menuOptionsState";
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import stringToHTML from "../stringToHTML";
import Sortable from "sortablejs";
export let MenuOptionsOpen = false;
export { MenuOptionsOpen, setMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
export { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
function escapeHtmlAttr(value: string): string {
return value
@@ -190,14 +196,22 @@ function restoreMenuItemsFromEditMode(
}
export function OpenMenuOptions() {
if (MenuOptionsOpen) return;
if (isMenuOptionsOpen()) return;
// Custom Svelte sidebar owns edit mode when mounted.
if (document.getElementById("bsplus-sidebar-root")) {
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
if (mod.openCustomSidebarEditor()) setMenuOptionsOpen(true);
});
return;
}
const container = document.getElementById("container");
const menu = document.getElementById("menu");
if (!container || !menu) return;
syncDefaultMenuOrder(menu);
MenuOptionsOpen = true;
setMenuOptionsOpen(true);
menu.classList.add("bsplus-sidebar-edit-mode");
const { cover, menusettings, defaultbutton, savebutton } =
@@ -220,7 +234,7 @@ export function OpenMenuOptions() {
const closeAll = () => {
menusettings.remove();
cover.remove();
MenuOptionsOpen = false;
setMenuOptionsOpen(false);
menu.classList.remove("bsplus-sidebar-edit-mode");
menu.style.setProperty("--menuHidden", "none");
restoreMenuItemsFromEditMode(menu, listItems);
@@ -267,34 +281,3 @@ function cloneAttributes(target: any, source: any) {
target.setAttribute(attr.nodeName, attr.nodeValue);
});
}
export function ChangeMenuItemPositions(menuorder: SettingsState["menuorder"]) {
var menuList = document.querySelector("#menu")!.firstChild!.childNodes;
let listorder = [];
for (let i = 0; i < menuList.length; i++) {
const menu = menuList[i] as HTMLElement;
let a = menuorder.indexOf(menu.dataset.key);
listorder.push(a);
}
var newArr = [];
for (var i = 0; i < listorder.length; i++) {
const index = listorder[i];
if (index >= 0) {
newArr[index] = menuList[i];
}
}
let listItemsDOM = document.getElementById("menu")!.firstChild;
for (let i = 0; i < newArr.length; i++) {
const element = newArr[i];
if (element) {
const elem = element as HTMLElement;
elem.setAttribute("data-checked", "true");
listItemsDOM!.appendChild(element);
}
}
}
@@ -0,0 +1,44 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
/** Insert a menu DOM node after the top-level item with `afterKey`. */
export function insertMenuItemAfterKey(
menuList: HTMLElement,
item: HTMLElement,
afterKey: string,
): void {
const after = menuList.querySelector(
`:scope > li[data-key="${afterKey}"], :scope > section[data-key="${afterKey}"]`,
);
if (after instanceof HTMLElement) {
after.insertAdjacentElement("afterend", item);
} else {
menuList.appendChild(item);
}
}
export function insertKeyAfterInOrder(
order: string[],
key: string,
afterKey: string,
): string[] {
if (order.includes(key)) return order;
const copy = [...order];
const afterIdx = copy.indexOf(afterKey);
if (afterIdx >= 0) {
copy.splice(afterIdx + 1, 0, key);
} else {
copy.push(key);
}
return copy;
}
/** Default Analytics immediately below Courses in saved menu order. */
export function ensureAnalyticsMenuOrder(): void {
for (const key of ["defaultmenuorder", "menuorder"] as const) {
const order = settingsState[key];
if (key === "menuorder" && order.length === 0) continue;
if (!order.includes("analytics")) {
settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
}
}
}
@@ -0,0 +1,13 @@
/**
* Tiny flag module with no imports — breaks the OpenMenuOptions ↔ sidebarMenuIcons
* cycle that caused Vite HMR: "does not provide an export named 'MenuOptionsOpen'".
*/
export let MenuOptionsOpen = false;
export function setMenuOptionsOpen(open: boolean) {
MenuOptionsOpen = open;
}
export function isMenuOptionsOpen(): boolean {
return MenuOptionsOpen;
}
+32
View File
@@ -0,0 +1,32 @@
import type { SettingsState } from "@/types/storage";
/** Reorder `#menu > ul` children to match a saved `menuorder` key list. */
export function ChangeMenuItemPositions(menuorder: SettingsState["menuorder"]) {
const menuRoot = document.querySelector("#menu")?.firstChild;
if (!menuRoot) return;
const menuList = menuRoot.childNodes;
const listorder: number[] = [];
for (let i = 0; i < menuList.length; i++) {
const menu = menuList[i] as HTMLElement;
listorder.push(menuorder.indexOf(menu.dataset.key));
}
const newArr: ChildNode[] = [];
for (let i = 0; i < listorder.length; i++) {
const index = listorder[i];
if (index >= 0) {
newArr[index] = menuList[i];
}
}
for (let i = 0; i < newArr.length; i++) {
const element = newArr[i];
if (element) {
const elem = element as HTMLElement;
elem.setAttribute("data-checked", "true");
menuRoot.appendChild(element);
}
}
}
+3 -5
View File
@@ -1,10 +1,8 @@
import browser from "webextension-polyfill";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
import {
MenuOptionsOpen,
OpenMenuOptions,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { OpenMenuOptions } from "@/seqta/utils/Openers/OpenMenuOptions";
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
@@ -144,7 +142,7 @@ export class MessageHandler {
}
editSidebar() {
if (!MenuOptionsOpen) {
if (!isMenuOptionsOpen()) {
OpenMenuOptions();
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
import { registerHomeUpcomingSettingsListeners } from "@/seqta/utils/Loaders/LoadHomePage";
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/OpenMenuOptions";
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import browser from "webextension-polyfill";
import type { CustomShortcut } from "@/types/storage";
+3 -1
View File
@@ -10,7 +10,9 @@ export function applyMenuItemVisibility(): void {
let css = "";
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
if (config && !config.toggle) {
css += `li[data-key=${menuItem}],section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
// Scope to the native SEQTA list only — the custom Svelte sidebar
// filters visibility in JS and must not be forced hidden by this rule.
css += `#menu > ul:not(#bsplus-sidebar-root) li[data-key=${menuItem}],#menu > ul:not(#bsplus-sidebar-root) section[data-key=${menuItem}]{display:var(--menuHidden) !important;transition:1s;}`;
}
}
+5 -49
View File
@@ -1,8 +1,6 @@
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
import {
ChangeMenuItemPositions,
MenuOptionsOpen,
} from "@/seqta/utils/Openers/OpenMenuOptions";
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
@@ -12,48 +10,6 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
export function insertMenuItemAfterKey(
menuList: HTMLElement,
item: HTMLElement,
afterKey: string,
): void {
const after = menuList.querySelector(
`:scope > li[data-key="${afterKey}"], :scope > section[data-key="${afterKey}"]`,
);
if (after instanceof HTMLElement) {
after.insertAdjacentElement("afterend", item);
} else {
menuList.appendChild(item);
}
}
export function insertKeyAfterInOrder(
order: string[],
key: string,
afterKey: string,
): string[] {
if (order.includes(key)) return order;
const copy = [...order];
const afterIdx = copy.indexOf(afterKey);
if (afterIdx >= 0) {
copy.splice(afterIdx + 1, 0, key);
} else {
copy.push(key);
}
return copy;
}
/** Default Analytics immediately below Courses in saved menu order. */
export function ensureAnalyticsMenuOrder(): void {
for (const key of ["defaultmenuorder", "menuorder"] as const) {
const order = settingsState[key];
if (key === "menuorder" && order.length === 0) continue;
if (!order.includes("analytics")) {
settingsState[key] = insertKeyAfterInOrder(order, "analytics", "courses");
}
}
}
function getMenuLabel(element: HTMLElement): HTMLElement | null {
const label = element.querySelector(":scope > label");
return label instanceof HTMLElement ? label : null;
@@ -107,7 +63,7 @@ export function replaceMenuSVG(element: HTMLElement, svg: string) {
}
export function processMenuItemNode(node: HTMLElement) {
if (!isTopLevelSidebarItem(node) || MenuOptionsOpen) return;
if (!isTopLevelSidebarItem(node) || isMenuOptionsOpen()) return;
const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
if (key && MenuitemSVGKey[key]) {
@@ -119,7 +75,7 @@ export function processMenuItemNode(node: HTMLElement) {
}
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
if (MenuOptionsOpen) return;
if (isMenuOptionsOpen()) return;
const topList = getTopLevelMenuList();
if (!topList) return;
@@ -186,7 +142,7 @@ export async function observeMenuItemPosition() {
if (!isTopLevelSidebarItem(node)) return;
if ((element as any)[processedSymbol]) return;
if (!MenuOptionsOpen) {
if (!isMenuOptionsOpen()) {
processMenuItemNode(node);
ChangeMenuItemPositions(settingsState.menuorder);
(element as any)[processedSymbol] = true;