/** * Клас AppNotificationContainer * Веб-компонент для відображення системних сповіщень. * Використовує Shadow DOM для інкапсуляції стилів. */ class AppNotificationContainer extends HTMLElement { constructor() { super(); // Створюємо Shadow DOM this.attachShadow({ mode: 'open' }); // Налаштування за замовчуванням this._timeout = 4000; // Час до автоматичного закриття (мс) this._maxVisible = 5; // Максимальна кількість одночасно видимих сповіщень this._position = 'top-right'; // Позиція за замовчуванням this._mobileBottomEnabled = false; // Чи застосовувати позицію 'bottom' на мобільних пристроях // Створюємо основний контейнер для сповіщень 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']; } /** * Викликається, коли елемент додається в DOM. */ 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'); // Якщо 'mobile-position' встановлено (навіть без значення) або 'bottom' this._mobileBottomEnabled = mobilePosAttr === 'bottom' || mobilePosAttr === ''; this._container.setAttribute('data-position', this._position); this._applyMobileStyles(); } /** * Застосовує CSS-клас для мобільної позиції. */ _applyMobileStyles() { if (this._mobileBottomEnabled) { this._container.classList.add('mobile-bottom'); } else { this._container.classList.remove('mobile-bottom'); } } /** * Програмно встановлює або вимикає примусову нижню позицію на мобільних. * @param {boolean} enable - true для ввімкнення, false для вимкнення. */ setMobileBottom(enable) { this._mobileBottomEnabled = !!enable; this._applyMobileStyles(); } /** * Показує нове сповіщення. * @param {string | {title?: string, text: string}} message - Текст або об'єкт сповіщення. * @param {object} options - Налаштування сповіщення. * @returns {HTMLElement} Створений елемент сповіщення. */ show(message, options = {}) { const { type = 'info', // Тип: 'info', 'success', 'warn', 'error' timeout = this._timeout, // Час зникнення title, onClick, // Функція при кліку lock // Якщо true, не зникає і не має кнопки закриття } = options; const content = typeof message === 'string' ? { title: title || '', text: message } : message; // Обмеження кількості: видаляємо найстаріше сповіщення (FIFO) while (this._container.children.length >= this._maxVisible) { const first = this._container.firstElementChild; 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', () => this._removeNode(node)); } this._container.appendChild(node); // Запускаємо анімацію появи через requestAnimationFrame requestAnimationFrame(() => node.classList.add('show')); let timer = null; const startTimer = () => { if (timeout === 0 || lock) return; // Ігноруємо таймаут, якщо 0 або lock 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', () => { try { onClick(); } catch (e) { console.error(e); } this._removeNode(node); // Закриваємо після виконання функції }); } startTimer(); return node; } /** * Приватний метод для видалення ноди з анімацією. */ _removeNode(node) { if (!node || !node.parentElement) return; node.classList.remove('show'); // Чекаємо завершення анімації зникнення (200мс) setTimeout(() => { if (node && node.parentElement) node.parentElement.removeChild(node); }, 200); } /** * Видаляє всі видимі сповіщення. */ clearAll() { if (!this._container) return; 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: 95%; min-width: 95%; } /* Спеціальна мобільна позиція знизу */ .app-notification-container.mobile-bottom { top: auto; bottom: 0; } } `; this.shadowRoot.appendChild(style); } } // Реєструємо веб-компонент у браузері customElements.define('app-notification-container', AppNotificationContainer); /* ============================ ПРИКЛАД ВИКОРИСТАННЯ ============================ */ /* 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(); // Видалити всі сповіщення */