/** * Theme switcher — light/dark toggle and menu with server-side persistence. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; const setTheme = (theme) => { document.documentElement.dataset.theme = theme; }; const isDarkTheme = (theme) => theme && theme.startsWith('dark'); const setIcon = (iconEl, theme) => { if (!iconEl) { return; } iconEl.classList.remove('bi-sun-fill', 'bi-moon-stars-fill'); iconEl.classList.add(isDarkTheme(theme) ? 'bi-moon-stars-fill' : 'bi-sun-fill'); }; const updateTheme = async (source, theme) => { const url = source.dataset.themeUrl; const csrfKey = source.dataset.csrfKey; const csrfToken = source.dataset.csrfToken; if (!url || !csrfKey || !csrfToken) { return true; } const body = new URLSearchParams({ theme, [csrfKey]: csrfToken }); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'X-Requested-With': 'fetch', }, body, }); return response.ok; }; export function initThemeControls(root = document, options = {}) { const host = resolveHost(root); const menuSelector = String(options.menuSelector || '[data-theme-menu]').trim() || '[data-theme-menu]'; const toggleSelector = String(options.toggleSelector || '[data-theme-toggle]').trim() || '[data-theme-toggle]'; const cleanupFns = []; const menu = host.matches?.(menuSelector) ? host : host.querySelector(menuSelector); if (menu instanceof HTMLElement) { const icon = menu.querySelector('[data-theme-icon]'); const optionsEls = Array.from(menu.querySelectorAll('[data-theme-option]')); if (!optionsEls.length) { warnOnce('UI_EL_MISSING', 'Missing theme options', { module: 'theme-toggle' }); } else { let pending = false; const getCurrent = () => document.documentElement.dataset.theme || ''; const setActive = (theme) => { optionsEls.forEach((optionEl) => { const isActive = optionEl.dataset.themeValue === theme; optionEl.classList.toggle('active', isActive); if (isActive) { optionEl.setAttribute('aria-current', 'true'); } else { optionEl.removeAttribute('aria-current'); } }); }; setIcon(icon, getCurrent()); setActive(getCurrent()); optionsEls.forEach((optionEl) => { const onOptionClick = async (event) => { event.preventDefault(); if (pending) { return; } const next = optionEl.dataset.themeValue || ''; if (!next) { return; } const current = getCurrent(); if (next === current) { return; } pending = true; setTheme(next); setIcon(icon, next); setActive(next); const ok = await updateTheme(menu, next); if (!ok) { setTheme(current); setIcon(icon, current); setActive(current); } pending = false; }; optionEl.addEventListener('click', onOptionClick); cleanupFns.push(() => optionEl.removeEventListener('click', onOptionClick)); }); } } const button = host.matches?.(toggleSelector) ? host : host.querySelector(toggleSelector); if (button instanceof HTMLButtonElement) { const icon = button.querySelector('i'); let pending = false; const getCurrent = () => document.documentElement.dataset.theme || ''; setIcon(icon, getCurrent()); const onButtonClick = async () => { if (pending) { return; } const current = getCurrent(); const next = isDarkTheme(current) ? 'light' : 'dark'; pending = true; setTheme(next); setIcon(icon, next); const ok = await updateTheme(button, next); if (!ok) { setTheme(current); setIcon(icon, current); } pending = false; }; button.addEventListener('click', onButtonClick); cleanupFns.push(() => button.removeEventListener('click', onButtonClick)); } const destroy = () => { cleanupFns.forEach((cleanup) => cleanup()); }; return { destroy }; } export const initThemeToggle = initThemeControls;