This commit is contained in:
2026-06-01 19:53:58 +09:30
11 changed files with 1266 additions and 81 deletions
+60 -26
View File
@@ -22,6 +22,9 @@ let sidebarAccessibilityObserver: MutationObserver | null = null;
let sidebarTabOrderAnimationFrame: number | null = null;
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() {
if (cachedUserInfo) return cachedUserInfo;
@@ -493,9 +496,15 @@ function scheduleSidebarAccessibilityUpdate() {
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 = null;
updateSidebarAccessibility();
requestAnimationFrame(() => {
sidebarTabOrderAnimationFrame = null;
updateSidebarAccessibility();
});
});
}
@@ -506,9 +515,10 @@ function handleSidebarKeyboardActivation(event: KeyboardEvent) {
const menuItem = target.closest("#menu li, #menu section") as
| HTMLElement
| null;
if (!menuItem || target !== menuItem) return;
if (!menuItem) return;
if (event.key === "Tab") {
if (target !== menuItem) return;
const menu = document.getElementById("menu");
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() {
const menu = document.getElementById("menu");
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");
for (const entry of menuEntries) {
@@ -565,28 +616,19 @@ function updateSidebarAccessibility() {
const label = entry.querySelector(":scope > label") as HTMLLabelElement | null;
if (!label) continue;
const childSubmenu = entry.querySelector(":scope > .sub") as HTMLElement | null;
const isHidden =
entry.offsetParent === null ||
window.getComputedStyle(entry).display === "none" ||
window.getComputedStyle(label).display === "none" ||
!visibleEntries.has(entry);
const interactive =
visibleEntries.has(entry) || drillFolders.has(entry);
if (isHidden) {
if (!interactive) {
entry.classList.add(BSPLUS_SIDEBAR_OFFSCREEN);
entry.tabIndex = -1;
label.tabIndex = -1;
entry.setAttribute("aria-hidden", "true");
label.setAttribute("aria-hidden", "true");
if (childSubmenu) {
childSubmenu.setAttribute("aria-hidden", "true");
}
continue;
}
entry.classList.remove(BSPLUS_SIDEBAR_OFFSCREEN);
entry.tabIndex = 0;
label.tabIndex = -1;
entry.removeAttribute("aria-hidden");
label.removeAttribute("aria-hidden");
if (!entry.hasAttribute("role")) {
entry.setAttribute("role", "button");
@@ -596,14 +638,6 @@ function updateSidebarAccessibility() {
if (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");
}
}
}
@@ -45,10 +45,10 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
}
}
/** True when we have a new monthly entry the user hasn't dismissed yet. */
/** True when the current month's entry should appear in the startup queue. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
}
function escapeHTML(str: string): string {
@@ -108,18 +108,9 @@ async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<st
return fallback || null;
}
function closeThemeOfTheMonthCard(
card: HTMLElement,
onDismissed?: () => void,
markSeen = true,
) {
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
if (markSeen) {
const entryId = card.dataset.entryId;
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
}
card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => {
card.remove();
@@ -143,7 +134,6 @@ export async function OpenThemeOfTheMonthPopup(
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
@@ -154,39 +144,74 @@ export async function OpenThemeOfTheMonthPopup(
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<div class="themeOfTheMonthCardActions">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
<div class="themeOfTheMonthCardActionsStart">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
</div>
<div class="themeOfTheMonthCardActionsEnd">
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
<button type="button" class="themeOfTheMonthCardDontShow">Don't show again</button>
</div>
</div>
</div>
<div class="themeOfTheMonthCardConfirm" hidden>
<div class="themeOfTheMonthCardConfirmInner">
<h3>Don't show again?</h3>
<p>Theme of the Month popups will be turned off. You can turn them back on in BetterSEQTA+ settings.</p>
<div class="themeOfTheMonthCardConfirmActions">
<button type="button" class="themeOfTheMonthCardConfirmCancel">Cancel</button>
<button type="button" class="themeOfTheMonthCardConfirmAccept">Don't show again</button>
</div>
</div>
</div>
</aside>
`).firstChild as HTMLElement;
card.dataset.entryId = entry.id;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 12000);
}, 30_000);
const dismiss = (markSeen = true) => {
const dismiss = () => {
window.clearTimeout(autoCloseTimeout);
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
closeThemeOfTheMonthCard(card, onDismissed);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss();
openThemeStoreWithHighlight(linkedThemeId!);
});
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
const openDontShowConfirm = () => {
window.clearTimeout(autoCloseTimeout);
if (!confirmEl) return;
confirmEl.hidden = false;
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
};
card.querySelector(".themeOfTheMonthCardDontShow")?.addEventListener("click", openDontShowConfirm);
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
if (!confirmEl) return;
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
window.setTimeout(() => {
confirmEl.hidden = true;
}, 160);
});
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
});
@@ -196,7 +221,7 @@ export async function OpenThemeOfTheMonthPopup(
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user has already dismissed it this month.
* even if the user dismissed it for this calendar month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
@@ -207,7 +232,7 @@ export async function showThemeOfTheMonthPopupNow(): Promise<void> {
return;
}
settingsState.themeOfTheMonthLastSeenId = undefined;
settingsState.themeOfTheMonthDismissedMonth = undefined;
if (document.getElementById("whatsnewbk")) {
await closePopup();
+1 -1
View File
@@ -15,7 +15,7 @@ type QueueStep = (goNext: () => void) => void;
/**
* Runs startup modals in order: What's New (if the extension just updated),
* Theme of the Month (when a new monthly entry hasn't been seen), then shows
* Theme of the Month (when the user hasn't dismissed this calendar month), then shows
* the SEQTA Engage toast (once, non-blocking).
*/
export async function runStartupPopupQueue() {