From d6620fa712cedb1a8918c3e292ec02239d0c726a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:42:58 -0400 Subject: [PATCH] Add theme controller to synchronize theme toggles --- js/index.js | 9 ++ js/themeController.js | 272 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 js/themeController.js diff --git a/js/index.js b/js/index.js index 3a9d183a..21f21a3b 100644 --- a/js/index.js +++ b/js/index.js @@ -12,6 +12,10 @@ import { applyDesignSystemAttributes, subscribeToDesignSystemChanges, } from "./designSystem.js"; +import { + initThemeController, + refreshThemeControls, +} from "./themeController.js"; import { prepareStaticModal, openStaticModal, @@ -21,8 +25,10 @@ import { validateInstanceConfig(); applyDesignSystemAttributes(); +initThemeController(); subscribeToDesignSystemChanges(() => { applyDesignSystemAttributes(); + refreshThemeControls(document); }); let application = null; @@ -148,6 +154,7 @@ async function loadModal(url) { const modalContainer = document.getElementById("modalContainer"); if (modalContainer) { applyDesignSystemAttributes(modalContainer); + refreshThemeControls(modalContainer); } console.log(url, "loaded"); } catch (err) { @@ -163,6 +170,7 @@ async function loadSidebar(url, containerId) { if (container) { container.innerHTML = html; applyDesignSystemAttributes(container); + refreshThemeControls(container); } console.log(url, "loaded into", containerId); } catch (err) { @@ -178,6 +186,7 @@ async function loadDisclaimer(url, containerId) { if (container) { container.insertAdjacentHTML("beforeend", html); applyDesignSystemAttributes(container); + refreshThemeControls(container); } console.log(url, "disclaimer loaded into", containerId); } catch (err) { diff --git a/js/themeController.js b/js/themeController.js new file mode 100644 index 00000000..1c32332c --- /dev/null +++ b/js/themeController.js @@ -0,0 +1,272 @@ +const STORAGE_KEY = "bitvid:theme"; +const FALLBACK_THEME = "dark"; +const VALID_THEMES = new Set(["light", "dark"]); +const FALLBACK_META_COLORS = { + dark: "#0f172a", + light: "#f8fafc", +}; +const TOGGLE_SELECTORS = [ + "[data-theme-toggle]", + "[data-action=\"toggle-theme\"]", + ".js-theme-toggle", + "#themeToggle", +]; + +const boundToggles = new WeakSet(); +const registeredToggles = new Set(); +let currentTheme = null; +let storageListenerBound = false; + +const isBrowser = () => + typeof window !== "undefined" && typeof document !== "undefined"; + +const normalizeTheme = (value) => { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "default" || normalized === "") { + return FALLBACK_THEME; + } + if (VALID_THEMES.has(normalized)) { + return normalized; + } + return null; +}; + +const readStoredTheme = () => { + if (!isBrowser()) { + return null; + } + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return normalizeTheme(stored); + } catch (error) { + console.warn("Unable to read stored theme preference:", error); + return null; + } +}; + +const persistTheme = (theme) => { + if (!isBrowser()) { + return; + } + try { + window.localStorage.setItem(STORAGE_KEY, theme); + } catch (error) { + console.warn("Unable to persist theme preference:", error); + } +}; + +const getRootElement = () => { + if (!isBrowser()) { + return null; + } + const { documentElement } = document; + if (documentElement instanceof HTMLElement) { + return documentElement; + } + return null; +}; + +const updateThemeColorMeta = (theme) => { + if (!isBrowser()) { + return; + } + + const themeMeta = document.querySelector('meta[name="theme-color"]'); + if (!themeMeta) { + return; + } + + const root = getRootElement(); + if (!root) { + return; + } + + let computedColor = ""; + try { + if (typeof window.getComputedStyle === "function") { + computedColor = getComputedStyle(root) + .getPropertyValue("--color-page") + .trim(); + } + } catch (error) { + console.warn("Unable to compute theme color token:", error); + } + + const fallback = FALLBACK_META_COLORS[theme] || FALLBACK_META_COLORS[FALLBACK_THEME]; + const nextColor = computedColor || fallback; + if (nextColor) { + themeMeta.setAttribute("content", nextColor); + } +}; + +const syncTogglePresentation = (toggle, theme) => { + if (!(toggle instanceof HTMLElement)) { + return; + } + toggle.dataset.themeState = theme; + toggle.setAttribute("aria-pressed", theme === "dark" ? "true" : "false"); + + const actionLabel = + theme === "dark" ? "Switch to light theme" : "Switch to dark theme"; + toggle.setAttribute("aria-label", actionLabel); + toggle.setAttribute("title", actionLabel); + + const labelTarget = + toggle.querySelector("[data-theme-toggle-label]") || toggle.querySelector(".sr-only"); + if (labelTarget) { + labelTarget.textContent = actionLabel; + } + + const iconTarget = + toggle.querySelector("[data-theme-toggle-icon]") || toggle.querySelector('[aria-hidden="true"]'); + if (iconTarget) { + const iconForLight = toggle.dataset.iconLight || "☀️"; + const iconForDark = toggle.dataset.iconDark || "🌙"; + iconTarget.textContent = theme === "dark" ? iconForLight : iconForDark; + } +}; + +const cleanupRegisteredToggles = () => { + if (!isBrowser()) { + registeredToggles.clear(); + return; + } + registeredToggles.forEach((toggle) => { + if (!(toggle instanceof HTMLElement) || !document.contains(toggle)) { + registeredToggles.delete(toggle); + } + }); +}; + +const syncRegisteredToggles = (theme) => { + cleanupRegisteredToggles(); + registeredToggles.forEach((toggle) => { + syncTogglePresentation(toggle, theme); + }); +}; + +const bindToggle = (toggle) => { + if (!(toggle instanceof HTMLElement) || boundToggles.has(toggle)) { + return; + } + + toggle.addEventListener("click", (event) => { + event.preventDefault(); + const nextTheme = currentTheme === "dark" ? "light" : "dark"; + setTheme(nextTheme); + }); + + toggle.dataset.themeToggleBound = "true"; + boundToggles.add(toggle); +}; + +const collectToggleElements = (root = document) => { + if (!isBrowser()) { + return []; + } + + const elements = new Set(); + const scope = root && typeof root.querySelectorAll === "function" ? root : document; + + TOGGLE_SELECTORS.forEach((selector) => { + try { + scope.querySelectorAll(selector).forEach((element) => { + if (element instanceof HTMLElement) { + elements.add(element); + } + }); + } catch (error) { + console.warn(`Invalid selector for theme toggle: ${selector}`, error); + } + }); + + if (root instanceof HTMLElement) { + TOGGLE_SELECTORS.forEach((selector) => { + if (root.matches(selector)) { + elements.add(root); + } + }); + } + + return Array.from(elements); +}; + +const applyTheme = (theme, { persist = true } = {}) => { + const normalized = normalizeTheme(theme) || FALLBACK_THEME; + currentTheme = normalized; + + const root = getRootElement(); + if (root) { + root.dataset.theme = normalized; + } + + if (persist) { + persistTheme(normalized); + } + + updateThemeColorMeta(normalized); + syncRegisteredToggles(normalized); + return normalized; +}; + +export const setTheme = (theme) => { + if (!isBrowser()) { + currentTheme = normalizeTheme(theme) || FALLBACK_THEME; + return currentTheme; + } + + return applyTheme(theme, { persist: true }); +}; + +export const refreshThemeControls = (root = document) => { + if (!isBrowser()) { + return; + } + + const toggles = collectToggleElements(root); + toggles.forEach((toggle) => { + registeredToggles.add(toggle); + bindToggle(toggle); + syncTogglePresentation(toggle, currentTheme || FALLBACK_THEME); + }); +}; + +const resolveInitialTheme = () => { + if (!isBrowser()) { + return FALLBACK_THEME; + } + const root = getRootElement(); + const datasetTheme = root ? normalizeTheme(root.dataset.theme) : null; + return datasetTheme || readStoredTheme() || FALLBACK_THEME; +}; + +const handleStorageEvent = (event) => { + if (!event || event.key !== STORAGE_KEY) { + return; + } + const nextTheme = normalizeTheme(event.newValue) || FALLBACK_THEME; + if (nextTheme === currentTheme) { + return; + } + applyTheme(nextTheme, { persist: false }); +}; + +export const initThemeController = () => { + if (!isBrowser()) { + currentTheme = FALLBACK_THEME; + return; + } + + if (!storageListenerBound) { + window.addEventListener("storage", handleStorageEvent); + storageListenerBound = true; + } + + const initialTheme = resolveInitialTheme(); + applyTheme(initialTheme, { persist: true }); + refreshThemeControls(document); +}; +