mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
fix issues with injected sidebar
This commit is contained in:
@@ -455,6 +455,58 @@ ul.magicDelete > li.deleting {
|
|||||||
top: 71.5px;
|
top: 71.5px;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drill-in stack: only the current list + folder header stay clickable.
|
||||||
|
Class is toggled by updateSidebarAccessibility (never touches aria-hidden). */
|
||||||
|
#menu .bsplus-sidebar-offscreen,
|
||||||
|
#menu .bsplus-sidebar-offscreen * {
|
||||||
|
pointer-events: none !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu > ul > .bsplus-sidebar-offscreen:not(.hasChildren.active) {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -10000px !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu .sub .bsplus-sidebar-offscreen:not(.hasChildren.active) {
|
||||||
|
visibility: hidden !important;
|
||||||
|
position: absolute !important;
|
||||||
|
left: -10000px !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the frontmost open .sub panel receives pointer events */
|
||||||
|
#menu .sub {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu li.hasChildren.active > .sub {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu li.hasChildren.active > .sub:has(.hasChildren.active) {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu li.hasChildren.active .hasChildren.active > .sub {
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#menu:has(> ul > li.hasChildren.active) > ul > li:not(.hasChildren.active) {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
#menu section > label {
|
#menu section > label {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -2326,6 +2378,10 @@ blurred {
|
|||||||
height: 64px;
|
height: 64px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
/* While a drill-in submenu is open, don't steal clicks meant for folder rows. */
|
||||||
|
#menu:has(li.hasChildren.active) > .icon-cover {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.uiSlidePane > .pane > .header button {
|
.uiSlidePane > .pane > .header button {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
|||||||
let lastTimeState: TimeState | null = null;
|
let lastTimeState: TimeState | null = null;
|
||||||
let lastDomSpec: ThemeDomSpec | null = null;
|
let lastDomSpec: ThemeDomSpec | null = null;
|
||||||
let bodyObserver: MutationObserver | null = null;
|
let bodyObserver: MutationObserver | null = null;
|
||||||
|
let pageshowListenerAttached = false;
|
||||||
|
let repaintScheduled = false;
|
||||||
|
|
||||||
type TimeState = "night" | "dawn" | "day" | "dusk" | "evening";
|
type TimeState = "night" | "dawn" | "day" | "dusk" | "evening";
|
||||||
|
|
||||||
@@ -422,6 +424,61 @@ function getOrCreateWallpaper(): HTMLElement {
|
|||||||
return wallpaper;
|
return wallpaper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Content scripts run at `document_start`; defer until `<body>` exists. */
|
||||||
|
function whenBodyReady(run: () => void): void {
|
||||||
|
if (document.body) {
|
||||||
|
run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", run, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeRootNeedsContent(spec: ThemeDomSpec): boolean {
|
||||||
|
const root = document.getElementById(ROOT_ID);
|
||||||
|
if (!root) return true;
|
||||||
|
if (spec.roadStrip && !document.getElementById("city-road")) return true;
|
||||||
|
const carCount = Math.min(spec.cars ?? 0, MAX_CARS);
|
||||||
|
if (carCount > 0 && root.querySelector(".city-car") === null) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityWallpaperNeedsLayers(): boolean {
|
||||||
|
if (!lastDomSpec?.cityLayers) return false;
|
||||||
|
const wallpaper = document.getElementById(WALLPAPER_ID);
|
||||||
|
if (!wallpaper) return true;
|
||||||
|
return wallpaper.querySelector("#city-buildings") === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repaintThemeDomIfNeeded(): void {
|
||||||
|
if (!lastDomSpec || !document.body) return;
|
||||||
|
if (runtimeRootNeedsContent(lastDomSpec)) {
|
||||||
|
const root = getOrCreateRoot();
|
||||||
|
populateRoot(root, lastDomSpec);
|
||||||
|
}
|
||||||
|
if (cityWallpaperNeedsLayers()) {
|
||||||
|
const wallpaper = getOrCreateWallpaper();
|
||||||
|
populateWallpaper(wallpaper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRepaintThemeDomIfNeeded(): void {
|
||||||
|
if (repaintScheduled) return;
|
||||||
|
repaintScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
repaintScheduled = false;
|
||||||
|
repaintThemeDomIfNeeded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeDomNeedsRepaint(): boolean {
|
||||||
|
if (!lastDomSpec) return false;
|
||||||
|
if (!document.getElementById(ROOT_ID)) return true;
|
||||||
|
if (lastDomSpec.cityLayers && !document.getElementById(WALLPAPER_ID)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return runtimeRootNeedsContent(lastDomSpec) || cityWallpaperNeedsLayers();
|
||||||
|
}
|
||||||
|
|
||||||
function populateWallpaper(wallpaper: HTMLElement): void {
|
function populateWallpaper(wallpaper: HTMLElement): void {
|
||||||
while (wallpaper.firstChild) wallpaper.removeChild(wallpaper.firstChild);
|
while (wallpaper.firstChild) wallpaper.removeChild(wallpaper.firstChild);
|
||||||
for (const id of CITY_LAYER_IDS) {
|
for (const id of CITY_LAYER_IDS) {
|
||||||
@@ -490,18 +547,28 @@ function populateRoot(root: HTMLElement, dom: ThemeDomSpec): void {
|
|||||||
*/
|
*/
|
||||||
function ensureBodyObserver(): void {
|
function ensureBodyObserver(): void {
|
||||||
if (bodyObserver) return;
|
if (bodyObserver) return;
|
||||||
bodyObserver = new MutationObserver(() => {
|
const attach = () => {
|
||||||
if (!lastDomSpec) return;
|
if (!document.body || bodyObserver) return;
|
||||||
if (!document.getElementById(ROOT_ID)) {
|
bodyObserver = new MutationObserver(() => {
|
||||||
const root = getOrCreateRoot();
|
if (themeDomNeedsRepaint()) scheduleRepaintThemeDomIfNeeded();
|
||||||
populateRoot(root, lastDomSpec);
|
});
|
||||||
}
|
// Only direct children of <body>: the theme nodes are appended there.
|
||||||
if (lastDomSpec.cityLayers && !document.getElementById(WALLPAPER_ID)) {
|
// Watching the full subtree fired on every SEQTA menu/content mutation and
|
||||||
const wallpaper = getOrCreateWallpaper();
|
// could repaint decorative layers mid-navigation (felt like a page reload).
|
||||||
populateWallpaper(wallpaper);
|
bodyObserver.observe(document.body, { childList: true });
|
||||||
}
|
};
|
||||||
});
|
whenBodyReady(attach);
|
||||||
bodyObserver.observe(document.body, { childList: true });
|
|
||||||
|
if (!pageshowListenerAttached && typeof window !== "undefined") {
|
||||||
|
pageshowListenerAttached = true;
|
||||||
|
window.addEventListener(
|
||||||
|
"pageshow",
|
||||||
|
(event) => {
|
||||||
|
if (event.persisted) repaintThemeDomIfNeeded();
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -513,34 +580,45 @@ function ensureBodyObserver(): void {
|
|||||||
export function injectThemeDom(dom: ThemeDomSpec | undefined): void {
|
export function injectThemeDom(dom: ThemeDomSpec | undefined): void {
|
||||||
if (!dom || !validateThemeDom(dom)) return;
|
if (!dom || !validateThemeDom(dom)) return;
|
||||||
lastDomSpec = dom;
|
lastDomSpec = dom;
|
||||||
const root = getOrCreateRoot();
|
|
||||||
populateRoot(root, dom);
|
|
||||||
if (dom.cityLayers) {
|
|
||||||
const wallpaper = getOrCreateWallpaper();
|
|
||||||
populateWallpaper(wallpaper);
|
|
||||||
} else {
|
|
||||||
// Theme switched from a cityLayers theme to one without — tear down
|
|
||||||
// any leftover wallpaper so we don't paint stale buildings/sun.
|
|
||||||
document.getElementById(WALLPAPER_ID)?.remove();
|
|
||||||
}
|
|
||||||
ensureBodyObserver();
|
ensureBodyObserver();
|
||||||
|
|
||||||
// Suppress the slow `transition: background-color` for the very first
|
const mount = () => {
|
||||||
// frame after the theme CSS lands. Without this, the browser
|
if (!lastDomSpec) return;
|
||||||
// interpolates from SEQTA's pre-theme `background: unset` (light) to
|
const spec = lastDomSpec;
|
||||||
// var(--city-sky-color) over 30s on every page load. Double rAF: the
|
if (runtimeRootNeedsContent(spec)) {
|
||||||
// first runs after the next layout, the second after that frame has
|
const root = getOrCreateRoot();
|
||||||
// actually been painted with the attribute set, so we can safely
|
populateRoot(root, spec);
|
||||||
// remove it and let real state changes (night -> dawn etc.) animate.
|
}
|
||||||
const html = document.documentElement;
|
if (spec.cityLayers) {
|
||||||
html.setAttribute("data-city-just-applied", "");
|
if (cityWallpaperNeedsLayers()) {
|
||||||
requestAnimationFrame(() => {
|
const wallpaper = getOrCreateWallpaper();
|
||||||
requestAnimationFrame(() => {
|
populateWallpaper(wallpaper);
|
||||||
html.removeAttribute("data-city-just-applied");
|
}
|
||||||
});
|
} else {
|
||||||
});
|
// Theme switched from a cityLayers theme to one without — tear down
|
||||||
|
// any leftover wallpaper so we don't paint stale buildings/sun.
|
||||||
|
document.getElementById(WALLPAPER_ID)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
mountDevTimePicker();
|
// Suppress the slow `transition: background-color` for the very first
|
||||||
|
// frame after the theme CSS lands. Without this, the browser
|
||||||
|
// interpolates from SEQTA's pre-theme `background: unset` (light) to
|
||||||
|
// var(--city-sky-color) over 30s on every page load. Double rAF: the
|
||||||
|
// first runs after the next layout, the second after that frame has
|
||||||
|
// actually been painted with the attribute set, so we can safely
|
||||||
|
// remove it and let real state changes (night -> dawn etc.) animate.
|
||||||
|
const html = document.documentElement;
|
||||||
|
html.setAttribute("data-city-just-applied", "");
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
html.removeAttribute("data-city-just-applied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mountDevTimePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
whenBodyReady(mount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ let sidebarAccessibilityObserver: MutationObserver | null = null;
|
|||||||
let sidebarTabOrderAnimationFrame: number | null = null;
|
let sidebarTabOrderAnimationFrame: number | null = null;
|
||||||
let sidebarAccessibilityListenersAttached = false;
|
let sidebarAccessibilityListenersAttached = false;
|
||||||
|
|
||||||
|
/** Marks menu rows that are off-screen in the drill stack (CSS blocks clicks). */
|
||||||
|
const BSPLUS_SIDEBAR_OFFSCREEN = "bsplus-sidebar-offscreen";
|
||||||
|
|
||||||
export async function getUserInfo() {
|
export async function getUserInfo() {
|
||||||
if (cachedUserInfo) return cachedUserInfo;
|
if (cachedUserInfo) return cachedUserInfo;
|
||||||
|
|
||||||
@@ -493,9 +496,15 @@ function scheduleSidebarAccessibilityUpdate() {
|
|||||||
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
|
cancelAnimationFrame(sidebarTabOrderAnimationFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Double rAF: SEQTA applies `.active` / updates `.sub` on the next frame
|
||||||
|
// after a click. Running earlier hid the submenu with `aria-hidden` while
|
||||||
|
// focus was still on a <label> inside it, which broke routing and sent
|
||||||
|
// the SPA back to home.
|
||||||
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
|
sidebarTabOrderAnimationFrame = requestAnimationFrame(() => {
|
||||||
sidebarTabOrderAnimationFrame = null;
|
requestAnimationFrame(() => {
|
||||||
updateSidebarAccessibility();
|
sidebarTabOrderAnimationFrame = null;
|
||||||
|
updateSidebarAccessibility();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,9 +515,10 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
|
|||||||
const menuItem = target.closest("#menu li, #menu section") as
|
const menuItem = target.closest("#menu li, #menu section") as
|
||||||
| HTMLElement
|
| HTMLElement
|
||||||
| null;
|
| null;
|
||||||
if (!menuItem || target !== menuItem) return;
|
if (!menuItem) return;
|
||||||
|
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
|
if (target !== menuItem) return;
|
||||||
const menu = document.getElementById("menu");
|
const menu = document.getElementById("menu");
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
@@ -552,11 +562,52 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard tab order for the drilled-in sidebar only.
|
||||||
|
* SEQTA already sets `aria-hidden` on off-screen menu rows; we must not
|
||||||
|
* override that or hide `.sub` ourselves — doing so while a <label> inside
|
||||||
|
* the submenu still has focus breaks SEQTA's router and navigates to home.
|
||||||
|
*/
|
||||||
|
/** Every folder row on the path to the open list (e.g. Assessments → 2026_S1). */
|
||||||
|
function getDrillFolderChain(
|
||||||
|
menu: HTMLElement,
|
||||||
|
visibleList: HTMLElement | null,
|
||||||
|
): Set<HTMLElement> {
|
||||||
|
const chain = new Set<HTMLElement>();
|
||||||
|
let list: HTMLElement | null = visibleList;
|
||||||
|
|
||||||
|
while (list && menu.contains(list)) {
|
||||||
|
const folder = getSidebarListParentEntry(list);
|
||||||
|
if (!folder || !menu.contains(folder)) break;
|
||||||
|
chain.add(folder);
|
||||||
|
|
||||||
|
const containerUl = folder.parentElement;
|
||||||
|
if (!(containerUl instanceof HTMLElement)) break;
|
||||||
|
const parentSub = containerUl.closest(".sub");
|
||||||
|
if (!parentSub || !menu.contains(parentSub)) break;
|
||||||
|
const parentFolder = parentSub.parentElement;
|
||||||
|
if (!(parentFolder instanceof HTMLElement) || !menu.contains(parentFolder)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chain.add(parentFolder);
|
||||||
|
list =
|
||||||
|
parentFolder.parentElement instanceof HTMLElement
|
||||||
|
? parentFolder.parentElement
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
function updateSidebarAccessibility() {
|
function updateSidebarAccessibility() {
|
||||||
const menu = document.getElementById("menu");
|
const menu = document.getElementById("menu");
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
const visibleEntries = new Set(getVisibleSidebarEntries(menu));
|
const visibleList = getVisibleSidebarList(menu);
|
||||||
|
const visibleEntries = new Set(
|
||||||
|
visibleList ? getDirectSidebarEntries(visibleList) : [],
|
||||||
|
);
|
||||||
|
const drillFolders = getDrillFolderChain(menu, visibleList);
|
||||||
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
|
const menuEntries = menu.querySelectorAll("li.item, section.item, li, section");
|
||||||
|
|
||||||
for (const entry of menuEntries) {
|
for (const entry of menuEntries) {
|
||||||
@@ -565,28 +616,19 @@ function updateSidebarAccessibility() {
|
|||||||
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
|
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
|
|
||||||
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null;
|
const interactive =
|
||||||
const isHidden =
|
visibleEntries.has(entry) || drillFolders.has(entry);
|
||||||
entry.offsetParent === null ||
|
|
||||||
window.getComputedStyle(entry).display === "none" ||
|
|
||||||
window.getComputedStyle(label).display === "none" ||
|
|
||||||
!visibleEntries.has(entry);
|
|
||||||
|
|
||||||
if (isHidden) {
|
if (!interactive) {
|
||||||
|
entry.classList.add(BSPLUS_SIDEBAR_OFFSCREEN);
|
||||||
entry.tabIndex = -1;
|
entry.tabIndex = -1;
|
||||||
label.tabIndex = -1;
|
label.tabIndex = -1;
|
||||||
entry.setAttribute("aria-hidden", "true");
|
|
||||||
label.setAttribute("aria-hidden", "true");
|
|
||||||
if (childSubmenu) {
|
|
||||||
childSubmenu.setAttribute("aria-hidden", "true");
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry.classList.remove(BSPLUS_SIDEBAR_OFFSCREEN);
|
||||||
entry.tabIndex = 0;
|
entry.tabIndex = 0;
|
||||||
label.tabIndex = -1;
|
label.tabIndex = -1;
|
||||||
entry.removeAttribute("aria-hidden");
|
|
||||||
label.removeAttribute("aria-hidden");
|
|
||||||
|
|
||||||
if (!entry.hasAttribute("role")) {
|
if (!entry.hasAttribute("role")) {
|
||||||
entry.setAttribute("role", "button");
|
entry.setAttribute("role", "button");
|
||||||
@@ -596,14 +638,6 @@ function updateSidebarAccessibility() {
|
|||||||
if (accessibleLabel) {
|
if (accessibleLabel) {
|
||||||
entry.setAttribute("aria-label", accessibleLabel);
|
entry.setAttribute("aria-label", accessibleLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (childSubmenu) {
|
|
||||||
const isExpanded = entry.classList.contains("active");
|
|
||||||
entry.setAttribute("aria-expanded", String(isExpanded));
|
|
||||||
childSubmenu.setAttribute("aria-hidden", String(!isExpanded));
|
|
||||||
} else {
|
|
||||||
entry.removeAttribute("aria-expanded");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type CustomTheme = {
|
|||||||
roadStrip?: boolean;
|
roadStrip?: boolean;
|
||||||
cars?: number;
|
cars?: number;
|
||||||
flickers?: number;
|
flickers?: number;
|
||||||
|
cityLayers?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user