/**
* Клас NotificationContainer
* Веб-компонент для відображення системних сповіщень.
* Використовує Shadow DOM для інкапсуляції стилів.
*/
class NotificationContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._timeout = 4000;
this._maxVisible = 5;
this._position = 'top-right';
this._mobileBottomEnabled = false;
// *** Вдосконалення: Відстежуємо ноди, що видаляються ***
this._removingNodes = new Set();
this._container = document.createElement('div');
this._container.className = 'app-notification-container';
this.shadowRoot.appendChild(this._container);
this._insertStyles();
// SVG іконки для різних типів сповіщень (залишаємо як є)
this._icons = {
info: ``,
success: ``,
warn: ``,
error: ``
};
}
static get observedAttributes() {
return ['timeout', 'max-visible', 'position', 'mobile-position'];
}
connectedCallback() {
this._updateSettings();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this._updateSettings();
}
}
_updateSettings() {
this._position = this.getAttribute('position') || 'top-right';
this._maxVisible = parseInt(this.getAttribute('max-visible')) || 5;
this._timeout = parseInt(this.getAttribute('timeout')) || 4000;
const mobilePosAttr = this.getAttribute('mobile-position');
this._mobileBottomEnabled = mobilePosAttr === 'bottom' || mobilePosAttr === '';
this._container.setAttribute('data-position', this._position);
this._applyMobileStyles();
}
_applyMobileStyles() {
if (this._mobileBottomEnabled) {
this._container.classList.add('mobile-bottom');
} else {
this._container.classList.remove('mobile-bottom');
}
}
setMobileBottom(enable) {
this._mobileBottomEnabled = !!enable;
this._applyMobileStyles();
}
/**
* Показує нове сповіщення.
*/
show(message, options = {}) {
const {
type = 'info',
timeout = this._timeout,
title,
onClick,
lock
} = options;
const content = typeof message === 'string'
? { title: title || '', text: message }
: message;
// **Оновлена логіка обмеження кількості:**
// Визначаємо кількість "видимих" нод (які не перебувають у процесі видалення)
const totalChildren = this._container.children.length;
const visibleNodesCount = totalChildren - this._removingNodes.size;
while (visibleNodesCount >= this._maxVisible) {
// Шукаємо найстаріший елемент, який ще НЕ видаляється
const first = Array.from(this._container.children).find(n => !this._removingNodes.has(n));
if (first) {
this._removeNode(first);
} else {
// Якщо всі елементи в процесі видалення, виходимо
break;
}
}
// Кінець оновленої логіки обмеження
// Створення DOM елементів (залишаємо як є)
const node = document.createElement('div');
node.className = `app-notification ${type}`;
if (onClick) node.style.cursor = "pointer";
const icon = document.createElement('div');
icon.className = 'icon';
icon.innerHTML = this._icons[type] || this._icons.info;
const body = document.createElement('div');
body.className = 'body';
if (content.title) {
const t = document.createElement('div');
t.className = 'title';
t.textContent = content.title;
body.appendChild(t);
}
const txt = document.createElement('div');
txt.className = 'text';
txt.textContent = content.text || '';
body.appendChild(txt);
node.appendChild(icon);
node.appendChild(body);
// Додаємо кнопку закриття
if (!onClick && !lock) {
const closeDiv = document.createElement('div');
closeDiv.className = 'blockClose';
node.appendChild(closeDiv);
const closeBtn = document.createElement('button');
closeBtn.className = 'close';
closeBtn.setAttribute('aria-label', 'Закрити повідомлення');
closeBtn.innerHTML = '';
closeDiv.appendChild(closeBtn);
closeBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Запобігаємо спрацьовуванню onClick на самій ноді
this._removeNode(node);
});
}
this._container.appendChild(node);
// Запускаємо анімацію появи через requestAnimationFrame
requestAnimationFrame(() => node.classList.add('show'));
let timer = null;
const startTimer = () => {
if (timeout === 0 || lock) return;
timer = setTimeout(() => this._removeNode(node), timeout);
};
const clearTimer = () => { if (timer) { clearTimeout(timer); timer = null; } };
// Зупинка таймауту при наведенні
node.addEventListener('mouseenter', clearTimer);
node.addEventListener('mouseleave', startTimer);
// Обробка кліку на сповіщенні
if (typeof onClick === 'function') {
node.addEventListener('click', () => {
clearTimer(); // Зупиняємо таймаут, якщо він був
try { onClick(); } catch (e) { console.error(e); }
this._removeNode(node);
});
}
startTimer();
return node;
}
/**
* Приватний метод для видалення ноди з анімацією.
* **Тепер перевіряє, чи нода вже видаляється.**
*/
_removeNode(node) {
if (!node || !node.parentElement || this._removingNodes.has(node)) return;
this._removingNodes.add(node); // Позначаємо як ту, що видаляється
node.classList.remove('show');
// Чекаємо завершення анімації зникнення (200мс)
setTimeout(() => {
if (node && node.parentElement) {
node.parentElement.removeChild(node);
}
this._removingNodes.delete(node); // Видаляємо зі списку після фізичного видалення
}, 200);
}
/**
* Видаляє всі видимі сповіщення.
*/
clearAll() {
if (!this._container) return;
// Використовуємо _removeNode, який тепер безпечно обробляє повторні виклики
Array.from(this._container.children).forEach(n => this._removeNode(n));
}
// Допоміжні методи з фіксованим типом сповіщення
info(message, opts = {}) { return this.show(message, { ...opts, type: 'info' }); }
success(message, opts = {}) { return this.show(message, { ...opts, type: 'success' }); }
warn(message, opts = {}) { return this.show(message, { ...opts, type: 'warn' }); }
error(message, opts = {}) { return this.show(message, { ...opts, type: 'error' }); }
// Метод для сповіщень, що реагують на клік (псевдонім 'click' для 'show' з 'onClick')
click(message, opts = {}) { return this.show(message, { ...opts, onClick: opts.f }); }
/**
* Вставляє необхідні CSS стилі в Shadow DOM (залишаємо як є).
*/
_insertStyles() {
const style = document.createElement('style');
style.textContent = `
/* Контейнер */
.app-notification-container {
position: fixed;
z-index: 9999;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
}
/* Позиціонування контейнера */
.app-notification-container[data-position="top-right"] { top: 8px; right: 8px; align-items: flex-end; }
.app-notification-container[data-position="top-left"] { top: 8px; left: 8px; align-items: flex-start; }
.app-notification-container[data-position="bottom-right"] { bottom: 8px; right: 8px; align-items: flex-end; }
.app-notification-container[data-position="bottom-left"] { bottom: 8px; left: 8px; align-items: flex-start; }
/* Одне сповіщення */
.app-notification {
pointer-events: auto;
min-width: 220px;
max-width: 360px;
background: #111;
color: #fff;
padding: 10px 12px 10px 12px;
border-radius: var(--border-radius, 8px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
font-size: var(--FontSize2, 14px);
line-height: 1.2;
display: flex;
gap: 10px;
align-items: center;
opacity: 0;
transform: translateY(-6px) scale(0.995);
transition: opacity .18s ease, transform .18s ease;
position: relative;
}
.app-notification.show {
opacity: 0.95;
transform: translateY(0) scale(1);
}
.app-notification .icon {
font-size: 18px;
width: 22px;
height: 22px;
display:flex;
align-items:center;
justify-content:center;
border-radius: calc(var(--border-radius, 8px) - 5px);
padding: 8px;
}
.app-notification .icon svg{
width: 20px;
height: 20px;
fill: #fff;
}
.app-notification .body { flex:1; }
.app-notification .title { font-weight: 600; margin-bottom: 4px; font-size: 13px; }
/* Кнопка закриття */
.app-notification .blockClose {
width: 20px;
height: 20px;
}
.app-notification .blockClose .close {
position: absolute;
right: 10px;
top: 10px;
margin-left: 8px;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
}
.app-notification .blockClose .close svg {
width: 15px;
height: 15px;
fill: #fff;
opacity: 0.8;
display: block;
}
/* Стилі за типами */
.app-notification.info {
background: var(--ColorThemes3, #2196F3);
color: var(--ColorThemes0, #ffffff);
}
.app-notification.info .icon { background: var(--ColorThemes0, #ffffff); }
.app-notification.info .icon svg{fill: var(--ColorThemes3, #2196F3);}
.app-notification.info .close svg{fill: var(--ColorThemes0, #ffffff);}
.app-notification.success { background: #52ac56; }
.app-notification.success .icon { background: #6dc450; }
.app-notification.success .close svg{fill: #fff;}
.app-notification.warn { background: #d18515; }
.app-notification.warn .icon { background: #eaad57; }
.app-notification.warn .close svg{fill: #fff;}
.app-notification.error { background: #9c2424; }
.app-notification.error .icon { background: #c45050; }
.app-notification.error .close svg{fill: #fff;}
/* Адаптивність для мобільних пристроїв */
@media (max-width: 700px) {
.app-notification-container {
left: 0;
right: 0;
width: calc(100% - 24px);
align-items: center !important;
}
.app-notification-container .app-notification {
max-width: calc(100% - 30px);
min-width: calc(100% - 30px);
}
/* Спеціальна мобільна позиція знизу */
.app-notification-container.mobile-bottom {
top: auto;
bottom: 0;
}
}
`;
this.shadowRoot.appendChild(style);
}
}
// Реєструємо веб-компонент у браузері
customElements.define('notification-container', NotificationContainer);
/*
============================
ПРИКЛАД ВИКОРИСТАННЯ
============================
*/
/*
1. Додайте цей елемент у свій HTML:
2. Отримайте посилання на компонент у JS:
const Notifier = document.getElementById('notif-manager');
3. Приклади викликів:
💡 Базові сповіщення
Notifier.info('Налаштування мобільної позиції змінено.');
Notifier.success('Успішна операція.');
Notifier.warn('Увага: низький рівень заряду батареї.');
Notifier.error('Критична помилка!');
💡 Сповіщення із заголовком
Notifier.info('Це повідомлення має чіткий заголовок.', {
title: 'Важлива інформація'
});
💡 Сповіщення з об'єктом (заголовок та текст)
Notifier.warn({
title: `Metrics`,
text: `З'єднання встановлено`
});
💡 Сповіщення, яке не зникає (timeout: 0 або lock: true)
Notifier.error('Критична помилка! Необхідне втручання.', {
timeout: 0,
lock: true
});
💡 Сповіщення з обробником кліку (автоматично закривається після кліку)
Notifier.info('Натисніть тут, щоб побачити деталі.', {
onClick: () => alert('Ви клацнули! Дякую.'),
lock: false
});
💡 Програмне керування
Notifier.setMobileBottom(true); // Включити примусову позицію знизу для мобільних
Notifier.setMobileBottom(false); // Вимкнути примусову позицію знизу
Notifier.clearAll(); // Видалити всі сповіщення
*/