mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-08-28 09:11:06 +00:00
feat: replace sidebar with svelte alternative
This commit is contained in:
@@ -37,8 +37,6 @@ function registerFetchSeqtaAppLinkListener() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export let MenuOptionsOpen = false;
|
|
||||||
|
|
||||||
var IsSEQTAPage = false;
|
var IsSEQTAPage = false;
|
||||||
let hasSEQTAText = false;
|
let hasSEQTAText = false;
|
||||||
|
|
||||||
|
|||||||
@@ -614,6 +614,154 @@ ul.magicDelete > li.deleting {
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide native menu as soon as possible (during loading), before Svelte mounts.
|
||||||
|
Keep `#bsplus-sidebar-root` visible — themes target `#menu > ul > li.item` on it. */
|
||||||
|
html.bsplus-custom-sidebar-pending #menu > ul:not(#bsplus-sidebar-root),
|
||||||
|
html.bsplus-custom-sidebar-pending #menu > #userInfo,
|
||||||
|
html.bsplus-custom-sidebar-pending #menu > .icon-cover,
|
||||||
|
#menu.bsplus-custom-sidebar > ul:not(#bsplus-sidebar-root),
|
||||||
|
#menu.bsplus-custom-sidebar > #userInfo,
|
||||||
|
#menu.bsplus-custom-sidebar > .icon-cover {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -10000px !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Svelte sidebar: SEQTA-like `#menu > ul > li.item` / `.sub` for theme CSS. */
|
||||||
|
#menu.bsplus-custom-sidebar {
|
||||||
|
/* SEQTA uses absolute top/bottom:0 — do not switch to relative or height collapses. */
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
/* No padding-top: logo `ul::before` + theme `.sub { top: 71.5px }` match native. */
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
|
||||||
|
> #bsplus-sidebar-root {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme drill panels assume static hasChildren + absolute .sub filling #menu. */
|
||||||
|
li.hasChildren {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native SEQTA leaves `li.hasChildren.active` on the hidden list after click-through.
|
||||||
|
* Global `#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active)`
|
||||||
|
* then sets pointer-events:none on our custom root rows. Override that here; drill
|
||||||
|
* locking only follows the custom list's own active folder.
|
||||||
|
*/
|
||||||
|
> #bsplus-sidebar-root > li.item {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only lock siblings while a drill `.sub` is open — not when a folder is
|
||||||
|
merely route-highlighted with `.active` at the root list. */
|
||||||
|
&:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
|
||||||
|
> #bsplus-sidebar-root
|
||||||
|
> li:not(.hasChildren.active),
|
||||||
|
&:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
|
||||||
|
> #bsplus-sidebar-root
|
||||||
|
> li:not(.hasChildren.active)
|
||||||
|
* {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> #bsplus-sidebar-root .sub {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> #bsplus-sidebar-root li.hasChildren.active > .sub {
|
||||||
|
pointer-events: auto;
|
||||||
|
/* Let the #menu chrome (gradient / theme) show through — themes often paint
|
||||||
|
`.sub` with flat `--background-primary` which reads as a white slab. */
|
||||||
|
background: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
> #bsplus-sidebar-root li.hasChildren.active > .sub:has(.hasChildren.active) {
|
||||||
|
pointer-events: none !important;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> #bsplus-sidebar-root li.hasChildren.active .hasChildren.active > .sub {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
background: transparent !important;
|
||||||
|
background-image: none !important;
|
||||||
|
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep folder + leaf rows on the same rhythm (themes often only style
|
||||||
|
`:not(.hasChildren)`, which left folders centred / differently spaced). */
|
||||||
|
> #bsplus-sidebar-root li.item {
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
margin: 2px 6px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Folder label stays in flow so sidebar-animation translateX can run.
|
||||||
|
Visually covered by the absolute `.sub` panel. */
|
||||||
|
> #bsplus-sidebar-root > li.hasChildren.active > label {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bsplus-sidebar-root .sub .back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bsplus-sidebar-root .sub .back:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bsplus-sidebar-root .sub .backLabel {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bsplus-sidebar-root .sub > ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bsplus-submenu-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: translateX(28px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
#menu li > label > svg,
|
#menu li > label > svg,
|
||||||
#menu section > label > svg {
|
#menu section > label > svg {
|
||||||
margin: 0 10px 0 4px;
|
margin: 0 10px 0 4px;
|
||||||
|
|||||||
@@ -66,11 +66,16 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
|||||||
label.textContent = "Overview";
|
label.textContent = "Overview";
|
||||||
gridItem.appendChild(label);
|
gridItem.appendChild(label);
|
||||||
menu.insertBefore(gridItem, menu.firstChild);
|
menu.insertBefore(gridItem, menu.firstChild);
|
||||||
|
window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
|
||||||
|
|
||||||
let loadRequestId = 0;
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const menuObserver = new MutationObserver(() => {
|
const menuObserver = new MutationObserver(() => {
|
||||||
|
const before = menu.firstElementChild;
|
||||||
ensureOverviewMenuPosition(menu, gridItem);
|
ensureOverviewMenuPosition(menu, gridItem);
|
||||||
|
if (menu.firstElementChild !== before) {
|
||||||
|
window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
menuObserver.observe(menu, { childList: true });
|
menuObserver.observe(menu, { childList: true });
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import type { Plugin } from "@/plugins/core/types";
|
|
||||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
|
||||||
import {
|
|
||||||
ensureAnalyticsMenuOrder,
|
|
||||||
insertMenuItemAfterKey,
|
|
||||||
processMenuItemNode,
|
|
||||||
} from "@/seqta/utils/sidebarMenuIcons";
|
|
||||||
import {
|
|
||||||
ChangeMenuItemPositions,
|
|
||||||
MenuOptionsOpen,
|
|
||||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
|
||||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
|
||||||
import { loadAnalyticsPage } from "../loadAnalyticsPage";
|
|
||||||
import styles from "../styles.css?inline";
|
|
||||||
|
|
||||||
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
|
||||||
const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
|
||||||
|
|
||||||
const gradeAnalyticsPlugin: Plugin<{}> = {
|
|
||||||
id: "grade-analytics",
|
|
||||||
name: "Grade Analytics",
|
|
||||||
description:
|
|
||||||
"Adds an analytics page with grade trends, distribution charts, and assessment history",
|
|
||||||
version: "1.0.0",
|
|
||||||
settings: {},
|
|
||||||
disableToggle: true,
|
|
||||||
styles,
|
|
||||||
|
|
||||||
run: async () => {
|
|
||||||
if (isSeqtaEngageExperience()) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuList = (await waitForElm("#menu > ul, #menu ul", true, 100, 60)) as HTMLElement;
|
|
||||||
|
|
||||||
const analyticsItem = document.createElement("li");
|
|
||||||
analyticsItem.className = "item";
|
|
||||||
analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
|
|
||||||
analyticsItem.id = "analyticsbutton";
|
|
||||||
analyticsItem.dataset.key = "analytics";
|
|
||||||
analyticsItem.dataset.path = "/analytics";
|
|
||||||
analyticsItem.dataset.betterseqta = "true";
|
|
||||||
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
|
||||||
|
|
||||||
const syncAnalyticsMenu = () => {
|
|
||||||
insertMenuItemAfterKey(menuList, analyticsItem, "courses");
|
|
||||||
ensureAnalyticsMenuOrder();
|
|
||||||
if (settingsState.menuorder.length > 0) {
|
|
||||||
ChangeMenuItemPositions(settingsState.menuorder);
|
|
||||||
}
|
|
||||||
processMenuItemNode(analyticsItem);
|
|
||||||
applyMenuItemVisibility();
|
|
||||||
};
|
|
||||||
|
|
||||||
syncAnalyticsMenu();
|
|
||||||
|
|
||||||
const menuObserver = new MutationObserver(() => {
|
|
||||||
if (MenuOptionsOpen || menuList.contains(analyticsItem)) return;
|
|
||||||
syncAnalyticsMenu();
|
|
||||||
});
|
|
||||||
menuObserver.observe(menuList, { childList: true });
|
|
||||||
|
|
||||||
analyticsItem.addEventListener("click", (e) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (
|
|
||||||
MenuOptionsOpen ||
|
|
||||||
analyticsItem.classList.contains("draggable") ||
|
|
||||||
target.closest(".onoffswitch, .editmenuoption-container")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
window.history.pushState({}, "", "/#?page=/analytics");
|
|
||||||
void loadAnalyticsPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
menuObserver.disconnect();
|
|
||||||
analyticsItem.remove();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default gradeAnalyticsPlugin;
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import {
|
||||||
|
ensureAnalyticsMenuOrder,
|
||||||
|
insertMenuItemAfterKey,
|
||||||
|
} from "@/seqta/utils/Openers/analyticsMenuOrder";
|
||||||
|
import { processMenuItemNode } from "@/seqta/utils/sidebarMenuIcons";
|
||||||
|
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||||
|
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||||
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
||||||
|
|
||||||
|
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
|
||||||
|
export const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
|
||||||
|
export const ANALYTICS_MENU_KEY = "analytics";
|
||||||
|
|
||||||
|
function getNativeMenuList(): HTMLElement | null {
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
if (!menu) return null;
|
||||||
|
return menu.querySelector(
|
||||||
|
":scope > ul:not(#bsplus-sidebar-root)",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAnalyticsPageLazy() {
|
||||||
|
void import("./loadAnalyticsPage").then((m) => m.loadAnalyticsPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject the Analytics sidebar row immediately (eager).
|
||||||
|
* The analytics page chunk stays lazy until click / direct navigation.
|
||||||
|
*/
|
||||||
|
export async function injectAnalyticsMenuItem(): Promise<() => void> {
|
||||||
|
if (isSeqtaEngageExperience()) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll(
|
||||||
|
`#menu .${ANALYTICS_MENU_CLASS}, #menu [data-key="${ANALYTICS_MENU_KEY}"]`,
|
||||||
|
)
|
||||||
|
.forEach((node) => {
|
||||||
|
// Only touch the native list copy — custom sidebar mirrors via sync.
|
||||||
|
if (node.closest("#bsplus-sidebar-root")) return;
|
||||||
|
node.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForElm(
|
||||||
|
"#menu > ul:not(#bsplus-sidebar-root), #menu > ul",
|
||||||
|
true,
|
||||||
|
50,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
|
||||||
|
let menuList = getNativeMenuList();
|
||||||
|
if (!menuList) {
|
||||||
|
menuList = (await waitForElm(
|
||||||
|
"#menu > ul:not(#bsplus-sidebar-root), #menu ul",
|
||||||
|
true,
|
||||||
|
50,
|
||||||
|
120,
|
||||||
|
)) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsItem = document.createElement("li");
|
||||||
|
analyticsItem.className = "item";
|
||||||
|
analyticsItem.classList.add(ANALYTICS_MENU_CLASS);
|
||||||
|
analyticsItem.id = "analyticsbutton";
|
||||||
|
analyticsItem.dataset.key = ANALYTICS_MENU_KEY;
|
||||||
|
analyticsItem.dataset.path = "/analytics";
|
||||||
|
analyticsItem.dataset.betterseqta = "true";
|
||||||
|
analyticsItem.innerHTML = `<label>${ANALYTICS_MENU_ICON}<span>Analytics</span></label>`;
|
||||||
|
|
||||||
|
const syncAnalyticsMenu = () => {
|
||||||
|
const list = getNativeMenuList() ?? menuList;
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (list.querySelector(':scope > [data-key="courses"]')) {
|
||||||
|
insertMenuItemAfterKey(list, analyticsItem, "courses");
|
||||||
|
} else if (list.querySelector(':scope > [data-key="home"]')) {
|
||||||
|
insertMenuItemAfterKey(list, analyticsItem, "home");
|
||||||
|
} else if (!list.contains(analyticsItem)) {
|
||||||
|
list.insertBefore(analyticsItem, list.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAnalyticsMenuOrder();
|
||||||
|
if (settingsState.menuorder.length > 0) {
|
||||||
|
ChangeMenuItemPositions(settingsState.menuorder);
|
||||||
|
}
|
||||||
|
processMenuItemNode(analyticsItem);
|
||||||
|
applyMenuItemVisibility();
|
||||||
|
window.dispatchEvent(new CustomEvent("bsplus-native-menu-updated"));
|
||||||
|
};
|
||||||
|
|
||||||
|
syncAnalyticsMenu();
|
||||||
|
|
||||||
|
const menuObserver = new MutationObserver(() => {
|
||||||
|
const list = getNativeMenuList() ?? menuList;
|
||||||
|
if (!list || isMenuOptionsOpen() || list.contains(analyticsItem)) return;
|
||||||
|
syncAnalyticsMenu();
|
||||||
|
});
|
||||||
|
menuObserver.observe(menuList, { childList: true });
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
isMenuOptionsOpen() ||
|
||||||
|
analyticsItem.classList.contains("draggable") ||
|
||||||
|
target.closest(".onoffswitch, .editmenuoption-container")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
window.history.pushState({}, "", "/#?page=/analytics");
|
||||||
|
loadAnalyticsPageLazy();
|
||||||
|
};
|
||||||
|
analyticsItem.addEventListener("click", onClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
menuObserver.disconnect();
|
||||||
|
analyticsItem.removeEventListener("click", onClick);
|
||||||
|
analyticsItem.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineLazyPlugin } from "../../core/dynamicLoader";
|
import { defineLazyPlugin } from "../../core/dynamicLoader";
|
||||||
import { defineSettings, numberSetting } from "../../core/settingsHelpers";
|
import { defineSettings, numberSetting } from "../../core/settingsHelpers";
|
||||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||||
|
import { injectAnalyticsMenuItem } from "./injectAnalyticsMenuItem";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
@@ -13,6 +14,10 @@ const settings = defineSettings({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell loads immediately so the sidebar Analytics icon can mount.
|
||||||
|
* The page UI (`loadAnalyticsPage` / charts) stays in a separate lazy chunk.
|
||||||
|
*/
|
||||||
const gradeAnalyticsPluginLazy = defineLazyPlugin({
|
const gradeAnalyticsPluginLazy = defineLazyPlugin({
|
||||||
id: "grade-analytics",
|
id: "grade-analytics",
|
||||||
name: "Grade Analytics",
|
name: "Grade Analytics",
|
||||||
@@ -23,16 +28,21 @@ const gradeAnalyticsPluginLazy = defineLazyPlugin({
|
|||||||
disableToggle: true,
|
disableToggle: true,
|
||||||
defaultEnabled: true,
|
defaultEnabled: true,
|
||||||
styles,
|
styles,
|
||||||
|
// Kept for API compatibility; menu injection no longer waits on this chunk.
|
||||||
loader: () => import("./core/index"),
|
loader: () => import("./core/index"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const runGradeAnalytics = gradeAnalyticsPluginLazy.run!;
|
gradeAnalyticsPluginLazy.run = async () => {
|
||||||
|
|
||||||
gradeAnalyticsPluginLazy.run = async (api) => {
|
|
||||||
if (isSeqtaEngageExperience()) {
|
if (isSeqtaEngageExperience()) {
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
return runGradeAnalytics(api);
|
|
||||||
|
// Eager: sidebar icon / native menu row only.
|
||||||
|
const cleanupMenu = await injectAnalyticsMenuItem();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupMenu();
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default gradeAnalyticsPluginLazy;
|
export default gradeAnalyticsPluginLazy;
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ const PLUGIN_START_PHASES: readonly string[][] = [
|
|||||||
"messageFolders",
|
"messageFolders",
|
||||||
"profile-picture",
|
"profile-picture",
|
||||||
"background-music",
|
"background-music",
|
||||||
|
// Menu icon is eager; page UI still loads on demand via dynamic import.
|
||||||
|
"grade-analytics",
|
||||||
],
|
],
|
||||||
["global-search", "grade-analytics"],
|
["global-search"],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ function SortMessagePageItems(messagesParentElement: any) {
|
|||||||
|
|
||||||
async function LoadPageElements(): Promise<void> {
|
async function LoadPageElements(): Promise<void> {
|
||||||
await AddBetterSEQTAElements();
|
await AddBetterSEQTAElements();
|
||||||
|
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||||
|
void mod.mountCustomSidebar();
|
||||||
|
});
|
||||||
const sublink: string | undefined = getEngageRoutePage();
|
const sublink: string | undefined = getEngageRoutePage();
|
||||||
|
|
||||||
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
|
if (isSeqtaEngageExperience() && !engageHashListenerAttached) {
|
||||||
@@ -676,6 +679,13 @@ export function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector(".legacy-root")?.classList.add("hidden");
|
document.querySelector(".legacy-root")?.classList.add("hidden");
|
||||||
|
|
||||||
|
// Hide native sidebar + mount Svelte replacement during the loading overlay.
|
||||||
|
document.documentElement.classList.add("bsplus-custom-sidebar-pending");
|
||||||
|
void import("@/seqta/ui/sidebar/mountCustomSidebar").then((mod) => {
|
||||||
|
mod.prepareCustomSidebarEarly();
|
||||||
|
});
|
||||||
|
|
||||||
void observeMenuItemPosition();
|
void observeMenuItemPosition();
|
||||||
|
|
||||||
new StorageChangeHandler();
|
new StorageChangeHandler();
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export async function AddBetterSEQTAElements() {
|
|||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
await addDarkLightToggle();
|
await addDarkLightToggle();
|
||||||
customizeMenuToggle();
|
customizeMenuToggle();
|
||||||
|
// Kept as fallback if the custom Svelte sidebar fails to mount.
|
||||||
setupSidebarAccessibility();
|
setupSidebarAccessibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +673,20 @@ function updateSidebarAccessibility() {
|
|||||||
const menu = document.getElementById("menu");
|
const menu = document.getElementById("menu");
|
||||||
if (!menu) return;
|
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 visibleList = getVisibleSidebarList(menu);
|
||||||
const visibleEntries = new Set(
|
const visibleEntries = new Set(
|
||||||
visibleList ? getDirectSidebarEntries(visibleList) : [],
|
visibleList ? getDirectSidebarEntries(visibleList) : [],
|
||||||
@@ -717,7 +732,9 @@ function getDirectSidebarEntries(list: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleSidebarList(menu: 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) {
|
while (currentList) {
|
||||||
const activeSubmenuParent = currentList.querySelector(
|
const activeSubmenuParent = currentList.querySelector(
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
prepareCustomSidebarEarly,
|
||||||
|
mountCustomSidebar,
|
||||||
|
unmountCustomSidebar,
|
||||||
|
openCustomSidebarEditor,
|
||||||
|
closeCustomSidebarEditor,
|
||||||
|
isCustomSidebarMounted,
|
||||||
|
} from "./mountCustomSidebar";
|
||||||
|
export { sidebarState } from "./sidebarState.svelte";
|
||||||
|
export type { SidebarItem, SidebarDrillFrame } from "./types";
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import type { SettingsState } from "@/types/storage";
|
import type { SettingsState } from "@/types/storage";
|
||||||
import { settingsState } from "../listeners/SettingsState";
|
import { settingsState } from "../listeners/SettingsState";
|
||||||
import { applyMenuItemVisibility } from "../menuItemVisibility";
|
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 stringToHTML from "../stringToHTML";
|
||||||
import Sortable from "sortablejs";
|
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 {
|
function escapeHtmlAttr(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -190,14 +196,22 @@ function restoreMenuItemsFromEditMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OpenMenuOptions() {
|
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 container = document.getElementById("container");
|
||||||
const menu = document.getElementById("menu");
|
const menu = document.getElementById("menu");
|
||||||
if (!container || !menu) return;
|
if (!container || !menu) return;
|
||||||
|
|
||||||
syncDefaultMenuOrder(menu);
|
syncDefaultMenuOrder(menu);
|
||||||
MenuOptionsOpen = true;
|
setMenuOptionsOpen(true);
|
||||||
menu.classList.add("bsplus-sidebar-edit-mode");
|
menu.classList.add("bsplus-sidebar-edit-mode");
|
||||||
|
|
||||||
const { cover, menusettings, defaultbutton, savebutton } =
|
const { cover, menusettings, defaultbutton, savebutton } =
|
||||||
@@ -220,7 +234,7 @@ export function OpenMenuOptions() {
|
|||||||
const closeAll = () => {
|
const closeAll = () => {
|
||||||
menusettings.remove();
|
menusettings.remove();
|
||||||
cover.remove();
|
cover.remove();
|
||||||
MenuOptionsOpen = false;
|
setMenuOptionsOpen(false);
|
||||||
menu.classList.remove("bsplus-sidebar-edit-mode");
|
menu.classList.remove("bsplus-sidebar-edit-mode");
|
||||||
menu.style.setProperty("--menuHidden", "none");
|
menu.style.setProperty("--menuHidden", "none");
|
||||||
restoreMenuItemsFromEditMode(menu, listItems);
|
restoreMenuItemsFromEditMode(menu, listItems);
|
||||||
@@ -267,34 +281,3 @@ function cloneAttributes(target: any, source: any) {
|
|||||||
target.setAttribute(attr.nodeName, attr.nodeValue);
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
|
||||||
import {
|
import { OpenMenuOptions } from "@/seqta/utils/Openers/OpenMenuOptions";
|
||||||
MenuOptionsOpen,
|
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||||
OpenMenuOptions,
|
|
||||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
|
||||||
|
|
||||||
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
|
||||||
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
@@ -144,7 +142,7 @@ export class MessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editSidebar() {
|
editSidebar() {
|
||||||
if (!MenuOptionsOpen) {
|
if (!isMenuOptionsOpen()) {
|
||||||
OpenMenuOptions();
|
OpenMenuOptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { renderShortcuts } from "@/seqta/utils/Render/renderShortcuts";
|
|||||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||||
import { registerHomeUpcomingSettingsListeners } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { registerHomeUpcomingSettingsListeners } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
|
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 browser from "webextension-polyfill";
|
||||||
import type { CustomShortcut } from "@/types/storage";
|
import type { CustomShortcut } from "@/types/storage";
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export function applyMenuItemVisibility(): void {
|
|||||||
let css = "";
|
let css = "";
|
||||||
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
|
for (const [menuItem, config] of Object.entries(settingsState.menuitems ?? {})) {
|
||||||
if (config && !config.toggle) {
|
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;}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
import MenuitemSVGKey from "@/seqta/content/MenuItemSVGKey.json";
|
||||||
import {
|
import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
|
||||||
ChangeMenuItemPositions,
|
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
|
||||||
MenuOptionsOpen,
|
|
||||||
} from "@/seqta/utils/Openers/OpenMenuOptions";
|
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
|
import { waitForEngageMenuList } from "@/seqta/utils/waitForEngageMenuList";
|
||||||
@@ -12,48 +10,6 @@ import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
|||||||
|
|
||||||
const BETTERSEQTA_ICON_ATTR = "data-betterseqta-icon";
|
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 {
|
function getMenuLabel(element: HTMLElement): HTMLElement | null {
|
||||||
const label = element.querySelector(":scope > label");
|
const label = element.querySelector(":scope > label");
|
||||||
return label instanceof HTMLElement ? label : null;
|
return label instanceof HTMLElement ? label : null;
|
||||||
@@ -107,7 +63,7 @@ export function replaceMenuSVG(element: HTMLElement, svg: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function processMenuItemNode(node: HTMLElement) {
|
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;
|
const key = node.dataset.key as keyof typeof MenuitemSVGKey | undefined;
|
||||||
if (key && MenuitemSVGKey[key]) {
|
if (key && MenuitemSVGKey[key]) {
|
||||||
@@ -119,7 +75,7 @@ export function processMenuItemNode(node: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
|
function processTopLevelMenuItems(reorder = !isSeqtaEngageExperience()) {
|
||||||
if (MenuOptionsOpen) return;
|
if (isMenuOptionsOpen()) return;
|
||||||
|
|
||||||
const topList = getTopLevelMenuList();
|
const topList = getTopLevelMenuList();
|
||||||
if (!topList) return;
|
if (!topList) return;
|
||||||
@@ -186,7 +142,7 @@ export async function observeMenuItemPosition() {
|
|||||||
if (!isTopLevelSidebarItem(node)) return;
|
if (!isTopLevelSidebarItem(node)) return;
|
||||||
if ((element as any)[processedSymbol]) return;
|
if ((element as any)[processedSymbol]) return;
|
||||||
|
|
||||||
if (!MenuOptionsOpen) {
|
if (!isMenuOptionsOpen()) {
|
||||||
processMenuItemNode(node);
|
processMenuItemNode(node);
|
||||||
ChangeMenuItemPositions(settingsState.menuorder);
|
ChangeMenuItemPositions(settingsState.menuorder);
|
||||||
(element as any)[processedSymbol] = true;
|
(element as any)[processedSymbol] = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user