/**
* @file ThemeManager.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` в соответствующее значение.
* @typedef ManagerState
*
* @property {DOMTokenList} clist: список классов ::root
* @property {HTMLElement} element: ::root, тег html
* @property {number} idx: индекс текущей темы
* @property {boolean} isDark: флаг тёмной системной темы
* @property {MediaQueryList} _schemeQuery: Медиа слушатель системной темы
* @property {boolean} _schemeListener: Обработчик изменений системной темы
*/
/**
* @typedef 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: Фактические размеры меню при применении темы в пикселях
*/
/**
* @typedef ThemeMenuSizes - Фактические размеры меню при использовании темы
* @property {string} w: ширина меню в пикселях
* @property {string} h: высота меню в пикселях
*/
/**
* @typedef MenuState
* Состояние меню
*
* @property {DOMTokenList} clist: список классов управляемого элемента classList
* @property {number} idx: индекс текущей темы меню
* @property {number} tmh: таймер сокрытия меню
* @property {number} tms: таймер показа меню
*/
/**
* Меню выбора тем
* @typedef ThemeMenu
* @property {ThemeManager} _tm - Ссылка на класс
* @property {MenuState} _stt - Состояние меню
* @property {[Theme]} _t - Ссылка на массив тем
* @property {()=>void} _tv - Переключает видимость
* @property {HTMLDivElement} div - Управляемый div элемент
* @property {CSSStyleDeclaration} style - Ссылка на стиль управляемого элемента
* @property {()=>void} appendChild - div.appendChild
* @property {(idx: number)=>void} apply - Превью темы
* @property {()=>void} hide - Скрыть меню
* @property {()=>void} show - Показать меню
*/
/**
* Глобальное состояние модуля
*
* @static
* @type {ManagerState}
*/
static #$ = (() => {
let e = {
clist: document.documentElement.classList,
element: document.documentElement,
idx: 0,
isDark: q.matches,
_schemeQuery: window.matchMedia('(prefers-color-scheme: dark)'),
_schemeListener: null
};
e._schemeQuery.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} defautlIcon: Иконка темы по умолчанию
* @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() {
const GLOBAL_PREFIX = 'flxd-tm';
const VISUALY_HIDDEN = 'visualy-hidden';
let c = {
/* Основные настройки модуля */
globalPrefix: 'flxd-tm', // глобальный префикс
storageKey: 'id', // базовое имя ключа в хранилище
toggle: {
id: 'button-toggle-color-scheme', // идентификатор кнопки переключения тем
liveId: 'button-tiggle-live', // идентификатор aria-live блока
liveClass: 'visualy-hidden',
},
theme: {
prefix: 'color-scheme', // корень названия классов темы
},
menu: {
attr: {
'id': 'menu',
'role': 'menu',
},
row: {
attr: {
class: 'entry',
role: 'menuitem',
},
icon: {
attr: {
class: 'icon',
'aria-hidden': 'true'
},
},
},
tmtAppear: 2000,
tmtDisappear: 300,
rowClass: 'row',
iconClass: 'icon',
descClass: 'desc',
ariaLabel: 'theme menu',
},
menuId: 'menu', // базовый идентификатор меню
menuRowClass: 'row', // базовый класс строки меню
menuImageClass: 'image', // базовый класс картинки темы в меню
menuDescriptionClass: 'desc', // базовый класс описательного имени темы в меню
menuAreaLabel: 'theme menu', // описание элемента меню
menuRowAreaLabel: 'row', // описание элемента строки меню
menuIconAreaLabel: 'icon', // описание элемента иконки темы
menuDescAreaLabel: 'description', // описание элемента описания темы
defautlIcon:'',
};
/* Собираем строки */
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}`;
/* Настраиваем ARIA */
c.menuRowAreaLabel = `${c.menuAreaLabel} ${c.menuRowAreaLabel}`;
c.menuIconAreaLabel = `${c.menuRowAreaLabel} ${c.menuIconAreaLabel}`;
c.menuDescAreaLabel = `${c.menuRowAreaLabel} ${c.menuDescAreaLabel}`;
/* Кнопка смены темы оформления */
/* инициализация имени кнопки переключения тем */
c.buttonToggleId = c.globalPrefix + c.buttonToggleId;
/* инициализация префикса классов в селекторе CSS */
/* Тема оформления */
c.colorSchemePrefix = c.globalPrefix + c.colorSchemePrefix + '-';
/* Возвращаем полностью инициализированный объект с динамически скомпонованными строками */
return c;
})();
/**
* Массив тем
*
* @static
* @type {[Theme]}
*/
static #THEMES = [];
/**
* Сохраняет индекс текущей темы в хранилище
*
* @static
* @type {(idx: number) => void}
*/
static #save = (() => {
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 {{ idx: number, clist: DOMTokenList }?} e - JS-объект со следующими обязательными полями: { idx: number, clist: DOMTokenList, }
* @param {[Theme]?} t - Ссылка на массив тем
*/
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);
}
/**
* Меню выбора тем
*
* @static
* @type {ThemeMenu}
*/
static #MENU = (() => {
let menu = {
/* private: */
_сonfig: {
timeout: {
apear: 2000, // таймаут появления меню
disapear: 300, // таймаут исчезновения меню
},
select: {
id: 'menu', // базовый идентификатор меню
row: 'row', // базовый класс строки меню
image: 'image', // базовый класс картинки темы в меню
desc: 'desc', // базовый класс описательного имени темы в меню
},
aria: {
menu: 'меню выбора тем',// описание элемента меню
row: 'тема', // описание элемента строки меню
},
},
/* Свойства */
_tm: this, // ссылка на основной класс1
/* СОстояние */
_stt: {
clist: null, // classList of element
idx: 0, // текущая тема меню
tmh: null, // таймер сокрытия меню
tms: null, // таймер показа меню
},
_t: this.#THEMES, // ссылка на массив тем
/* Методы */
_tv: null, // переключить видимость
/* public: */
/* Методы */
appendChild: null, // `div.appendChild`
apply: null,
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;
})();
/**
* Создаёт и инициализирует новый объект темы
* @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: Пустое или не строковое значение 'name': ${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: 0, // Индекс темы в массиве тем
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.defautlIcon;
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 #findTheme(name) {
let id = -1;
for (let i = 0; i < this.#THEMES.length; i++) {
if (name === this.#THEMES[i].name) {
id = i;
}
}
return id;
}
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);
})();
};
const menu = document.createElement('div');
menu.setAttribute('aria-label', 'меню выбора тем');
menu.setAttribute('role', 'menu');
menu.tabIndex = 0;
for (let n = 1; n < 6; n++) {
let r = document.createElement('div');
let i = document.createElement('div');
let d = document.createElement('div');
r.setAttribute('role', `menuitem`);
r.setAttribute('aria-label', `цветовая схема ` + n);
i.setAttribute('aria-hidden', 'true');
d.setAttribute('aria-hidden', 'true');
r.appendChild(i);
r.appendChild(d);
menu.appendChild(r);
}