fix issues with injected sidebar

This commit is contained in:
2026-06-01 14:16:38 +09:30
parent 2356a49fcd
commit 774be0ceed
4 changed files with 232 additions and 63 deletions
+56
View File
@@ -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;
} }
+115 -37
View File
@@ -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);
} }
/** /**
+60 -26
View File
@@ -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");
}
} }
} }
+1
View File
@@ -37,6 +37,7 @@ export type CustomTheme = {
roadStrip?: boolean; roadStrip?: boolean;
cars?: number; cars?: number;
flickers?: number; flickers?: number;
cityLayers?: boolean;
}; };
}; };