/**
* @file felexdev-theme-manager.js
* @version 1.0.0 dev-in-progress (2026-06-09)
* @author felex67 (admin@felexdev.ru)
* @license Apache2.0
*
* @brief ThemeManager - модуль-переключатель тем
* @details Архитектура singleton-модуля на базе самоинициализирующегося статического класса
*/
class ThemeManager {
/**
* Состояние модуля:
* При инициализации данного объекта создаётся обработчик события изменения системной темы
* автоматически выставляющий поле `.isDark` в соответствующее значение.
*
* @property {DOMTokenList} clist: список классов ::root
* @property {HTMLElement} element: ::root, тег html
* @property {number} idx: индекс текущей темы
* @property {boolean} isDark: флаг тёмной системной темы
* @property {MediaQueryList} _schemeQuery: Медиа слушатель системной темы
* @property {boolean} _schemeListener: Обработчик изменений системной темы
*/
static #$ = (() => {
let q = window.matchMedia('(prefers-color-scheme: dark)');
let e = {
clist: document.documentElement.classList,
element: document.documentElement,
idx: 0,
isDark: q.matches,
_schemeQuery: q,
_schemeListener: null
};
q.addEventListener('change', e._schemeListener = (function(ev) { this.isDark = ev.matches; }).bind(e));
return e;
})();
/**
* Конфигурация/настройки модуля
* @property {string} buttonToggleId: Идентификатор кнопки-переключателя тем. 'button-toggle'
* @property {string} colorSchemePrefix: Префикс селектора CSS. 'color-scheme'
* @property {string} default.Image: Иконка темы по умолчанию
* @property {string} globalPrefix: Глобальный префикс. 'theme-'
* @property {number} menuAppearTimeout: таймаут до появления меню в милисекундах (2000),
* @property {number} menuDisappearTimeout: таймауйт исчезновения меню в милисекудах (300),
* @property {string} menuId: Идентификатор меню. 'theme-menu'
* @property {string} menuRowClass: Класс элементов-строк меню 'theme-menu-row',
* @property {string} menuImageClass: Класс иконки темы 'theme-menu-image',
* @property {string} menuDescriptionClass: Класс описателя темы 'theme-menu-desc',
* @property {string} storageKey: Ключ в хранилище 'theme-id',
*/
static #CONFIG = (function() { let c = {
/* Основные настройки модуля */
buttonToggleId: 'button-toggle', // суффикс кнопки переключения тем
colorSchemePrefix: 'color-scheme', // корень названия классов темы
globalPrefix: 'theme', // глобальный префикс
/* Настройки меню */
menuAppearTimeout: 2000, // таймаут появления меню
menuDisappearTimeout: 300, // таймаут исчезновения меню
menuId: 'menu', // базовый идентификатор меню
menuRowClass: 'row', // базовый класс строки меню
menuImageClass: 'image', // базовый класс картинки темы в меню
menuDescriptionClass: 'desc', // базовый класс описательного имени темы в меню
menuAreaLable: 'menu-select-theme', // описание элемента меню
menuRowAreaLable: 'row', // описание элемента строки меню
menuIconAreaLable: 'icon', // описание элемента иконки темы
menuDescAreaLable: 'description', // описание элемента описания темы
defaultImage:'',
storageKey: 'id', // базовое имя ключа в хранилище
};
/* Собираем строки */
c.globalPrefix += '-';
/* С этого момента globalPrefix = '${c.globalPrefix}-' */
c.storageKey = c.globalPrefix + c.storageKey;
/* Логика инициализации */
/* инициализация классов меню */
c.menuId = c.globalPrefix + c.menuId;
c.menuRowClass = `${c.menuId}-${c.menuRowClass}`;
c.menuImageClass = `${c.menuId}-${c.menuImageClass}`;
c.menuDescriptionClass = `${c.menuId}-${c.menuDescriptionClass}`;
/* инициализация имени кнопки переключения тем */
c.buttonToggleId = c.globalPrefix + c.buttonToggleId;
/* инициализация префикса классов в селекторе CSS */
c.colorSchemePrefix = c.globalPrefix + c.colorSchemePrefix + '-';
/* Возвращаем полностью инициализированный объект с динамически скомпонованными строками */
return c;
})();
static #THEMES = [];
static #save = ((idx) => {
let save = (idx) => {
console.error(this.#CONFIG.globalPrefix + '#save(): It is unavailable to save a theme, the storages are unreachable.');
}
try {
/* Пробуем localStorage */
this.#$.current = parseInt(localStorage.getItem(this.#CONFIG.storageKey)) || 0;
save = (idx) => { localStorage.setItem(this.#CONFIG.storageKey, idx); };
}
catch(e) {
console.log(this.#CONFIG.globalPrefix + '#save-IIFE(): localStorage is unreachable, trying to use cookie.')
try {
let cookie = document.cookie.match(`${this.#CONFIG.storageKey}\\s*=\\s*(\\d+)`);
this.#$.current = cookie ? parseInt(cookie[1]) || 0 : 0;
save = (idx) => { document.cookie = `${this.#CONFIG.storageKey}=${idx}; Max-Age=${31536000}; Path=/`; };
}
catch (e) {
console.log(`${this.#CONFIG.globalPrefix}#save-IIFE: All storages are unreachable('localStorage' & 'document.cookie')`);
}
}
/* Мы не дублируем, а просто навешиваем класс на `::root` ели тема не 'auto'
* Это самый быстрый способ применить тему.
* Не знаю как Вам, а мне - всё понятно =D */
if (this.#$.current < this.#THEMES.length)
this.#$.current > 0
? this.#$.element.classList.add(this.#THEMES[this.#$.current].class)
: this.#$.current
&& (console.error(`${this.#CONFIG.globalPrefix}#save-IIFE: Negative theme index!!!: '${this.#$.current}'. Fallback to 'auto'`)
|| (this.#$.current = 0));
else
console.log(`${this.#CONFIG.globalPrefix}#save-IIFE: Theme index out of range: '${this.#$.current}'. Fallback to 'auto'`);
return save;
})();
/**
* Добавляет/заменяет/удаляет класс старой темы на новый
* @param {number} idx: индекс новой темы
* @param {JSObject} e: JS-объект со следующими обязательными полями: { idx: number, clist: DOMTokenList, }
*/
static #set(idx, e = this.#$, t = this.#THEMES) {
if (idx == e.idx)
return;
if (idx && e.idx)
e.clist.replace(t[e.idx].class, t[idx].class);
else if (idx)
e.clist.add(t[idx].class);
else
e.clist.remove(t[e.idx].class);
this.#save(e.idx = idx);
}
/**
*
*/
/**
* Меню выбора тем
* @property {class} _tm: ссылка на класс
* @property {JSObject} _stt:
* @property {DOMTokenList} clist: список классов управляемого элемента classList
* @property {number} idx: индекс текущей темы меню
* @property {number} tmh: таймер сокрытия меню
* @property {number} tms: таймер показа меню
* @property {JSObject} _t: объект темы
* @property {DOMTokenList} clist: список классов управляемого элемента classList
* @property {number} idx: индекс текущей темы меню
* @property {number} tmh: таймер сокрытия меню
* @property {number} tms: таймер показа меню
*
*/
static #MENU = (() => {
let menu = {
/* private: */
/* Свойства */
_tm: this, // ссылка на основной класс
/**
* {JSObject} `static #MENU._stt`
* Внутреннее состояние меню
* @property {DOMTokenList} clist: список классов управляемого элемента classList
* @property {number} idx: индекс текущей темы меню
* @property {number} tmh: таймер сокрытия меню
* @property {number} tms: таймер показа меню
*/
_stt: {
clist: null, // classList of element
idx: 0, // текущая тема меню
tmh: null, // таймер сокрытия меню
tms: null, // таймер показа меню
},
_t: this.#THEMES, // ссылка на массив тем
/* Методы */
_tv: null, // переключить видимость
/* public: */
/* Методы */
appendChild: null, // `div.appendChild`
div: document.createElement('div'), // ссылка на контролируемый DOM-элемент
hide: null, // спрятать меню
show: null, // показать меню
style: null, // `div.style`
};
/* Инициализируем div-элемент */
menu.div.id = this.#CONFIG.menuId; // устанавливаем id
menu._stt.clist = menu.div.classList; // привязываем список классов
/* Пробрасываем часто нужные методы и поля div-элемента */
menu.appendChild = menu.div.appendChild.bind(menu._state.element);
menu.style = menu._state.element.style;
/* Внутренняя логика меню */
/* Спрятать меню */
menu.hide = (function() {
this.style.width = this.style.height = '0px';
}).bind(menu);
/* Показать меню */
menu.show = (function() {
let s = this._themes[this._manager.#current].sizes;
this.style.visibility = 'visible';
this.style.width = s.w;
this.style.height = s.h;
}).bind(menu);
/* Применяем тему ко всему документу */
menu.apply = (function(idx) {
this._manager.#set(idx, this._state);
this.hide();
/* Это необходимо для того чтобы меню подхватило стиль всего документа */
this._remove(this.themes[this._current].name);
this._current = 0;
}).bind(menu);
return menu;
})();
/**
* @typedef {Object} ThemeMenuSizes - Фактические размеры меню при использовании темы
* @property {string} w: ширина меню в пикселях
* @property {string} h: высота меню в пикселях
*/
/**
* @typedef {Object} Theme
* @property {string} name: Внутреннее название темы `'auto'`
* @property {string} class: Селектор CSS
* @property {number} idx: Индекс темы в массиве `#THEMES`
* @property {HTMLDivElement} row: Строка меню
* @property {HTMLDivElement} icon: Иконка темы
* @property {HTMLDivElement} desc: Описательное имя темы
* @property {ThemeMenuSizes} sizes: Фактические размеры меню при применении темы в пикселях
*/
/**
* Создаёт и инициализирует новый объект темы
* @param {string} name - внутреннее название темы.
* @param {string?} desc - описательное название темы. `{ desc: desc || name, }`
* @param {string?} svg - векторное изображение темы. `{ svg: svg || this.#CONFIG.defaultImg, }`
*
* @returns {Theme} - инициализированный объект темы
*
* @throws {Error} при `!name.length || typeof(name) != 'string'`
* @throws {Error} если тема с таким 'name' уже существует
*/
static #newTheme = (name, desc = null, svg = null) => {
if ((typeof name) != 'string' || !name) {
throw new Error(`${this.#CONFIG.globalPrefix}#newTheme: empty or invalid theme name: type: ${typeof name}, val: '${name}'`)
}
else if (-1 != this.#THEMES.indexOf(name)) {
throw new Error(`${this.#CONFIG.globalPrefix}#newTheme: theme '${name}' allready exists`);
}
let theme = {
menu: this.#MENU,
name: name, // Имя темы
class: this.#CONFIG.colorSchemePrefix + name, // Класс в CSS-селекторе
idx: idx, // Индекс темы в массиве тем
row: document.createElement('div'), // Элемент строки
icon: document.createElement('div'), // Элемент иконки
desc: document.createElement('div'), // Элемент описания
sizes: { w: '0px', h: '0px' }, // Размеры меню при установке темы(для анимации)
};
/* настраиваем классы элементов */
theme.row.classList.add(this.#CONFIG.menuRowClass);
theme.icon.classList.add(this.#CONFIG.menuImageClass);
theme.desc.classList.add(this.#CONFIG.menuDescriptionClass);
/* заполняем иконку и описание */
theme.icon.innerHTML = svg || this.#CONFIG.defaultImage;
theme.desc.innerText = desc || name;
/* собираем строку меню */
theme.row.appendChild(theme.icon);
theme.row.appendChild(theme.desc);
/* настраиваем обработчики */
theme.row.addEventListener('mouseenter', (function () { this.menu.preview(this.idx); }).bind(theme));
theme.row.addEventListener('focusin', (function () { this.menu.preview(this.idx); }).bind(theme));
this.#MENU.appendChild(theme.row);
theme.idx = this.#THEMES.push(theme) - 1;
return theme;
}
static #initDeafultThemes = (() => {
this.#newTheme(
'auto',
'Системная',
'',
);
this.#newTheme(
'light',
'Светлая',
'',
);
this.#newTheme(
'dark',
'Тёмная',
'',
);
this.#newTheme(
'old-terminal',
'Старый терминал',
'',
);
});
static #_ = (() => {
this.#initDeafultThemes();
console.log(this.#CONFIG);
console.log(this.#MENU);
})();
};