feat: sidebar cleanup + fixes

This commit is contained in:
2026-07-21 09:20:53 +09:30
parent c439d8d88f
commit e732011f1e
11 changed files with 218 additions and 230 deletions
+30 -27
View File
@@ -635,16 +635,14 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
clip: rect(0, 0, 0, 0) !important; clip: rect(0, 0, 0, 0) !important;
} }
/* Custom Svelte sidebar: SEQTA-like `#menu > ul > li.item` / `.sub` for theme CSS. */ /* Custom Svelte sidebar: keep `#menu > ul > li.item` / `.sub` shape for theme CSS. */
#menu.bsplus-custom-sidebar { #menu.bsplus-custom-sidebar {
/* SEQTA uses absolute top/bottom:0 — do not switch to relative or height collapses. */
position: absolute !important; position: absolute !important;
top: 0 !important; top: 0 !important;
bottom: 0 !important; bottom: 0 !important;
left: 0 !important; left: 0 !important;
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
/* No padding-top: logo `ul::before` + theme `.sub { top: 71.5px }` match native. */
box-sizing: border-box !important; box-sizing: border-box !important;
height: auto !important; height: auto !important;
max-height: none !important; max-height: none !important;
@@ -659,23 +657,20 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
position: relative; position: relative;
} }
/* Theme drill panels assume static hasChildren + absolute .sub filling #menu. */
li.hasChildren { li.hasChildren {
position: static !important; position: static !important;
} }
/* /* Native list may keep `.active` after click-through; force our rows clickable,
* Native SEQTA leaves `li.hasChildren.active` on the hidden list after click-through. then lock siblings only while a custom drill `.sub` is open. */
* 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 { > #bsplus-sidebar-root > li.item {
pointer-events: auto !important; pointer-events: auto !important;
width: auto !important;
max-width: none !important;
margin: 2px 6px !important;
box-sizing: border-box !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) &:has(> #bsplus-sidebar-root > li.hasChildren.active > .sub)
> #bsplus-sidebar-root > #bsplus-sidebar-root
> li:not(.hasChildren.active), > li:not(.hasChildren.active),
@@ -692,40 +687,42 @@ html.bsplus-custom-sidebar-pending #menu > .icon-cover,
> #bsplus-sidebar-root li.hasChildren.active > .sub { > #bsplus-sidebar-root li.hasChildren.active > .sub {
pointer-events: auto; 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: transparent !important;
background-image: none !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) { > #bsplus-sidebar-root li.hasChildren.active > .sub:has(.hasChildren.active) {
pointer-events: none !important; pointer-events: none !important;
animation: none;
} }
> #bsplus-sidebar-root li.hasChildren.active .hasChildren.active > .sub { > #bsplus-sidebar-root li.hasChildren.active .hasChildren.active > .sub {
pointer-events: auto !important; pointer-events: auto !important;
background: transparent !important; background: transparent !important;
background-image: none !important; background-image: none !important;
}
/* One-shot enter — not tied to `.active` (SEQTA strips/restores that on every
nested click, which would restart the animation). */
> #bsplus-sidebar-root .sub.bsplus-sub-enter {
animation: bsplus-submenu-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1); 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 { > #bsplus-sidebar-root > li.hasChildren.active > label {
pointer-events: none !important; pointer-events: none !important;
} }
/* Theme decorations on root leaves (Beach palm/sand) must not show through
the transparent drill `.sub` panel. */
> #bsplus-sidebar-root:has(> li.hasChildren.active > .sub)
> li:not(.hasChildren.active)::before,
> #bsplus-sidebar-root:has(> li.hasChildren.active > .sub)
> li:not(.hasChildren.active)::after {
content: none !important;
display: none !important;
animation: none !important;
opacity: 0 !important;
}
#bsplus-sidebar-root .sub .back { #bsplus-sidebar-root .sub .back {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -849,6 +846,12 @@ body.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) {
display: none !important; display: none !important;
} }
/* Compact BetterSEQTA mark instead of the full wordmark. */
&.student #menu > ul::before {
background-size: 36px 36px !important;
background-position: center center !important;
}
} }
[class*="notifications__items___"] { [class*="notifications__items___"] {
-ms-overflow-style: none !important; -ms-overflow-style: none !important;
+5 -1
View File
@@ -8,12 +8,16 @@
} }
#menu > ul:has(li.hasChildren.active) > li::before, #menu > ul:has(li.hasChildren.active) > li::before,
#menu > ul:has(li.hasChildren.active) > li::after,
#menu > ul ul:has(li.hasChildren.active) > li::before, #menu > ul ul:has(li.hasChildren.active) > li::before,
#menu > ul ul:has(li.hasChildren.active) > li::after,
#menu > ul:has(li.hasChildren.active) > li > label, #menu > ul:has(li.hasChildren.active) > li > label,
#menu > ul:has(li.hasChildren.active) > li > svg, #menu > ul:has(li.hasChildren.active) > li > svg,
#menu > ul ul:has(li.hasChildren.active) > li > label, #menu > ul ul:has(li.hasChildren.active) > li > label,
#menu > ul ul:has(li.hasChildren.active) > li > svg { #menu > ul ul:has(li.hasChildren.active) > li > svg {
transform: translateX(-320px); /* !important beats theme animations (e.g. Beach palm-slide forwards). */
transform: translateX(-320px) !important;
animation: none !important;
} }
#menu li.hasChildren:not(.active) li .sub { #menu li.hasChildren:not(.active) li .sub {
@@ -10,19 +10,12 @@ import { ChangeMenuItemPositions } from "@/seqta/utils/Openers/menuOrder";
import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState"; import { isMenuOptionsOpen } from "@/seqta/utils/Openers/menuOptionsState";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility"; import { applyMenuItemVisibility } from "@/seqta/utils/menuItemVisibility";
import { getNativeMenuList } from "@/seqta/ui/sidebar/parseNativeMenu";
const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics; const ANALYTICS_MENU_ICON = MenuitemSVGKey.analytics;
export const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item"; export const ANALYTICS_MENU_CLASS = "betterseqta-grade-analytics-item";
export const ANALYTICS_MENU_KEY = "analytics"; 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() { function loadAnalyticsPageLazy() {
void import("./loadAnalyticsPage").then((m) => m.loadAnalyticsPage()); void import("./loadAnalyticsPage").then((m) => m.loadAnalyticsPage());
} }
+9 -2
View File
@@ -10,6 +10,8 @@ import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/cus
import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl"; import { resolveExtensionAssetUrl } from "@/lib/extensionAssetUrl";
import darkLogo from "@/resources/icons/betterseqta-light-full.png"; import darkLogo from "@/resources/icons/betterseqta-light-full.png";
import lightLogo from "@/resources/icons/betterseqta-dark-full.png"; import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
import darkIcon from "@/resources/icons/betterseqta-light-icon.png";
import lightIcon from "@/resources/icons/betterseqta-dark-icon.png";
const ADAPTIVE_THEME_TRANSITION_MS = 400; const ADAPTIVE_THEME_TRANSITION_MS = 400;
const LOGO_STYLE_ID = "bsplus-logo-style"; const LOGO_STYLE_ID = "bsplus-logo-style";
@@ -88,7 +90,8 @@ function cancelColorTransition() {
/** Chromium does not always resolve extension URLs inside CSS variables on ::before. */ /** Chromium does not always resolve extension URLs inside CSS variables on ::before. */
function applyBetterseqtaLogoBackground(isDark: boolean) { function applyBetterseqtaLogoBackground(isDark: boolean) {
const url = resolveExtensionAssetUrl(isDark ? darkLogo : lightLogo); const fullUrl = resolveExtensionAssetUrl(isDark ? darkLogo : lightLogo);
const iconUrl = resolveExtensionAssetUrl(isDark ? darkIcon : lightIcon);
let styleEl = document.getElementById(LOGO_STYLE_ID); let styleEl = document.getElementById(LOGO_STYLE_ID);
if (!styleEl) { if (!styleEl) {
styleEl = document.createElement("style"); styleEl = document.createElement("style");
@@ -98,7 +101,11 @@ function applyBetterseqtaLogoBackground(isDark: boolean) {
styleEl.textContent = ` styleEl.textContent = `
body.student #menu > ul::before, body.student #menu > ul::before,
#title::before { #title::before {
background-image: url("${url}") !important; background-image: url("${fullUrl}") !important;
}
/* Icon-only sidebar: wordmark → compact mark (theme-aware). */
body.student.icon-only-sidebar:not(:has(#menu li.hasChildren.active)) #menu > ul::before {
background-image: url("${iconUrl}") !important;
} }
`; `;
} }
+29 -60
View File
@@ -34,33 +34,17 @@
function closeEdit() { function closeEdit() {
sidebarState.setEditMode(false); sidebarState.setEditMode(false);
menuEl.classList.remove("bsplus-sidebar-edit-mode");
void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => { void import("@/seqta/utils/Openers/menuOptionsState").then((mod) => {
mod.setMenuOptionsOpen(false); mod.setMenuOptionsOpen(false);
}); });
} }
function findFolder( const drillFolders = $derived(
items: SidebarItemModel[], sidebarState.drillStack.map((frame) => ({
key: string, frame,
): SidebarItemModel | null { folder: sidebarState.findByKey(frame.key),
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) { function onToggleVisible(key: string, visible: boolean) {
sidebarState.setItemVisibility(key, visible); sidebarState.setItemVisibility(key, visible);
@@ -105,26 +89,13 @@
}; };
}); });
// SEQTA strips `.active` from `#menu li` after clicks — keep it on our custom // SEQTA strips `.active` from `#menu li` after clicks — re-apply from state.
// rows so theme animations (palm/sand) and drill `.sub` chrome stay applied. // Do NOT MutationObserver class changes here: restore writes `.active`, SEQTA
// strips it again, and the feedback loop freezes the tab.
$effect(() => { $effect(() => {
// Re-run when the route-active key changes so we re-bind the observer target.
void sidebarState.activeKey; void sidebarState.activeKey;
void sidebarState.isDrilling; void sidebarState.isDrilling;
const root = document.getElementById("bsplus-sidebar-root");
if (!root) return;
restoreCustomMenuActive(); restoreCustomMenuActive();
const observer = new MutationObserver(() => restoreCustomMenuActive());
observer.observe(root, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}); });
</script> </script>
@@ -151,9 +122,8 @@
{item} {item}
active={sidebarState.activeKey === item.key} active={sidebarState.activeKey === item.key}
compact={sidebarState.compact} compact={sidebarState.compact}
editMode={sidebarState.editMode} editMode={true}
visible={itemVisible(item.key)} visible={itemVisible(item.key)}
drillEnter={false}
{onActivate} {onActivate}
{onToggleVisible} {onToggleVisible}
{onDragStart} {onDragStart}
@@ -173,21 +143,19 @@
</button> </button>
</li> </li>
{:else if drillFolders.length} {:else if drillFolders.length}
<!-- <!-- Keep root rows in the DOM so sidebar-animation.scss can slide siblings.
Keep all root rows in the DOM (like native SEQTA) so sidebar-animation.scss Never mark root leaves active while drilling — theme decorations
can slide sibling labels away when a folder opens. (Beach palm/sand) would show through the transparent .sub panel. -->
-->
{#each sidebarState.visibleRootItems as item (item.key)} {#each sidebarState.visibleRootItems as item (item.key)}
{#if item.key === drillFolders[0].frame.key} {#if item.key === drillFolders[0].frame.key}
{@render drillLevel(drillFolders, 0)} {@render drillLevel(drillFolders, 0)}
{:else} {:else}
<SidebarItem <SidebarItem
{item} {item}
active={sidebarState.activeKey === item.key} active={false}
compact={false} compact={false}
editMode={false} editMode={false}
visible={itemVisible(item.key)} visible={true}
drillEnter={false}
{onActivate} {onActivate}
/> />
{/if} {/if}
@@ -198,13 +166,9 @@
{item} {item}
active={sidebarState.activeKey === item.key} active={sidebarState.activeKey === item.key}
compact={sidebarState.compact} compact={sidebarState.compact}
editMode={sidebarState.editMode} editMode={false}
visible={itemVisible(item.key)} visible={true}
drillEnter={false}
{onActivate} {onActivate}
{onToggleVisible}
{onDragStart}
{onDrop}
/> />
{/each} {/each}
{/if} {/if}
@@ -232,7 +196,15 @@
{/if} {/if}
<span class="label">{current.frame.label}</span> <span class="label">{current.frame.label}</span>
</label> </label>
<div class="sub"> <div
class="sub"
class:bsplus-sub-enter={sidebarState.enterFrameKey === current.frame.key}
onanimationend={(e) => {
if (e.target === e.currentTarget) {
sidebarState.clearEnterFrame(current.frame.key);
}
}}
>
<div class="nav"> <div class="nav">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@@ -260,13 +232,12 @@
active={sidebarState.activeKey === item.key} active={sidebarState.activeKey === item.key}
compact={false} compact={false}
editMode={false} editMode={false}
visible={itemVisible(item.key)} visible={true}
drillEnter={true} drillEnter={sidebarState.enterFrameKey === current.frame.key}
{onActivate} {onActivate}
/> />
{/each} {/each}
{:else} {:else}
<!-- Keep nested siblings for the same slide-away animation. -->
{#each current.frame.items as item (item.key)} {#each current.frame.items as item (item.key)}
{#if item.key === nextKey} {#if item.key === nextKey}
{@render drillLevel(frames, depth + 1)} {@render drillLevel(frames, depth + 1)}
@@ -276,8 +247,7 @@
active={sidebarState.activeKey === item.key} active={sidebarState.activeKey === item.key}
compact={false} compact={false}
editMode={false} editMode={false}
visible={itemVisible(item.key)} visible={true}
drillEnter={false}
{onActivate} {onActivate}
/> />
{/if} {/if}
@@ -302,7 +272,6 @@
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
color: var(--text-color, #fff); color: var(--text-color, #fff);
font-family: Rubik, sans-serif;
scrollbar-width: thin; scrollbar-width: thin;
z-index: 2; z-index: 2;
} }
+3 -12
View File
@@ -8,6 +8,7 @@
compact: boolean; compact: boolean;
editMode: boolean; editMode: boolean;
visible: boolean; visible: boolean;
/** Play intro fly only when the parent folder first opens. */
drillEnter?: boolean; drillEnter?: boolean;
onActivate: (item: SidebarItem) => void; onActivate: (item: SidebarItem) => void;
onToggleVisible?: (key: string, visible: boolean) => void; onToggleVisible?: (key: string, visible: boolean) => void;
@@ -51,7 +52,7 @@
aria-label={item.label} aria-label={item.label}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
draggable={editMode} draggable={editMode}
in:fly={{ x: drillEnter ? 24 : 0, duration: 180 }} in:fly={{ x: drillEnter ? 24 : 0, duration: drillEnter ? 180 : 0 }}
ondragstart={() => onDragStart?.(item.key)} ondragstart={() => onDragStart?.(item.key)}
ondragover={(e) => e.preventDefault()} ondragover={(e) => e.preventDefault()}
ondrop={() => onDrop?.(item.key)} ondrop={() => onDrop?.(item.key)}
@@ -97,20 +98,15 @@
</li> </li>
<style> <style>
/* Defaults only — theme #menu rules (often !important) win for look. /* Layout defaults — theme #menu rules (often !important) win for look. */
Keep folder + leaf rows left-aligned (no 85% centred width). */
.bsplus-sidebar-item { .bsplus-sidebar-item {
position: relative; position: relative;
list-style: none; list-style: none;
width: auto; width: auto;
margin: 2px 6px; margin: 2px 6px;
/* Match leaf vertical spacing so folders don't sit lower than neighbours. */
padding: 0; padding: 0;
border-radius: 12px; border-radius: 12px;
color: var(--text-color, #fff); color: var(--text-color, #fff);
font-family: Rubik, sans-serif;
font-size: 16px;
font-weight: 700;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -131,7 +127,6 @@
padding: 12px; padding: 12px;
cursor: inherit; cursor: inherit;
color: inherit; color: inherit;
font: inherit;
} }
.bsplus-sidebar-item:hover { .bsplus-sidebar-item:hover {
@@ -149,7 +144,6 @@
} }
.bsplus-sidebar-item.compact { .bsplus-sidebar-item.compact {
width: auto;
justify-content: center; justify-content: center;
} }
@@ -168,9 +162,6 @@
.bsplus-sidebar-item :global(label > svg) { .bsplus-sidebar-item :global(label > svg) {
flex-shrink: 0; flex-shrink: 0;
margin: 0 10px 0 4px;
width: 28px !important;
height: 28px !important;
} }
.compact :global(label > svg) { .compact :global(label > svg) {
-2
View File
@@ -3,8 +3,6 @@ export {
mountCustomSidebar, mountCustomSidebar,
unmountCustomSidebar, unmountCustomSidebar,
openCustomSidebarEditor, openCustomSidebarEditor,
closeCustomSidebarEditor,
isCustomSidebarMounted,
} from "./mountCustomSidebar"; } from "./mountCustomSidebar";
export { sidebarState } from "./sidebarState.svelte"; export { sidebarState } from "./sidebarState.svelte";
export type { SidebarItem, SidebarDrillFrame } from "./types"; export type { SidebarItem, SidebarDrillFrame } from "./types";
+33 -55
View File
@@ -25,24 +25,24 @@ let earlyPrepareStarted = false;
let catchupTimer: ReturnType<typeof setInterval> | null = null; let catchupTimer: ReturnType<typeof setInterval> | null = null;
let nativeMenuListenerAttached = false; let nativeMenuListenerAttached = false;
function onNativeMenuUpdated() {
if (menuEl) sidebarState.syncFromNative(menuEl);
}
const settingsListeners: Array<{ const settingsListeners: Array<{
key: keyof SettingsState; key: keyof SettingsState;
listener: ChangeListener; listener: ChangeListener;
}> = []; }> = [];
function syncFromMenu() {
if (menuEl) sidebarState.syncFromNative(menuEl);
}
function startCatchupSync() { function startCatchupSync() {
if (catchupTimer) clearInterval(catchupTimer); if (catchupTimer) clearInterval(catchupTimer);
let attempts = 0; let attempts = 0;
catchupTimer = setInterval(() => { catchupTimer = setInterval(() => {
attempts += 1; attempts += 1;
if (menuEl) sidebarState.syncFromNative(menuEl); syncFromMenu();
// Plugins (Analytics, Overview, icons) inject shortly after first paint. // Plugins (Analytics, Overview, icons) inject shortly after first paint.
if (attempts >= 60) { if (attempts >= 60) {
if (catchupTimer) clearInterval(catchupTimer); clearInterval(catchupTimer!);
catchupTimer = null; catchupTimer = null;
} }
}, 50); }, 50);
@@ -52,7 +52,7 @@ function scheduleSync() {
if (syncTimer) clearTimeout(syncTimer); if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(() => { syncTimer = setTimeout(() => {
syncTimer = null; syncTimer = null;
if (menuEl) sidebarState.syncFromNative(menuEl); syncFromMenu();
}, 50); }, 50);
} }
@@ -80,22 +80,20 @@ function onHashChange() {
function ensureDefaultMenuOrder(menu: HTMLElement) { function ensureDefaultMenuOrder(menu: HTMLElement) {
const list = getNativeMenuList(menu); const list = getNativeMenuList(menu);
if (!list) return; if (!list) return;
const keys = [...list.children] const keys = [...list.children]
.map((node) => (node as HTMLElement).dataset.key) .map((node) => (node as HTMLElement).dataset.key)
.filter((key): key is string => !!key); .filter((key): key is string => !!key);
if (!settingsState.defaultmenuorder?.length) { const current = settingsState.defaultmenuorder ?? [];
if (!current.length) {
settingsState.defaultmenuorder = keys; settingsState.defaultmenuorder = keys;
return; return;
} }
for (const key of keys) { const missing = keys.filter((key) => !current.includes(key));
if (!settingsState.defaultmenuorder.includes(key)) { if (missing.length) {
settingsState.defaultmenuorder = [ settingsState.defaultmenuorder = [...current, ...missing];
...settingsState.defaultmenuorder,
key,
];
}
} }
} }
@@ -139,18 +137,6 @@ export function openCustomSidebarEditor() {
return true; 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> { export async function mountCustomSidebar(): Promise<boolean> {
if (isSeqtaEngageExperience()) return false; if (isSeqtaEngageExperience()) return false;
if (!settingsState.onoff) return false; if (!settingsState.onoff) return false;
@@ -159,7 +145,7 @@ export async function mountCustomSidebar(): Promise<boolean> {
if (app && menuEl) { if (app && menuEl) {
ensureDefaultMenuOrder(menuEl); ensureDefaultMenuOrder(menuEl);
sidebarState.syncSettings(); sidebarState.syncSettings();
sidebarState.syncFromNative(menuEl); syncFromMenu();
startCatchupSync(); startCatchupSync();
clearPendingClass(); clearPendingClass();
return true; return true;
@@ -178,25 +164,19 @@ export async function mountCustomSidebar(): Promise<boolean> {
} }
if (!list) return false; if (!list) return false;
if (app) {
menuEl = menu; menuEl = menu;
ensureDefaultMenuOrder(menu); ensureDefaultMenuOrder(menu);
sidebarState.syncFromNative(menu); sidebarState.syncSettings();
syncFromMenu();
if (app) {
clearPendingClass(); clearPendingClass();
return true; return true;
} }
menuEl = menu;
menu.classList.add(MENU_CLASS); menu.classList.add(MENU_CLASS);
// Remove a stale root from a previous HMR / partial mount.
document.getElementById(ROOT_ID)?.remove(); 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, { app = mount(Sidebar, {
target: menu, target: menu,
props: { menuEl: menu }, props: { menuEl: menu },
@@ -209,14 +189,21 @@ export async function mountCustomSidebar(): Promise<boolean> {
menuObserver?.disconnect(); menuObserver?.disconnect();
menuObserver = new MutationObserver((mutations) => { menuObserver = new MutationObserver((mutations) => {
const ours = document.getElementById(ROOT_ID); const ours = document.getElementById(ROOT_ID);
if (ours && mutations.every((m) => ours.contains(m.target))) return; // Ignore our list entirely. Also ignore native `class` toggles — SEQTA
// re-adds drill `.active` after we clear it; syncing on that freezes the tab.
const relevant = mutations.some((m) => {
if (ours?.contains(m.target as Node)) return false;
if (m.type === "attributes" && m.attributeName === "class") return false;
return true;
});
if (!relevant) return;
scheduleSync(); scheduleSync();
}); });
menuObserver.observe(menu, { menuObserver.observe(menu, {
subtree: true, subtree: true,
childList: true, childList: true,
attributes: true, attributes: true,
attributeFilter: ["class", "style", "data-key", "data-path", "data-colour"], attributeFilter: ["style", "data-key", "data-path", "data-colour"],
}); });
if (!hashListenerAttached) { if (!hashListenerAttached) {
@@ -225,20 +212,15 @@ export async function mountCustomSidebar(): Promise<boolean> {
} }
if (!nativeMenuListenerAttached) { if (!nativeMenuListenerAttached) {
window.addEventListener("bsplus-native-menu-updated", onNativeMenuUpdated); window.addEventListener("bsplus-native-menu-updated", syncFromMenu);
nativeMenuListenerAttached = true; nativeMenuListenerAttached = true;
} }
clearSettingListeners(); clearSettingListeners();
registerSetting("iconOnlySidebar", () => { registerSetting("iconOnlySidebar", () => sidebarState.syncSettings());
sidebarState.syncSettings(); const resync = () => syncFromMenu();
}); registerSetting("menuorder", resync);
registerSetting("menuorder", () => { registerSetting("menuitems", resync);
if (menuEl) sidebarState.syncFromNative(menuEl);
});
registerSetting("menuitems", () => {
if (menuEl) sidebarState.syncFromNative(menuEl);
});
startCatchupSync(); startCatchupSync();
clearPendingClass(); clearPendingClass();
@@ -261,10 +243,7 @@ export function unmountCustomSidebar() {
} }
if (nativeMenuListenerAttached) { if (nativeMenuListenerAttached) {
window.removeEventListener( window.removeEventListener("bsplus-native-menu-updated", syncFromMenu);
"bsplus-native-menu-updated",
onNativeMenuUpdated,
);
nativeMenuListenerAttached = false; nativeMenuListenerAttached = false;
} }
@@ -278,7 +257,6 @@ export function unmountCustomSidebar() {
menuEl = null; menuEl = null;
sidebarState.resetDrill(); sidebarState.resetDrill();
sidebarState.setEditMode(false); sidebarState.setEditMode(false);
sidebarState.ready = false;
earlyPrepareStarted = false; earlyPrepareStarted = false;
clearPendingClass(); clearPendingClass();
} }
+6 -6
View File
@@ -1,12 +1,13 @@
import type { SidebarItem } from "./types"; import type { SidebarItem } from "./types";
/** Native SEQTA list only — never the custom Svelte `#bsplus-sidebar-root`. */ /** Native SEQTA list only — never the custom Svelte `#bsplus-sidebar-root`. */
export function getNativeMenuList(menu: HTMLElement): HTMLElement | null { export function getNativeMenuList(
return ( menu: HTMLElement | null = document.getElementById("menu"),
(menu.querySelector( ): HTMLElement | null {
if (!menu) return null;
return menu.querySelector(
":scope > ul:not(#bsplus-sidebar-root)", ":scope > ul:not(#bsplus-sidebar-root)",
) as HTMLElement | null) ?? null ) as HTMLElement | null;
);
} }
function readLabelText(label: HTMLElement | null): string { function readLabelText(label: HTMLElement | null): string {
@@ -60,7 +61,6 @@ function parseEntry(entry: HTMLElement): SidebarItem | null {
label: labelText, label: labelText,
iconHtml: readIconHtml(label), iconHtml: readIconHtml(label),
hasChildren: entry.classList.contains("hasChildren") || children.length > 0, hasChildren: entry.classList.contains("hasChildren") || children.length > 0,
colour: entry.getAttribute("data-colour"),
itemColour: entry.style.getPropertyValue("--item-colour") || null, itemColour: entry.style.getPropertyValue("--item-colour") || null,
betterseqta: entry.dataset.betterseqta === "true", betterseqta: entry.dataset.betterseqta === "true",
children, children,
+97 -51
View File
@@ -37,50 +37,61 @@ function filterVisible(items: SidebarItem[]): SidebarItem[] {
return items.filter((item) => menuItems[item.key]?.toggle !== false); return items.filter((item) => menuItems[item.key]?.toggle !== false);
} }
function ensureActive(el: Element | null | undefined) {
if (el instanceof HTMLElement && !el.classList.contains("active")) {
el.classList.add("active");
}
}
/** /**
* SEQTA (and some themes) strip `.active` from `#menu li` after navigation. * SEQTA (and some themes) strip `.active` from `#menu li` after navigation.
* Theme decorations (e.g. beach palm/sand) and drill `.sub` chrome all depend * Theme decorations and drill `.sub` chrome depend on that class on our list.
* on that class staying on our custom list — re-apply it from known state. *
* While drilling, never re-apply route-active on root leaves — themes like Beach
* paint palm/sand on `#menu > ul > li:not(.hasChildren).active`, and our `.sub`
* is transparent so those decorations show through over folder contents.
*/ */
export function restoreCustomMenuActive() { export function restoreCustomMenuActive() {
const root = document.getElementById("bsplus-sidebar-root"); const root = document.getElementById("bsplus-sidebar-root");
if (!root) return; if (!root) return;
// Open drill folders (must keep `.active` for `.sub` layout + theme selectors).
for (const li of root.querySelectorAll("li.hasChildren")) { for (const li of root.querySelectorAll("li.hasChildren")) {
if (!(li instanceof HTMLElement)) continue; if (!(li instanceof HTMLElement)) continue;
if (!li.querySelector(":scope > .sub")) continue; if (!li.querySelector(":scope > .sub")) continue;
if (!li.classList.contains("active")) li.classList.add("active"); ensureActive(li);
} }
// Route-active row — Svelte sets aria-current, but SEQTA often removes `.active`.
const activeKey = sidebarState.activeKey; const activeKey = sidebarState.activeKey;
const drilling = sidebarState.isDrilling;
if (drilling) {
if (activeKey) { if (activeKey) {
const activeLi = root.querySelector( ensureActive(
`li.item[data-key="${CSS.escape(activeKey)}"]`, root.querySelector(`.sub 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(
'.sub li.item[aria-current="page"]',
)) {
ensureActive(li);
}
return;
}
if (activeKey) {
ensureActive(
root.querySelector(`li.item[data-key="${CSS.escape(activeKey)}"]`),
);
} }
for (const li of root.querySelectorAll('li.item[aria-current="page"]')) { for (const li of root.querySelectorAll('li.item[aria-current="page"]')) {
if (li instanceof HTMLElement && !li.classList.contains("active")) { ensureActive(li);
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. */ /** Clear native drill state so it cannot steal pointer-events from the custom list. */
export function clearNativeDrillActive(menu: HTMLElement) { export function clearNativeDrillActive(menu: HTMLElement) {
const nativeList = getNativeMenuList(menu); getNativeMenuList(menu)
nativeList
?.querySelectorAll("li.hasChildren.active, section.hasChildren.active") ?.querySelectorAll("li.hasChildren.active, section.hasChildren.active")
.forEach((node) => node.classList.remove("active")); .forEach((node) => node.classList.remove("active"));
} }
@@ -113,6 +124,50 @@ function findItemByKey(
return null; return null;
} }
function menuTreeEqual(a: SidebarItem[], b: SidebarItem[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (
left.key !== right.key ||
left.path !== right.path ||
left.label !== right.label ||
left.iconHtml !== right.iconHtml ||
left.hasChildren !== right.hasChildren ||
left.itemColour !== right.itemColour ||
left.betterseqta !== right.betterseqta ||
left.id !== right.id ||
!menuTreeEqual(left.children, right.children)
) {
return false;
}
}
return true;
}
function drillStackEqual(
a: SidebarDrillFrame[],
b: SidebarDrillFrame[],
): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i];
const right = b[i];
if (
left.key !== right.key ||
left.label !== right.label ||
left.items.length !== right.items.length ||
left.items.some((item, j) => item.key !== right.items[j]?.key)
) {
return false;
}
}
return true;
}
class SidebarState { class SidebarState {
items = $state.raw<SidebarItem[]>([]); items = $state.raw<SidebarItem[]>([]);
drillStack = $state.raw<SidebarDrillFrame[]>([]); drillStack = $state.raw<SidebarDrillFrame[]>([]);
@@ -120,31 +175,22 @@ class SidebarState {
activePath = $state(""); activePath = $state("");
editMode = $state(false); editMode = $state(false);
iconOnly = $state(false); iconOnly = $state(false);
ready = $state(false); /** Frame key whose `.sub` should play the one-shot enter animation. */
enterFrameKey = $state<string | null>(null);
visibleRootItems = $derived( visibleRootItems = $derived(
filterVisible(orderItems(this.items, settingsState.menuorder ?? [])), 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); isDrilling = $derived(this.drillStack.length > 0);
compact = $derived(this.iconOnly && !this.isDrilling && !this.editMode); compact = $derived(this.iconOnly && !this.isDrilling && !this.editMode);
syncFromNative(menu: HTMLElement) { syncFromNative(menu: HTMLElement) {
this.items = parseNativeMenu(menu); const next = parseNativeMenu(menu);
this.ready = this.items.length > 0; if (!menuTreeEqual(this.items, next)) {
this.items = next;
}
this.syncActiveFromLocation(); this.syncActiveFromLocation();
this.pruneDrillStack(); this.pruneDrillStack();
} }
@@ -172,8 +218,7 @@ class SidebarState {
const segments = path.split("/").filter(Boolean); const segments = path.split("/").filter(Boolean);
while (segments.length > 1) { while (segments.length > 1) {
segments.pop(); segments.pop();
const parentPath = `/${segments.join("/")}`; const parent = findItemByPath(this.items, `/${segments.join("/")}`);
const parent = findItemByPath(this.items, parentPath);
if (parent) { if (parent) {
this.activeKey = parent.key; this.activeKey = parent.key;
return; return;
@@ -197,11 +242,14 @@ class SidebarState {
cursor = children; cursor = children;
} }
if (!drillStackEqual(this.drillStack, next)) {
this.drillStack = next; this.drillStack = next;
} }
}
openFolder(item: SidebarItem) { openFolder(item: SidebarItem) {
if (!item.hasChildren) return; if (!item.hasChildren) return;
this.enterFrameKey = item.key;
this.drillStack = [ this.drillStack = [
...this.drillStack, ...this.drillStack,
{ {
@@ -212,12 +260,20 @@ class SidebarState {
]; ];
} }
clearEnterFrame(key?: string) {
if (key == null || this.enterFrameKey === key) {
this.enterFrameKey = null;
}
}
goBack() { goBack() {
if (!this.drillStack.length) return; if (!this.drillStack.length) return;
this.enterFrameKey = null;
this.drillStack = this.drillStack.slice(0, -1); this.drillStack = this.drillStack.slice(0, -1);
} }
resetDrill() { resetDrill() {
this.enterFrameKey = null;
this.drillStack = []; this.drillStack = [];
} }
@@ -273,28 +329,18 @@ class SidebarState {
const native = findNativeMenuEntry(menu, item); const native = findNativeMenuEntry(menu, item);
if (native) { if (native) {
// Never strip `.active` from custom `#bsplus-sidebar-root` folders. // Clear native drill once only — repeating clearNativeDrillActive fights
// SEQTA (it re-adds .active) and used to freeze the tab via menu sync.
clearNativeDrillActive(menu); clearNativeDrillActive(menu);
native.click(); native.click();
// SEQTA re-opens native drill and strips `.active` after click — undo both.
clearNativeDrillActive(menu); clearNativeDrillActive(menu);
restoreCustomMenuActive(); restoreCustomMenuActive();
requestAnimationFrame(() => { requestAnimationFrame(() => {
clearNativeDrillActive(menu); clearNativeDrillActive(menu);
restoreCustomMenuActive(); restoreCustomMenuActive();
}); });
setTimeout(() => { // Later pass restores custom `.active` only (no native clear loop).
clearNativeDrillActive(menu); setTimeout(() => restoreCustomMenuActive(), 50);
restoreCustomMenuActive();
}, 0);
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 50);
setTimeout(() => {
clearNativeDrillActive(menu);
restoreCustomMenuActive();
}, 100);
return; return;
} }
-1
View File
@@ -6,7 +6,6 @@ export type SidebarItem = {
/** Outer HTML of the label's leading SVG (if any). */ /** Outer HTML of the label's leading SVG (if any). */
iconHtml: string; iconHtml: string;
hasChildren: boolean; hasChildren: boolean;
colour: string | null;
itemColour: string | null; itemColour: string | null;
betterseqta: boolean; betterseqta: boolean;
children: SidebarItem[]; children: SidebarItem[];