Some checks failed
Repo Health / Access control (push) Failing after 1s
Repo Health / Release configuration (push) Has been skipped
Repo Health / Scripts governance (push) Has been skipped
Repo Health / Repository health (push) Has been skipped
Auto-Update SHA Hash / Update SHA-256 Hash in updates.xml (release) Failing after 40s
- Brand-aside position now uses flex columns like top-a (card style, equal-width) - Offline page: external offline.css with theme variables, 3-column centered card layout, Osaka font loading, full-screen on mobile - CSS variable click-to-copy: scans text for --var patterns, wraps in clickable chips with toast notification on copy - Search button border matches input border (--input-border-color) - mod_stats override: converted from dl to table layout - Patch bump 03.09.15 → 03.09.16 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
827 lines
26 KiB
JavaScript
827 lines
26 KiB
JavaScript
/* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
|
|
|
This file is part of a Moko Consulting project.
|
|
|
|
SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
(function (win, doc) {
|
|
"use strict";
|
|
|
|
// ========================================================================
|
|
// THEME INITIALIZATION (Early theme application)
|
|
// ========================================================================
|
|
var storageKey = "theme";
|
|
var mql = win.matchMedia("(prefers-color-scheme: dark)");
|
|
var root = doc.documentElement;
|
|
|
|
/**
|
|
* Apply theme to <html>, syncing both data-bs-theme and data-aria-theme.
|
|
* @param {"light"|"dark"} theme
|
|
*/
|
|
function applyTheme(theme) {
|
|
root.setAttribute("data-bs-theme", theme);
|
|
root.setAttribute("data-aria-theme", theme);
|
|
try { localStorage.setItem(storageKey, theme); } catch (e) {}
|
|
}
|
|
|
|
/**
|
|
* Clear stored preference so system preference is followed.
|
|
*/
|
|
function clearStored() {
|
|
try { localStorage.removeItem(storageKey); } catch (e) {}
|
|
}
|
|
|
|
/**
|
|
* Determine system theme.
|
|
*/
|
|
function systemTheme() {
|
|
return mql.matches ? "dark" : "light";
|
|
}
|
|
|
|
/**
|
|
* Get stored theme preference.
|
|
*/
|
|
function getStored() {
|
|
try { return localStorage.getItem(storageKey); } catch (e) { return null; }
|
|
}
|
|
|
|
// ========================================================================
|
|
// FLOATING THEME TOGGLE (FAB)
|
|
// ========================================================================
|
|
function posClassFromBody() {
|
|
var pos = (doc.body.getAttribute('data-theme-fab-pos') || 'br').toLowerCase();
|
|
if (!/^(br|bl|tr|tl)$/.test(pos)) pos = 'br';
|
|
return 'pos-' + pos;
|
|
}
|
|
|
|
function buildThemeToggle() {
|
|
if (doc.getElementById('mokoThemeFab')) return;
|
|
|
|
var wrap = doc.createElement('div');
|
|
wrap.id = 'mokoThemeFab';
|
|
wrap.className = posClassFromBody();
|
|
|
|
// Sun/Moon toggle button
|
|
var switchWrap = doc.createElement('button');
|
|
switchWrap.id = 'mokoThemeSwitch';
|
|
switchWrap.type = 'button';
|
|
switchWrap.className = 'theme-icon-btn';
|
|
switchWrap.setAttribute('aria-label', 'Toggle dark mode');
|
|
|
|
var sunIcon = doc.createElement('i');
|
|
sunIcon.className = 'fa-solid fa-sun';
|
|
sunIcon.setAttribute('aria-hidden', 'true');
|
|
|
|
var moonIcon = doc.createElement('i');
|
|
moonIcon.className = 'fa-solid fa-moon';
|
|
moonIcon.setAttribute('aria-hidden', 'true');
|
|
|
|
switchWrap.appendChild(sunIcon);
|
|
switchWrap.appendChild(moonIcon);
|
|
|
|
function updateThemeIcon(theme) {
|
|
if (theme === 'dark') {
|
|
switchWrap.classList.add('is-dark');
|
|
switchWrap.classList.remove('is-light');
|
|
} else {
|
|
switchWrap.classList.add('is-light');
|
|
switchWrap.classList.remove('is-dark');
|
|
}
|
|
}
|
|
|
|
// Auto toggle (on/off switch style)
|
|
var autoWrap = doc.createElement('div');
|
|
autoWrap.className = 'auto-toggle-wrap';
|
|
|
|
var autoLabel = doc.createElement('span');
|
|
autoLabel.className = 'auto-label';
|
|
autoLabel.textContent = 'Auto';
|
|
|
|
var auto = doc.createElement('button');
|
|
auto.id = 'mokoThemeAuto';
|
|
auto.type = 'button';
|
|
auto.className = 'auto-switch';
|
|
auto.setAttribute('role', 'switch');
|
|
auto.setAttribute('aria-label', 'Automatic theme (follow system)');
|
|
auto.setAttribute('aria-checked', getStored() ? 'false' : 'true');
|
|
|
|
var autoTrack = doc.createElement('span');
|
|
autoTrack.className = 'auto-track';
|
|
var autoKnob = doc.createElement('span');
|
|
autoKnob.className = 'auto-knob';
|
|
autoTrack.appendChild(autoKnob);
|
|
auto.appendChild(autoTrack);
|
|
if (!getStored()) auto.classList.add('on');
|
|
|
|
autoWrap.appendChild(autoLabel);
|
|
autoWrap.appendChild(auto);
|
|
|
|
// Divider before a11y slot
|
|
var divider = doc.createElement('span');
|
|
divider.className = 'fab-divider';
|
|
|
|
// A11y slot — buildA11yToolbar will inject its toggle here
|
|
var a11ySlot = doc.createElement('span');
|
|
a11ySlot.id = 'mokoA11ySlot';
|
|
|
|
// Behavior
|
|
switchWrap.addEventListener('click', function () {
|
|
var current = (root.getAttribute('data-bs-theme') || 'light').toLowerCase();
|
|
var next = current === 'dark' ? 'light' : 'dark';
|
|
applyTheme(next);
|
|
updateThemeIcon(next);
|
|
// Turn off auto when manually switching
|
|
auto.classList.remove('on');
|
|
auto.setAttribute('aria-checked', 'false');
|
|
// Update meta theme color
|
|
var meta = doc.querySelector('meta[name="theme-color"]');
|
|
if (meta) {
|
|
meta.setAttribute('content', next === 'dark' ? '#0f1115' : '#ffffff');
|
|
}
|
|
});
|
|
|
|
auto.addEventListener('click', function () {
|
|
var isAuto = auto.classList.toggle('on');
|
|
auto.setAttribute('aria-checked', isAuto ? 'true' : 'false');
|
|
if (isAuto) {
|
|
clearStored();
|
|
var sys = systemTheme();
|
|
applyTheme(sys);
|
|
updateThemeIcon(sys);
|
|
}
|
|
});
|
|
|
|
// Respond to OS changes only when not user-forced
|
|
var onMql = function () {
|
|
if (!getStored()) {
|
|
var sys = systemTheme();
|
|
applyTheme(sys);
|
|
updateThemeIcon(sys);
|
|
}
|
|
};
|
|
if (typeof mql.addEventListener === 'function') mql.addEventListener('change', onMql);
|
|
else if (typeof mql.addListener === 'function') mql.addListener(onMql);
|
|
|
|
// Initial state
|
|
var initial = getStored() || systemTheme();
|
|
updateThemeIcon(initial);
|
|
|
|
// Mount
|
|
wrap.appendChild(switchWrap);
|
|
wrap.appendChild(autoWrap);
|
|
wrap.appendChild(divider);
|
|
wrap.appendChild(a11ySlot);
|
|
doc.body.appendChild(wrap);
|
|
|
|
// Debug helper
|
|
win.mokoThemeFabStatus = function () {
|
|
var el = doc.getElementById('mokoThemeFab');
|
|
if (!el) return { mounted: false };
|
|
var r = el.getBoundingClientRect();
|
|
return {
|
|
mounted: true,
|
|
rect: { top: r.top, left: r.left, width: r.width, height: r.height },
|
|
zIndex: win.getComputedStyle(el).zIndex,
|
|
posClass: el.className
|
|
};
|
|
};
|
|
|
|
// Outline if invisible
|
|
setTimeout(function () {
|
|
var r = wrap.getBoundingClientRect();
|
|
if (r.width < 10 || r.height < 10) {
|
|
wrap.classList.add('debug-outline');
|
|
console.warn('[moko] Theme FAB mounted but appears too small — check CSS collisions.');
|
|
}
|
|
}, 50);
|
|
}
|
|
|
|
// ========================================================================
|
|
// ACCESSIBILITY TOOLBAR
|
|
// ========================================================================
|
|
var a11yStorageKey = "moko-a11y";
|
|
var fontSizeSteps = [85, 90, 100, 110, 120, 130];
|
|
var defaultStep = 2; // index of 100
|
|
|
|
function getA11yPrefs() {
|
|
try {
|
|
var raw = localStorage.getItem(a11yStorageKey);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch (e) {}
|
|
return { fontStep: defaultStep, inverted: false, contrast: false, links: false, font: false, paused: false };
|
|
}
|
|
|
|
function saveA11yPrefs(prefs) {
|
|
try { localStorage.setItem(a11yStorageKey, JSON.stringify(prefs)); } catch (e) {}
|
|
}
|
|
|
|
function applyFontSize(step) {
|
|
root.style.fontSize = fontSizeSteps[step] + "%";
|
|
}
|
|
|
|
function applyInversion(on) {
|
|
if (on) {
|
|
root.classList.add("a11y-inverted");
|
|
} else {
|
|
root.classList.remove("a11y-inverted");
|
|
}
|
|
}
|
|
|
|
function applyContrast(on) {
|
|
root.classList.toggle("a11y-high-contrast", on);
|
|
// Lazy-load the high-contrast stylesheet
|
|
var hcId = "mokoA11yHcSheet";
|
|
var existing = doc.getElementById(hcId);
|
|
if (on && !existing) {
|
|
var link = doc.createElement("link");
|
|
link.id = hcId;
|
|
link.rel = "stylesheet";
|
|
link.href = (doc.querySelector('link[href*="mokocassiopeia/css/template"]') || {}).href
|
|
? (doc.querySelector('link[href*="mokocassiopeia/css/template"]').href.replace(/\/css\/template[^/]*$/, "/css/a11y-high-contrast.css"))
|
|
: "media/templates/site/mokocassiopeia/css/a11y-high-contrast.css";
|
|
doc.head.appendChild(link);
|
|
}
|
|
}
|
|
|
|
function applyLinks(on) {
|
|
root.classList.toggle("a11y-highlight-links", on);
|
|
}
|
|
|
|
function applyFont(on) {
|
|
root.classList.toggle("a11y-readable-font", on);
|
|
}
|
|
|
|
function applyPaused(on) {
|
|
root.classList.toggle("a11y-pause-animations", on);
|
|
}
|
|
|
|
/** Create a Font Awesome icon element (safe DOM, no innerHTML). */
|
|
function faIcon(classes) {
|
|
var i = doc.createElement("i");
|
|
i.className = classes;
|
|
i.setAttribute("aria-hidden", "true");
|
|
return i;
|
|
}
|
|
|
|
function buildA11yToolbar() {
|
|
if (doc.getElementById("mokoA11yToolbar")) return;
|
|
|
|
var body = doc.body;
|
|
var showResize = body.getAttribute("data-a11y-resize") === "1";
|
|
var showInvert = body.getAttribute("data-a11y-invert") === "1";
|
|
var showContrast = body.getAttribute("data-a11y-contrast") === "1";
|
|
var showLinks = body.getAttribute("data-a11y-links") === "1";
|
|
var showFont = body.getAttribute("data-a11y-font") === "1";
|
|
var showAnimations = body.getAttribute("data-a11y-animations") === "1";
|
|
var pos = (body.getAttribute("data-a11y-pos") || "tl").toLowerCase();
|
|
if (!/^(br|bl|tr|tl)$/.test(pos)) pos = "tl";
|
|
|
|
var prefs = getA11yPrefs();
|
|
|
|
// Container
|
|
var toolbar = doc.createElement("div");
|
|
toolbar.id = "mokoA11yToolbar";
|
|
toolbar.className = "a11y-pos-" + pos;
|
|
toolbar.setAttribute("role", "toolbar");
|
|
toolbar.setAttribute("aria-label", "Accessibility options");
|
|
|
|
// Toggle button (accessibility icon)
|
|
var toggle = doc.createElement("button");
|
|
toggle.type = "button";
|
|
toggle.id = "mokoA11yToggle";
|
|
toggle.className = "a11y-toggle";
|
|
toggle.setAttribute("aria-label", "Accessibility options");
|
|
toggle.setAttribute("aria-expanded", "false");
|
|
var a11yIcon = faIcon("fa-solid fa-universal-access");
|
|
// Unicode fallback if FA7 glyph doesn't render (e.g. FA6/FA7 conflict)
|
|
setTimeout(function () {
|
|
var cs = win.getComputedStyle(a11yIcon, "::before");
|
|
if (!cs.content || cs.content === "none" || cs.content === '""' || cs.content === '"" / ""') {
|
|
a11yIcon.className = "";
|
|
a11yIcon.textContent = "\u267F";
|
|
a11yIcon.style.fontSize = "1.1rem";
|
|
}
|
|
}, 500);
|
|
toggle.appendChild(a11yIcon);
|
|
|
|
// Panel
|
|
var panel = doc.createElement("div");
|
|
panel.id = "mokoA11yPanel";
|
|
panel.className = "a11y-panel";
|
|
panel.hidden = true;
|
|
|
|
// --- Text resize controls ---
|
|
if (showResize) {
|
|
var resizeGroup = doc.createElement("div");
|
|
resizeGroup.className = "a11y-group";
|
|
resizeGroup.setAttribute("role", "group");
|
|
resizeGroup.setAttribute("aria-label", "Text size");
|
|
|
|
var sizeLabel = doc.createElement("span");
|
|
sizeLabel.className = "a11y-group-label";
|
|
sizeLabel.textContent = "Text size";
|
|
resizeGroup.appendChild(sizeLabel);
|
|
|
|
var btnRow = doc.createElement("div");
|
|
btnRow.className = "a11y-btn-row";
|
|
|
|
var btnMinus = doc.createElement("button");
|
|
btnMinus.type = "button";
|
|
btnMinus.className = "a11y-btn";
|
|
btnMinus.setAttribute("aria-label", "Decrease text size");
|
|
btnMinus.appendChild(faIcon("fa-solid fa-minus"));
|
|
|
|
var sizeDisplay = doc.createElement("span");
|
|
sizeDisplay.className = "a11y-size-display";
|
|
sizeDisplay.setAttribute("aria-live", "polite");
|
|
sizeDisplay.textContent = fontSizeSteps[prefs.fontStep] + "%";
|
|
|
|
var btnPlus = doc.createElement("button");
|
|
btnPlus.type = "button";
|
|
btnPlus.className = "a11y-btn";
|
|
btnPlus.setAttribute("aria-label", "Increase text size");
|
|
btnPlus.appendChild(faIcon("fa-solid fa-plus"));
|
|
|
|
var btnReset = doc.createElement("button");
|
|
btnReset.type = "button";
|
|
btnReset.className = "a11y-btn a11y-btn-reset";
|
|
btnReset.setAttribute("aria-label", "Reset text size");
|
|
btnReset.appendChild(faIcon("fa-solid fa-rotate-left"));
|
|
|
|
btnMinus.addEventListener("click", function () {
|
|
if (prefs.fontStep > 0) {
|
|
prefs.fontStep--;
|
|
applyFontSize(prefs.fontStep);
|
|
sizeDisplay.textContent = fontSizeSteps[prefs.fontStep] + "%";
|
|
saveA11yPrefs(prefs);
|
|
}
|
|
});
|
|
|
|
btnPlus.addEventListener("click", function () {
|
|
if (prefs.fontStep < fontSizeSteps.length - 1) {
|
|
prefs.fontStep++;
|
|
applyFontSize(prefs.fontStep);
|
|
sizeDisplay.textContent = fontSizeSteps[prefs.fontStep] + "%";
|
|
saveA11yPrefs(prefs);
|
|
}
|
|
});
|
|
|
|
btnReset.addEventListener("click", function () {
|
|
prefs.fontStep = defaultStep;
|
|
applyFontSize(prefs.fontStep);
|
|
sizeDisplay.textContent = fontSizeSteps[prefs.fontStep] + "%";
|
|
saveA11yPrefs(prefs);
|
|
});
|
|
|
|
btnRow.appendChild(btnMinus);
|
|
btnRow.appendChild(sizeDisplay);
|
|
btnRow.appendChild(btnPlus);
|
|
btnRow.appendChild(btnReset);
|
|
resizeGroup.appendChild(btnRow);
|
|
panel.appendChild(resizeGroup);
|
|
}
|
|
|
|
// --- Helper: build a switch-style toggle button ---
|
|
function addSwitchOption(show, prefKey, icon, label, applyFn) {
|
|
if (!show) return;
|
|
var group = doc.createElement("div");
|
|
group.className = "a11y-group";
|
|
|
|
var btn = doc.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "a11y-btn a11y-btn-wide";
|
|
btn.setAttribute("role", "switch");
|
|
btn.setAttribute("aria-checked", prefs[prefKey] ? "true" : "false");
|
|
btn.setAttribute("aria-label", label);
|
|
btn.appendChild(faIcon(icon));
|
|
btn.appendChild(doc.createTextNode(" " + label));
|
|
|
|
if (prefs[prefKey]) btn.classList.add("active");
|
|
|
|
btn.addEventListener("click", function () {
|
|
prefs[prefKey] = !prefs[prefKey];
|
|
applyFn(prefs[prefKey]);
|
|
btn.setAttribute("aria-checked", prefs[prefKey] ? "true" : "false");
|
|
btn.classList.toggle("active", prefs[prefKey]);
|
|
saveA11yPrefs(prefs);
|
|
});
|
|
|
|
group.appendChild(btn);
|
|
panel.appendChild(group);
|
|
}
|
|
|
|
// --- Toggle options ---
|
|
addSwitchOption(showInvert, "inverted", "fa-solid fa-circle-half-stroke", "Invert colors", applyInversion);
|
|
addSwitchOption(showContrast, "contrast", "fa-solid fa-adjust", "High contrast", applyContrast);
|
|
addSwitchOption(showLinks, "links", "fa-solid fa-link", "Highlight links", applyLinks);
|
|
addSwitchOption(showFont, "font", "fa-solid fa-font", "Readable font", applyFont);
|
|
addSwitchOption(showAnimations, "paused", "fa-solid fa-pause", "Pause animations", applyPaused);
|
|
|
|
// Apply saved preferences on load
|
|
if (prefs.fontStep !== defaultStep) applyFontSize(prefs.fontStep);
|
|
if (prefs.inverted) applyInversion(true);
|
|
if (prefs.contrast) applyContrast(true);
|
|
if (prefs.links) applyLinks(true);
|
|
if (prefs.font) applyFont(true);
|
|
if (prefs.paused) applyPaused(true);
|
|
|
|
// If theme FAB is present, mount a11y toggle inside it; otherwise standalone
|
|
var fabSlot = doc.getElementById("mokoA11ySlot");
|
|
if (fabSlot) {
|
|
toggle.className = "a11y-toggle a11y-toggle-inline";
|
|
fabSlot.appendChild(toggle);
|
|
toolbar.className = "a11y-toolbar-floating";
|
|
toolbar.appendChild(panel);
|
|
body.appendChild(toolbar);
|
|
// Position panel near the FAB
|
|
toggle.addEventListener("click", function () {
|
|
var isOpen = !panel.hidden;
|
|
panel.hidden = isOpen;
|
|
toggle.setAttribute("aria-expanded", isOpen ? "false" : "true");
|
|
toggle.classList.toggle("active", !isOpen);
|
|
if (!isOpen) {
|
|
var rect = toggle.getBoundingClientRect();
|
|
toolbar.style.position = "fixed";
|
|
toolbar.style.bottom = (win.innerHeight - rect.top + 8) + "px";
|
|
toolbar.style.right = (win.innerWidth - rect.right) + "px";
|
|
toolbar.style.zIndex = "1202";
|
|
}
|
|
});
|
|
// Close on outside click for inline mode
|
|
doc.addEventListener("click", function (e) {
|
|
if (!toggle.contains(e.target) && !toolbar.contains(e.target) && !panel.hidden) {
|
|
panel.hidden = true;
|
|
toggle.setAttribute("aria-expanded", "false");
|
|
toggle.classList.remove("active");
|
|
}
|
|
});
|
|
} else {
|
|
// Standalone mode — toggle and close handlers
|
|
toggle.addEventListener("click", function () {
|
|
var isOpen = !panel.hidden;
|
|
panel.hidden = isOpen;
|
|
toggle.setAttribute("aria-expanded", isOpen ? "false" : "true");
|
|
toggle.classList.toggle("active", !isOpen);
|
|
});
|
|
doc.addEventListener("click", function (e) {
|
|
if (!toolbar.contains(e.target) && !panel.hidden) {
|
|
panel.hidden = true;
|
|
toggle.setAttribute("aria-expanded", "false");
|
|
toggle.classList.remove("active");
|
|
}
|
|
});
|
|
toolbar.appendChild(toggle);
|
|
toolbar.appendChild(panel);
|
|
body.appendChild(toolbar);
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// TEMPLATE UTILITIES
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Utility: smooth scroll to top
|
|
*/
|
|
function backToTop() {
|
|
win.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
|
|
/**
|
|
* Utility: toggle body class on scroll for sticky header styling
|
|
*/
|
|
function handleScroll() {
|
|
if (win.scrollY > 50) {
|
|
doc.body.classList.add("scrolled");
|
|
} else {
|
|
doc.body.classList.remove("scrolled");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize offcanvas drawer buttons for left/right drawers.
|
|
* Bootstrap handles drawers automatically via data-bs-toggle="offcanvas"
|
|
* This function is kept for backwards compatibility but only runs if drawers exist.
|
|
*/
|
|
function initDrawers() {
|
|
// Check if any drawer buttons exist before initializing
|
|
var hasDrawers = doc.querySelector(".drawer-toggle-left") || doc.querySelector(".drawer-toggle-right");
|
|
if (!hasDrawers) {
|
|
return; // No drawers, skip initialization
|
|
}
|
|
|
|
// Bootstrap 5 handles offcanvas automatically via data-bs-toggle attribute
|
|
// No manual initialization needed if Bootstrap is loaded correctly
|
|
// The buttons already have data-bs-toggle="offcanvas" and data-bs-target="#drawer-*"
|
|
}
|
|
|
|
/**
|
|
* Initialize back-to-top link if present
|
|
*/
|
|
function initBackTop() {
|
|
var backTop = doc.getElementById("back-top");
|
|
if (backTop) {
|
|
backTop.addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
backToTop();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize theme based on stored preference or system setting
|
|
*/
|
|
function initTheme() {
|
|
var stored = getStored();
|
|
var theme = stored ? stored : systemTheme();
|
|
applyTheme(theme);
|
|
|
|
// Listen for system changes only if Auto mode (no stored)
|
|
var onChange = function () {
|
|
if (!getStored()) {
|
|
applyTheme(systemTheme());
|
|
}
|
|
};
|
|
if (typeof mql.addEventListener === "function") {
|
|
mql.addEventListener("change", onChange);
|
|
} else if (typeof mql.addListener === "function") {
|
|
mql.addListener(onChange);
|
|
}
|
|
|
|
// Hook toggle UI if present (for inline switch, not FAB)
|
|
var switchEl = doc.getElementById("themeSwitch");
|
|
var autoBtn = doc.getElementById("themeAuto");
|
|
|
|
if (switchEl) {
|
|
switchEl.checked = (theme === "dark");
|
|
switchEl.addEventListener("change", function () {
|
|
var choice = switchEl.checked ? "dark" : "light";
|
|
applyTheme(choice);
|
|
});
|
|
}
|
|
|
|
if (autoBtn) {
|
|
autoBtn.addEventListener("click", function () {
|
|
clearStored();
|
|
applyTheme(systemTheme());
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if theme FAB should be enabled based on body data attribute
|
|
*/
|
|
function shouldEnableThemeFab() {
|
|
return doc.body.getAttribute('data-theme-fab-enabled') === '1';
|
|
}
|
|
|
|
/**
|
|
* Convert sidebar card modules into accordion on mobile.
|
|
* On screens <= 991px each card collapses; on desktop they revert.
|
|
*/
|
|
function initSidebarAccordion() {
|
|
var BREAKPOINT = 992;
|
|
var sidebars = doc.querySelectorAll(".container-sidebar-left, .container-sidebar-right");
|
|
if (!sidebars.length) return;
|
|
|
|
// Build accordion structure once — works at all breakpoints
|
|
sidebars.forEach(function (sidebar, si) {
|
|
var accId = "sidebarAcc-" + si;
|
|
sidebar.setAttribute("id", accId);
|
|
sidebar.classList.add("accordion");
|
|
|
|
var isMobile = win.innerWidth < BREAKPOINT;
|
|
var cards = sidebar.querySelectorAll(":scope > .card");
|
|
|
|
cards.forEach(function (card, ci) {
|
|
var collapseId = accId + "-c" + ci;
|
|
card.classList.add("accordion-item");
|
|
|
|
var header = card.querySelector(".card-header");
|
|
var body = card.querySelector(".card-body");
|
|
if (!header || !body) return;
|
|
|
|
// Turn header into accordion button
|
|
header.classList.add("accordion-header");
|
|
var btn = doc.createElement("button");
|
|
btn.className = isMobile ? "accordion-button collapsed" : "accordion-button";
|
|
btn.type = "button";
|
|
btn.setAttribute("data-bs-toggle", "collapse");
|
|
btn.setAttribute("data-bs-target", "#" + collapseId);
|
|
btn.setAttribute("aria-expanded", isMobile ? "false" : "true");
|
|
btn.setAttribute("aria-controls", collapseId);
|
|
btn.textContent = header.textContent;
|
|
header.textContent = "";
|
|
header.appendChild(btn);
|
|
|
|
// Wrap body in collapse
|
|
var wrapper = doc.createElement("div");
|
|
wrapper.id = collapseId;
|
|
wrapper.className = isMobile ? "accordion-collapse collapse" : "accordion-collapse collapse show";
|
|
wrapper.setAttribute("data-bs-parent", "#" + accId);
|
|
card.insertBefore(wrapper, body);
|
|
wrapper.appendChild(body);
|
|
body.classList.add("accordion-body");
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggle search on mobile via .show class
|
|
*/
|
|
function initSearchToggle() {
|
|
var btn = doc.querySelector(".search-toggler");
|
|
var target = doc.getElementById("headerSearchCollapse");
|
|
if (!btn || !target) return;
|
|
|
|
btn.addEventListener("click", function () {
|
|
var isOpen = target.classList.toggle("show");
|
|
btn.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
|
});
|
|
}
|
|
|
|
// ========================================================================
|
|
// CSS VARIABLE CLICK-TO-COPY
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Inject toast + variable-chip styles once.
|
|
*/
|
|
function injectVarCopyStyles() {
|
|
if (doc.getElementById("moko-var-copy-styles")) return;
|
|
var style = doc.createElement("style");
|
|
style.id = "moko-var-copy-styles";
|
|
style.textContent =
|
|
".moko-var-chip{cursor:pointer;font-family:var(--font-monospace,monospace);font-size:.875em;" +
|
|
"background:var(--secondary-bg,#151b22);color:var(--link-color,#8ab4f8);" +
|
|
"border:1px solid var(--border-color,#2b323b);border-radius:.25rem;padding:.1em .4em;" +
|
|
"transition:background .15s,border-color .15s;white-space:nowrap;display:inline}" +
|
|
".moko-var-chip:hover{background:var(--color-primary,#112855);color:#fff;border-color:var(--color-primary,#112855)}" +
|
|
".moko-toast{position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%);z-index:10000;" +
|
|
"background:var(--color-primary,#112855);color:#fff;padding:.6rem 1.25rem;" +
|
|
"border-radius:.375rem;font-size:.875rem;box-shadow:0 4px 12px rgba(0,0,0,.25);" +
|
|
"opacity:0;transition:opacity .2s;pointer-events:none}" +
|
|
".moko-toast--show{opacity:1}";
|
|
doc.head.appendChild(style);
|
|
}
|
|
|
|
/**
|
|
* Show a brief "Copied to clipboard" toast.
|
|
* @param {string} text - The variable name that was copied
|
|
*/
|
|
function showCopyToast(text) {
|
|
var existing = doc.querySelector(".moko-toast");
|
|
if (existing) existing.remove();
|
|
|
|
var toast = doc.createElement("div");
|
|
toast.className = "moko-toast";
|
|
toast.textContent = "Copied to clipboard: " + text;
|
|
doc.body.appendChild(toast);
|
|
|
|
// Trigger reflow then show
|
|
void toast.offsetWidth;
|
|
toast.classList.add("moko-toast--show");
|
|
|
|
setTimeout(function () {
|
|
toast.classList.remove("moko-toast--show");
|
|
setTimeout(function () { toast.remove(); }, 200);
|
|
}, 2000);
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard and show toast.
|
|
* @param {string} text
|
|
*/
|
|
function copyVariable(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
showCopyToast(text);
|
|
});
|
|
} else {
|
|
// Fallback for older browsers using deprecated API
|
|
var ta = doc.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.cssText = "position:fixed;left:-9999px";
|
|
doc.body.appendChild(ta);
|
|
ta.select();
|
|
try { doc.execCommand("copy"); } catch (e) { /* noop */ }
|
|
ta.remove();
|
|
showCopyToast(text);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan text nodes for CSS variable patterns (--variable-name) and wrap
|
|
* each match in a clickable chip that copies the variable to clipboard.
|
|
*/
|
|
function initVarCopy() {
|
|
injectVarCopyStyles();
|
|
|
|
// Pattern: --[a-zA-Z] followed by word/hyphen chars
|
|
var varPattern = /--[a-zA-Z][\w-]*/g;
|
|
|
|
// Elements to skip (inputs, scripts, styles, already-processed, code editors)
|
|
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEXTAREA: 1, INPUT: 1, SELECT: 1, NOSCRIPT: 1 };
|
|
|
|
var walker = doc.createTreeWalker(
|
|
doc.body,
|
|
NodeFilter.SHOW_TEXT,
|
|
{
|
|
acceptNode: function (node) {
|
|
if (SKIP_TAGS[node.parentNode.tagName]) return NodeFilter.FILTER_REJECT;
|
|
if (node.parentNode.classList && node.parentNode.classList.contains("moko-var-chip")) return NodeFilter.FILTER_REJECT;
|
|
if (!varPattern.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
|
|
varPattern.lastIndex = 0;
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
}
|
|
);
|
|
|
|
var textNodes = [];
|
|
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
|
|
|
textNodes.forEach(function (node) {
|
|
var text = node.nodeValue;
|
|
var frag = doc.createDocumentFragment();
|
|
var lastIndex = 0;
|
|
var match;
|
|
|
|
varPattern.lastIndex = 0;
|
|
while ((match = varPattern.exec(text)) !== null) {
|
|
// Text before the match
|
|
if (match.index > lastIndex) {
|
|
frag.appendChild(doc.createTextNode(text.slice(lastIndex, match.index)));
|
|
}
|
|
|
|
// Clickable chip
|
|
var chip = doc.createElement("span");
|
|
chip.className = "moko-var-chip";
|
|
chip.textContent = match[0];
|
|
chip.setAttribute("role", "button");
|
|
chip.setAttribute("tabindex", "0");
|
|
chip.setAttribute("title", "Click to copy " + match[0]);
|
|
chip.addEventListener("click", (function (varName) {
|
|
return function (e) {
|
|
e.preventDefault();
|
|
copyVariable(varName);
|
|
};
|
|
})(match[0]));
|
|
chip.addEventListener("keydown", (function (varName) {
|
|
return function (e) {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
copyVariable(varName);
|
|
}
|
|
};
|
|
})(match[0]));
|
|
frag.appendChild(chip);
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Remaining text after last match
|
|
if (lastIndex < text.length) {
|
|
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
|
}
|
|
|
|
node.parentNode.replaceChild(frag, node);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run all template JS initializations
|
|
*/
|
|
function init() {
|
|
// Initialize theme first
|
|
initTheme();
|
|
|
|
// Build floating theme toggle if enabled
|
|
if (shouldEnableThemeFab()) {
|
|
buildThemeToggle();
|
|
}
|
|
|
|
// Build accessibility toolbar if enabled
|
|
if (doc.body.getAttribute("data-a11y-toolbar") === "1") {
|
|
buildA11yToolbar();
|
|
}
|
|
|
|
// Sticky header behavior
|
|
handleScroll();
|
|
win.addEventListener("scroll", handleScroll);
|
|
|
|
// Init features
|
|
initDrawers();
|
|
initBackTop();
|
|
initSearchToggle();
|
|
initSidebarAccordion();
|
|
initVarCopy();
|
|
}
|
|
|
|
if (doc.readyState === "loading") {
|
|
doc.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})(window, document);
|