Додані повідомлення та перепрацьована структура застосунку та api

This commit is contained in:
2026-03-15 00:25:10 +02:00
parent 85483b85bb
commit 4bc9c11512
101 changed files with 5763 additions and 2546 deletions

View File

@@ -0,0 +1,511 @@
// =========================================================
// Клас 1: Пункт Меню (nav-item)
// =========================================================
class NavigationItem extends HTMLElement {
// 1. Відстежувані атрибути
static get observedAttributes() {
return ['title', 'icon', 'href', 'click'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Створюємо базову структуру елементів
this.renderBaseStructure();
}
/**
* Створює базовий DOM та стилі.
*/
renderBaseStructure() {
const shadow = this.shadowRoot;
shadow.innerHTML = '';
const style = document.createElement('style');
style.textContent = `
.item-wrapper{
width: 184px;
height: 54px;
list-style-type: none;
position: relative;
-webkit-transition: width .2s ease 0s;
-o-transition: width .2s ease 0s;
transition: width .2s ease 0s;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-item {
width: 100%;
height: 50px;
padding: 0 12px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
border-radius: var(--border-radius, 15px);
-webkit-transition: width .2s ease 0s;
-o-transition: width .2s ease 0s;
transition: width .2s ease 0s;
opacity: 0.8;
cursor: pointer;
border: 2px;
border: 2px solid var(--ColorThemes2, #525151);
color: var(--ColorThemes3, #f3f3f3);
overflow: hidden;
text-decoration: none;
gap: 15px;
}
.nav-icon-img, .nav-icon-wrapper {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-icon-wrapper svg {
width: 25px;
height: 25px;
min-width: 25px;
min-height: 25px;
fill: currentColor;
}
.nav-title {
font-size: var(--FontSize3, 14px);
font-weight: 300;
white-space: nowrap;
}
:host([data-state="active"]) .nav-item {
color: var(--ColorThemes2, #525151);
background: var(--ColorThemes3, #f3f3f3);
border: 2px solid var(--ColorThemes3, #f3f3f3);
box-shadow: var(--shadow-l1, 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.06), 0px 0px 1px rgba(0, 0, 0, 0.04));
}
@media (hover: hover) {
.nav-item:hover {
border: 2px solid var(--ColorThemes3);
}
}
@media (max-width: 1100px) {
.item-wrapper{
width: 54px;
}
.nav-item{
width: 50px;
}
.nav-title {
display: none;
}
}
@media (max-width: 700px), (max-height: 540px) {
.item-wrapper{
width: 40px;
height: 40px;
}
.nav-item {
width: 40px;
height: 40px;
padding: 0;
border: 0;
justify-content: center;
background: transparent;
color: var(--ColorThemes0, #1c1c19);
border-radius: 50%;
}
:host([data-state="active"]) .nav-item {
color: var(--PrimaryColor, #cb9e44);
background: transparent;
border: 0;
box-shadow: none;
}
.nav-title {
display: none;
}
@media (hover: hover) {
.nav-item:hover {
border: 0;
}
}
}
`;
shadow.appendChild(style);
// Створюємо порожній контейнер, який буде замінено на <div> або <a>
this.containerWrapper = document.createElement('div');
this.containerWrapper.setAttribute('class', 'item-wrapper');
shadow.appendChild(this.containerWrapper);
// Первинне заповнення контентом
this.updateContent();
}
connectedCallback() {
// Обробник кліку додається після того, як this.itemElement створено в updateContent
this.containerWrapper.addEventListener('click', this.handleItemClick.bind(this));
}
disconnectedCallback() {
this.containerWrapper.removeEventListener('click', this.handleItemClick.bind(this));
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.updateContent();
}
}
/**
* Зчитує атрибути та оновлює внутрішній HTML і тег-контейнер.
*/
updateContent() {
const title = this.getAttribute('title') || 'Без назви';
const iconContent = this.getAttribute('icon');
const href = this.getAttribute('href');
// 1. Формуємо HTML для іконки та заголовка
let iconHTML = '';
if (iconContent) {
const trimmedIcon = iconContent.trim();
const isSVG = trimmedIcon.startsWith('<svg') && trimmedIcon.endsWith('</svg>');
if (isSVG) {
iconHTML = `<span class="nav-icon-wrapper">${trimmedIcon}</span>`;
} else {
iconHTML = `<img src="${iconContent}" alt="${title} icon" class="nav-icon-img">`;
}
}
const innerContent = `${iconHTML}<span class="nav-title">${title}</span>`;
// 2. Визначаємо, який тег використовувати (<a> чи <div>)
const currentTag = this.containerWrapper.firstChild ? this.containerWrapper.firstChild.tagName.toLowerCase() : null;
const requiredTag = href ? 'a' : 'div';
// Якщо тип тега потрібно змінити, створюємо новий елемент
if (currentTag !== requiredTag) {
// Створюємо новий елемент <a> або <div>
this.itemElement = document.createElement(requiredTag);
this.itemElement.setAttribute('class', 'nav-item');
// Замінюємо старий елемент новим
this.containerWrapper.innerHTML = '';
this.containerWrapper.appendChild(this.itemElement);
} else {
// Елемент вже правильний, використовуємо його
this.itemElement = this.containerWrapper.firstChild;
}
// 3. Встановлюємо атрибути
this.itemElement.innerHTML = innerContent;
if (href) {
// Це посилання: встановлюємо href та data-route
this.itemElement.setAttribute('href', href);
this.itemElement.setAttribute('data-route', href); // <-- ДОДАНО data-route
this.itemElement.removeAttribute('role');
this.itemElement.removeAttribute('tabindex');
} else {
// Це кнопка: видаляємо посилальні атрибути та встановлюємо роль
this.itemElement.removeAttribute('href');
this.itemElement.removeAttribute('data-route');
this.itemElement.setAttribute('role', 'button');
this.itemElement.setAttribute('tabindex', '0');
}
}
/**
* Обробляє клік, виконуючи код з атрибута 'click' для не-посилань.
*/
handleItemClick(event) {
const clickAction = this.getAttribute('click');
const href = this.getAttribute('href');
if (href) {
// Якщо це тег <a>, дозволяємо браузеру обробляти клік (або JS-роутеру)
return;
}
if (clickAction) {
try {
event.preventDefault(); // Запобігаємо стандартній дії (якщо була встановлена роль кнопки)
console.log(`Executing click action: ${clickAction}`);
eval(clickAction);
} catch (e) {
console.error(`Error executing click action "${clickAction}":`, e);
}
}
}
}
// =========================================================
// Клас 2: Контейнер Меню (navigation-container)
// =========================================================
class NavigationContainer extends HTMLElement {
/**
* Обробляє клік на документі, щоб приховати меню, якщо клік був за його межами.
*/
handleOutsideClick = (event) => {
// Перевіряємо, чи містить наш компонент елемент, на який клікнули
// (this.shadowRoot.host - це сам <navigation-container>)
// або this.menuContainer.contains(event.target)
if (!this.contains(event.target) && !this.shadowRoot.contains(event.target)) {
// Клік був за межами компонента
this.hideHiddenMenu();
}
}
handleScroll = () => {
this.hideHiddenMenu();
}
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.standalone = window.matchMedia('(display-mode: standalone)').matches;
this.os = this.detectOS();
// Стилі контейнера
const style = document.createElement('style');
style.textContent = `
.navigation-menu{
position: fixed;
width: 230px;
height: calc(100vh - 60px);
min-height: 510px;
background: var(--ColorThemes2, #525151);
margin: 0;
padding: 40px 10px;
-webkit-transition: width .2s ease 0s;
-o-transition: width .2s ease 0s;
transition: width .2s ease 0s;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.navigation-items,
.navigation-items-hidden {
position: relative;
display: flex;
flex-direction: column;
min-height: 55px;
align-items: center;
justify-content: flex-start;
gap: 6px;
border-radius: 30px;
}
.navigation-items-hidden {
margin-top: 5px;
}
.more-button-item {
display: none;
}
@media (max-width: 1100px) {
.navigation-menu {
width: 100px;
}
}
@media (max-width: 700px), (max-height: 540px) {
.navigation-menu {
width: calc(100% - 30px);
height: 60px;
min-height: 60px;
padding: 0;
z-index: 9991;
bottom: 0px;
background: transparent;
left: 15px;
border: 0;
margin: 0;
bottom: 0px;
}
.navigation-items {
display: flex;
flex-direction: row;
height: 100%;
justify-content: space-around;
align-items: center;
z-index: 9998;
bottom: 10px;
box-shadow: 0px -4px 10px rgba(0, 0, 0, 0.2);
}
.navigation-items::before {
content: "";
position: absolute;
inset: 0;
background: var(--ColorThemes2, #525151);
background: var(--ColorThemes3, #f3f3f3);
opacity: 0.97;
z-index: 0;
border-radius: 30px;
}
.navigation-items-hidden{
flex-direction: row;
height: fit-content;
justify-content: space-around;
align-items: center;
position: absolute;
bottom: 12px;
left: 2px;
width: calc(100% - 4px);
margin: 0;
-webkit-transition: .2s ease 0s;
-o-transition: .2s ease 0s;
transition: .2s ease 0s;
z-index: 9992;
opacity: 0;
}
.navigation-items-hidden::before {
content: "";
position: absolute;
inset: 0;
background: var(--ColorThemes2, #525151);
background: var(--ColorThemes3, #f3f3f3);
opacity: 0.98;
z-index: 0;
border-radius: 30px;
}
.more-button-item {
display: flex;
}
.navigation-menu.expanded .navigation-items-hidden {
bottom: 75px;
opacity: 1;
box-shadow: 0px -4px 10px rgba(0, 0, 0, 0.2);
}
.navigation-menu[data-os="iOS"] .navigation-items {
bottom: 15px;
}
.navigation-menu[data-os="iOS"] .navigation-items-hidden {
bottom: 17px;
}
.navigation-menu[data-os="iOS"].expanded .navigation-items-hidden {
bottom: 80px;
opacity: 1;
}
}
@media (max-width: 700px) {
.navigation-menu {
-webkit-transition: 0s ease 0s;
-o-transition: 0s ease 0s;
transition: 0s ease 0s;
}
}
`;
shadow.appendChild(style);
this.menuContainer = document.createElement('menu');
this.menuContainer.setAttribute('class', 'navigation-menu');
if (this.standalone) this.menuContainer.setAttribute('data-os', this.os);
this.itemsContainer = document.createElement('items');
this.itemsContainer.setAttribute('class', 'navigation-items');
this.itemsHiddenContainer = document.createElement('items-hidden');
this.itemsHiddenContainer.setAttribute('class', 'navigation-items-hidden');
// Слот дозволяє відображати дочірні елементи <nav-item>
const slot = document.createElement('slot');
this.itemsContainer.appendChild(slot);
this.menuContainer.appendChild(this.itemsContainer);
this.menuContainer.appendChild(this.itemsHiddenContainer);
shadow.appendChild(this.menuContainer);
// MutationObserver для відстеження динамічного додавання/видалення пунктів
this.observer = new MutationObserver(this.handleMutations.bind(this));
// Спостерігаємо за зміною дочірніх елементів
this.observer.observe(this, { childList: true, subtree: false });
}
connectedCallback() {
// Додаємо обробник кліків до документа
document.addEventListener('click', this.handleOutsideClick);
// Додаємо обробник прокручування до вікна
window.addEventListener('scroll', this.handleScroll);
// Повторна перевірка елементів на випадок, якщо вони вже були в DOM до реєстрації
this.reassignItems();
}
disconnectedCallback() {
this.observer.disconnect();
// Видаляємо обробник кліків з документа
document.removeEventListener('click', this.handleOutsideClick);
// Видаляємо обробник прокручування з вікна
window.removeEventListener('scroll', this.handleScroll);
}
handleMutations(mutationsList, observer) {
// Логіка оновлення при додаванні/видаленні дочірніх елементів (наприклад, для логування)
this.reassignItems();
}
reassignItems() {
// Отримуємо всі дочірні елементи (які можуть бути nav-item)
const allItems = Array.from(this.children);
allItems.forEach(item => {
// Перевіряємо, чи має елемент атрибут data-hidden
if (item.getAttribute('data-hidden') == 'true') {
this.itemsHiddenContainer.appendChild(item);
this.createMoreButton();
} else if (item.parentNode !== this) {
this.appendChild(item);
}
});
}
// --- Утиліти ---
hideHiddenMenu() {
this.menuContainer.classList.remove('expanded');
}
toggleHiddenMenu() {
this.menuContainer.classList.toggle('expanded');
}
createMoreButton() {
let moreButton = this.itemsContainer.querySelector('.more-button-item');
if (!moreButton) {
const button = document.createElement('nav-item');
button.setAttribute('class', 'more-button-item');
button.setAttribute('title', 'Більше');
button.setAttribute('icon',
`<svg viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>`
);
button.addEventListener('click', this.toggleHiddenMenu.bind(this));
this.itemsContainer.appendChild(button);
return button;
}
}
detectOS() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
return 'iOS';
}
return 'Other';
}
}
// =========================================================
// Реєстрація компонентів у браузері
// =========================================================
customElements.define('nav-item', NavigationItem);
customElements.define('navigation-container', NavigationContainer);

View File

@@ -1,20 +1,28 @@
class AppNotificationContainer extends HTMLElement {
/**
* Клас 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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M 15 3 C 13.895 3 13 3.895 13 5 L 13 5.2929688 C 10.109011 6.1538292 8 8.8293311 8 12 L 8 14.757812 C 8 17.474812 6.921 20.079 5 22 A 1 1 0 0 0 4 23 A 1 1 0 0 0 5 24 L 25 24 A 1 1 0 0 0 26 23 A 1 1 0 0 0 25 22 C 23.079 20.079 22 17.474812 22 14.757812 L 22 12 C 22 8.8293311 19.890989 6.1538292 17 5.2929688 L 17 5 C 17 3.895 16.105 3 15 3 z M 3.9550781 7.9882812 A 1.0001 1.0001 0 0 0 3.1054688 8.5527344 C 3.1054688 8.5527344 2 10.666667 2 13 C 2 15.333333 3.1054687 17.447266 3.1054688 17.447266 A 1.0001165 1.0001165 0 0 0 4.8945312 16.552734 C 4.8945312 16.552734 4 14.666667 4 13 C 4 11.333333 4.8945313 9.4472656 4.8945312 9.4472656 A 1.0001 1.0001 0 0 0 3.9550781 7.9882812 z M 26.015625 7.9882812 A 1.0001 1.0001 0 0 0 25.105469 9.4472656 C 25.105469 9.4472656 26 11.333333 26 13 C 26 14.666667 25.105469 16.552734 25.105469 16.552734 A 1.0001163 1.0001163 0 1 0 26.894531 17.447266 C 26.894531 17.447266 28 15.333333 28 13 C 28 10.666667 26.894531 8.5527344 26.894531 8.5527344 A 1.0001 1.0001 0 0 0 26.015625 7.9882812 z M 12 26 C 12 27.657 13.343 29 15 29 C 16.657 29 18 27.657 18 26 L 12 26 z"/></svg>`,
success: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8' style="width: 17px;height: 17px;"><path d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/></svg>`,
@@ -43,7 +51,6 @@ class AppNotificationContainer extends HTMLElement {
this._timeout = parseInt(this.getAttribute('timeout')) || 4000;
const mobilePosAttr = this.getAttribute('mobile-position');
// Если атрибут установлен в 'bottom' или присутствует (как пустая строка, если это булевый атрибут)
this._mobileBottomEnabled = mobilePosAttr === 'bottom' || mobilePosAttr === '';
this._container.setAttribute('data-position', this._position);
@@ -51,9 +58,6 @@ class AppNotificationContainer extends HTMLElement {
this._applyMobileStyles();
}
/**
* Динамически применяет класс, который активирует мобильные стили "только снизу".
*/
_applyMobileStyles() {
if (this._mobileBottomEnabled) {
this._container.classList.add('mobile-bottom');
@@ -62,15 +66,14 @@ class AppNotificationContainer extends HTMLElement {
}
}
/**
* Публичный метод для изменения настройки мобильной позиции во время выполнения.
* @param {boolean} enable - true, чтобы принудительно устанавливать позицию снизу на мобильных, false, чтобы использовать обычные @media стили.
*/
setMobileBottom(enable) {
this._mobileBottomEnabled = !!enable;
this._applyMobileStyles();
}
/**
* Показує нове сповіщення.
*/
show(message, options = {}) {
const {
type = 'info',
@@ -84,15 +87,28 @@ class AppNotificationContainer extends HTMLElement {
? { title: title || '', text: message }
: message;
while (this._container.children.length >= this._maxVisible) {
const first = this._container.firstElementChild;
if (first) first.remove();
else break;
}
// **Оновлена логіка обмеження кількості:**
// Визначаємо кількість "видимих" нод (які не перебувають у процесі видалення)
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"
if (onClick) node.style.cursor = "pointer";
const icon = document.createElement('div');
icon.className = 'icon';
@@ -114,6 +130,7 @@ class AppNotificationContainer extends HTMLElement {
node.appendChild(icon);
node.appendChild(body);
// Додаємо кнопку закриття
if (!onClick && !lock) {
const closeDiv = document.createElement('div');
closeDiv.className = 'blockClose';
@@ -121,28 +138,35 @@ class AppNotificationContainer extends HTMLElement {
const closeBtn = document.createElement('button');
closeBtn.className = 'close';
closeBtn.setAttribute('aria-label', 'Закрыть уведомление');
closeBtn.setAttribute('aria-label', 'Закрити повідомлення');
closeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26"><path d="M 6.65625 4 C 6.367188 4 6.105469 4.113281 5.90625 4.3125 L 4.3125 5.90625 C 3.914063 6.304688 3.914063 7 4.3125 7.5 L 9.8125 13 L 4.3125 18.5 C 3.914063 19 3.914063 19.695313 4.3125 20.09375 L 5.90625 21.6875 C 6.40625 22.085938 7.101563 22.085938 7.5 21.6875 L 13 16.1875 L 18.5 21.6875 C 19 22.085938 19.695313 22.085938 20.09375 21.6875 L 21.6875 20.09375 C 22.085938 19.59375 22.085938 18.898438 21.6875 18.5 L 16.1875 13 L 21.6875 7.5 C 22.085938 7 22.085938 6.304688 21.6875 5.90625 L 20.09375 4.3125 C 19.59375 3.914063 18.898438 3.914063 18.5 4.3125 L 13 9.8125 L 7.5 4.3125 C 7.25 4.113281 6.945313 4 6.65625 4 Z"></path></svg>';
closeDiv.appendChild(closeBtn);
closeBtn.addEventListener('click', () => this._removeNode(node));
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) return;
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', () => {
try { onClick(); } catch (e) { }
clearTimer(); // Зупиняємо таймаут, якщо він був
try { onClick(); } catch (e) { console.error(e); }
this._removeNode(node);
});
}
@@ -152,29 +176,50 @@ class AppNotificationContainer extends HTMLElement {
return node;
}
/**
* Приватний метод для видалення ноди з анімацією.
* **Тепер перевіряє, чи нода вже видаляється.**
*/
_removeNode(node) {
if (!node || !node.parentElement) return;
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);
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;
@@ -184,11 +229,13 @@ class AppNotificationContainer extends HTMLElement {
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;
@@ -230,6 +277,8 @@ class AppNotificationContainer extends HTMLElement {
}
.app-notification .body { flex:1; }
.app-notification .title { font-weight: 600; margin-bottom: 4px; font-size: 13px; }
/* Кнопка закриття */
.app-notification .blockClose {
width: 20px;
height: 20px;
@@ -253,6 +302,7 @@ class AppNotificationContainer extends HTMLElement {
display: block;
}
/* Стилі за типами */
.app-notification.info {
background: var(--ColorThemes3, #2196F3);
color: var(--ColorThemes0, #ffffff);
@@ -273,6 +323,7 @@ class AppNotificationContainer extends HTMLElement {
.app-notification.error .icon { background: #c45050; }
.app-notification.error .close svg{fill: #fff;}
/* Адаптивність для мобільних пристроїв */
@media (max-width: 700px) {
.app-notification-container {
left: 0;
@@ -281,10 +332,10 @@ class AppNotificationContainer extends HTMLElement {
align-items: center !important;
}
.app-notification-container .app-notification {
max-width: 95%;
min-width: 95%;
max-width: calc(100% - 30px);
min-width: calc(100% - 30px);
}
/* Спеціальна мобільна позиція знизу */
.app-notification-container.mobile-bottom {
top: auto;
bottom: 0;
@@ -295,32 +346,62 @@ class AppNotificationContainer extends HTMLElement {
}
}
customElements.define('app-notification-container', AppNotificationContainer);
// Реєструємо веб-компонент у браузері
customElements.define('notification-container', NotificationContainer);
/* <app-notification-container
/*
============================
ПРИКЛАД ВИКОРИСТАННЯ
============================
*/
/*
1. Додайте цей елемент у свій HTML:
<notification-container
id="notif-manager"
position="top-right"
max-visible="5"
timeout="4000"
mobile-position>
</app-notification-container> */
// const Notifier = document.getElementById('notif-manager');
</notification-container>
// 💡 Включить принудительную позицию снизу для мобильных
// Notifier.setMobileBottom(true);
2. Отримайте посилання на компонент у JS:
const Notifier = document.getElementById('notif-manager');
// 💡 Отключить принудительную позицию снизу (вернется к поведению @media или position)
// Notifier.setMobileBottom(false);
3. Приклади викликів:
💡 Базові сповіщення
Notifier.info('Налаштування мобільної позиції змінено.');
Notifier.success('Успішна операція.');
Notifier.warn('Увага: низький рівень заряду батареї.');
Notifier.error('Критична помилка!');
// Пример использования
💡 Сповіщення із заголовком
Notifier.info('Це повідомлення має чіткий заголовок.', {
title: 'Важлива інформація'
});
// Notifier.info('Настройки мобильной позиции изменены.');
// Notifier.info('Привет! Это ваше первое уведомление через Web Component.', {
// title: 'Успешная инициализация',
// onClick: () => alert('Вы кликнули!'),
// lock: false
// });
// Notifier.success('Успешная операция.');
// Notifier.error('Критическая ошибка!', { timeout: 0, lock: true });
// Notifier.warn({ title: `Metrics`, text: `З'єднання встановлено` }, { timeout: 0 });
💡 Сповіщення з об'єктом (заголовок та текст)
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(); // Видалити всі сповіщення
*/

View File

@@ -0,0 +1,301 @@
class PwaInstallBanner extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.deferredPrompt = null;
this.STORAGE_KEY = 'PwaInstallBanner'; // Визначаємо ключ localStorage
this.isInStandaloneMode = () => ('standalone' in window.navigator && window.navigator.standalone === true);
this.os = this.detectOS();
}
connectedCallback() {
// Додаємо стилі та розмітку до Shadow DOM
this.shadowRoot.innerHTML = this.getStyles() + this.getTemplate();
this.elements = {
backdrop: this.shadowRoot.getElementById('blur-backdrop'),
installOverlay: this.shadowRoot.getElementById('pwa-install-overlay'),
iosOverlay: this.shadowRoot.getElementById('pwa-ios-overlay'),
installButton: this.shadowRoot.getElementById('pwa-install-button'),
closeButton: this.shadowRoot.getElementById('pwa-close-button'),
iosCloseButton: this.shadowRoot.getElementById('pwa-ios-close-button'),
};
this.setupListeners();
this.checkInitialDisplay();
}
// --- Утиліти ---
detectOS() {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
return 'iOS';
}
// ... (можна додати Android, Windows, але для PWA нас цікавить в першу чергу iOS)
return 'Other';
}
shouldShowBanner() {
return localStorage.getItem(this.STORAGE_KEY) !== 'false';
}
checkInitialDisplay() {
if (!this.shouldShowBanner()) {
return; // Не показуємо, якщо localStorage = 'false'
}
// Логіка для iOS
if (this.os === 'iOS' && !this.isInStandaloneMode()) {
// Затримка відображення, як у вихідному коді
setTimeout(() => {
this.openPopup(this.elements.iosOverlay);
}, 1000);
}
}
openPopup(overlayElement) {
this.elements.backdrop.classList.remove('pwa-hidden');
overlayElement.classList.remove('pwa-hidden');
document.body.classList.add('modal-open');
}
closePopup = () => {
this.elements.installOverlay.classList.add('pwa-hidden');
this.elements.iosOverlay.classList.add('pwa-hidden');
this.elements.backdrop.classList.add('pwa-hidden');
document.body.classList.remove('modal-open');
this.deferredPrompt = null;
}
// --- Обробники подій ---
setupListeners() {
window.addEventListener("beforeinstallprompt", this.handleBeforeInstallPrompt);
// Обробники кнопок
this.elements.installButton.addEventListener("click", this.handleInstallClick);
this.elements.closeButton.addEventListener("click", this.closePopup);
this.elements.iosCloseButton.addEventListener('click', this.closePopup);
}
handleBeforeInstallPrompt = (e) => {
// Вихідний код перевіряв localStorage, але для простоти прикладу я її пропускаю.
if (!this.shouldShowBanner()) {
return; // Не показуємо, якщо localStorage = 'false'
}
e.preventDefault();
this.deferredPrompt = e;
// Показуємо стандартний банер, якщо доступно і не в режимі iOS
if (this.os !== 'iOS') {
this.openPopup(this.elements.installOverlay);
}
}
handleInstallClick = async () => {
if (!this.deferredPrompt) return;
this.deferredPrompt.prompt();
const { outcome } = await this.deferredPrompt.userChoice;
console.log(`[APP] Результат встановлення PWA: ${outcome}`);
this.closePopup();
}
// --- Шаблон (Template) та Стилі (Styles) ---
getTemplate() {
// HTML розмітка з вихідного коду
return `
<div id="blur-backdrop" class="pwa-hidden"></div>
<div id="pwa-install-overlay" class="pwa-overlay pwa-hidden">
<div class="popup">
<h2>Встановити застосунок?</h2>
<p>Додайте його на головний екран для швидкого доступу.</p>
<div>
<button id="pwa-install-button">Встановити</button>
<button id="pwa-close-button">Пізніше</button>
</div>
</div>
</div>
<div id="pwa-ios-overlay" class="pwa-overlay pwa-hidden">
<div class="popup">
<h2>Встановлення застосунку</h2>
<p>Щоб встановити застосунок, виконайте наступні кроки:</p>
<ol>
<li>1. Відкрийте посилання в браузері Safari.</li>
<li>
2. Натисніть кнопку
<span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<path
d="M 14.984375 1 A 1.0001 1.0001 0 0 0 14.292969 1.2929688 L 10.292969 5.2929688 A 1.0001 1.0001 0 1 0 11.707031 6.7070312 L 14 4.4140625 L 14 17 A 1.0001 1.0001 0 1 0 16 17 L 16 4.4140625 L 18.292969 6.7070312 A 1.0001 1.0001 0 1 0 19.707031 5.2929688 L 15.707031 1.2929688 A 1.0001 1.0001 0 0 0 14.984375 1 z M 9 9 C 7.3550302 9 6 10.35503 6 12 L 6 24 C 6 25.64497 7.3550302 27 9 27 L 21 27 C 22.64497 27 24 25.64497 24 24 L 24 12 C 24 10.35503 22.64497 9 21 9 L 19 9 L 19 11 L 21 11 C 21.56503 11 22 11.43497 22 12 L 22 24 C 22 24.56503 21.56503 25 21 25 L 9 25 C 8.4349698 25 8 24.56503 8 24 L 8 12 C 8 11.43497 8.4349698 11 9 11 L 11 11 L 11 9 L 9 9 z"
/>
</svg>
</span>
в нижній частині екрана Safari.
</li>
<li>
3. У меню, що з’явиться, виберіть
<span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M 6 3 C 4.3550302 3 3 4.3550302 3 6 L 3 18 C 3 19.64497 4.3550302 21 6 21 L 18 21 C 19.64497 21 21 19.64497 21 18 L 21 6 C 21 4.3550302 19.64497 3 18 3 L 6 3 z M 6 5 L 18 5 C 18.56503 5 19 5.4349698 19 6 L 19 18 C 19 18.56503 18.56503 19 18 19 L 6 19 C 5.4349698 19 5 18.56503 5 18 L 5 6 C 5 5.4349698 5.4349698 5 6 5 z M 11.984375 6.9863281 A 1.0001 1.0001 0 0 0 11 8 L 11 11 L 8 11 A 1.0001 1.0001 0 1 0 8 13 L 11 13 L 11 16 A 1.0001 1.0001 0 1 0 13 16 L 13 13 L 16 13 A 1.0001 1.0001 0 1 0 16 11 L 13 11 L 13 8 A 1.0001 1.0001 0 0 0 11.984375 6.9863281 z"
/>
</svg>
</span>
«На Початковий екран».
</li>
</ol>
<div>
<button id="pwa-ios-close-button">Зрозуміло</button>
</div>
</div>
</div>
`;
}
getStyles() {
// CSS стилі, які були у вихідному коді, але адаптовані для Shadow DOM
// Примітки:
// 1. Змінні CSS (наприклад, --ColorThemes0) мають бути визначені в основному документі
// або передані через властивості, інакше вони не працюватимуть в Shadow DOM.
// Я залишаю їх як є, припускаючи, що вони глобально доступні.
// 2. Стилі для body.modal-open потрібно додати в основний CSS.
return `
<style>
#blur-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
z-index: 9998;
}
.pwa-overlay {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.pwa-overlay>.popup {
background: var(--ColorThemes0, #ffffff); /* Fallback */
padding: 24px 32px;
border-radius: var(--border-radius, 15px);
max-width: 90%;
width: 320px;
text-align: center;
font-family: sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
animation: fadeIn 0.3s ease-out;
display: flex;
flex-direction: column;
align-items: center;
}
.pwa-overlay>.popup h2 {
margin-bottom: 12px;
color: var(--ColorThemes3, #333);
opacity: 0.8;
}
.pwa-overlay>.popup p {
margin-bottom: 10px;
color: var(--ColorThemes3, #333);
opacity: 0.6;
}
.pwa-overlay>.popup ol {
text-align: justify;
font-size: var(--FontSize4, 15px);
margin-bottom: 10px;
max-width: 290px;
padding-left: 0; /* Виправлення відступу списку */
}
.pwa-overlay>.popup li {
list-style-type: none;
font-size: var(--FontSize3, 14px);
margin-bottom: 8px;
}
.pwa-overlay>.popup li span {
vertical-align: middle;
display: inline-block;
width: 22px;
height: 22px;
}
.pwa-overlay>.popup li span svg {
fill: var(--PrimaryColor, #007bff);
}
.pwa-overlay>.popup>div {
margin-top: 10px;
display: flex;
justify-content: center;
gap: 10px;
}
.pwa-overlay>.popup>div>button {
padding: 8px 16px;
border: none;
border-radius: calc(var(--border-radius, 15px) - 8px);
cursor: pointer;
font-size: var(--FontSize3, 14px);
}
#pwa-install-button {
background-color: var(--PrimaryColor, #007bff);
color: var(--PrimaryColorText, #ffffff);
}
#pwa-close-button,
#pwa-ios-close-button {
background-color: #ccc;
color: #333;
}
.pwa-hidden {
display: none !important; /* Важливо для скриптів */
}
@media (max-width: 450px) {
.pwa-overlay>.popup {
padding: 17px 10px;
}
.pwa-overlay>.popup h2 {
font-size: 22px;
}
.pwa-overlay>.popup p {
font-size: var(--FontSize4, 15px);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
`;
}
}
// Реєстрація веб-компонента
customElements.define('pwa-install-banner', PwaInstallBanner);

View File

@@ -0,0 +1,292 @@
const SMART_SELECT_STYLES_CSS = `
:host { display: block; position: relative; width: 100%; font-family: system-ui, sans-serif; }
:host ::-webkit-scrollbar { height: 5px; width: 8px; }
:host ::-webkit-scrollbar-track { background: transparent; }
:host ::-webkit-scrollbar-thumb { background: var(--smart-select-chip-background, #475569); border-radius: 4px; }
@media (hover: hover) {
:host ::-webkit-scrollbar-thumb:hover { opacity: 0.7; }
}
/* Стили для скролла */
.trigger::-webkit-scrollbar { height: 4px; }
.trigger::-webkit-scrollbar-thumb { background: var(--smart-select-chip-background, #475569); border-radius: 4px; }
.wrapper {
min-height: 35px;
border: 1px solid var(--smart-select-border-color, #ccc);
border-radius: var(--smart-select-border-radius-1, 6px);
display: flex;
padding: 0 6px;
flex-direction: column;
justify-content: center;
background: var(--smart-select-background, #fff);
}
.trigger {
display: flex;
gap: 6px;
width: 100%;
overflow-x: auto;
align-items: center;
cursor: pointer;
}
.placeholder-text {
color: var(--smart-select-chip-color);
opacity: 0.4;
pointer-events: none;
user-select: none;
font-size: var(--smart-select-font-size-2);
}
.chip {
background: var(--smart-select-chip-background, #dbe3ea);
color: var(--smart-select-chip-color, #000);
padding: 4px 6px;
border-radius: var(--smart-select-border-radius-2, 4px);
font-size: var(--smart-select-font-size-1, 12px);
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
cursor: pointer;
}
.chip button {
display: flex;
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 0;
width: 20px;
height: 15px;
justify-content: flex-end;
fill: var(--smart-select-chip-fill, #e91e63);
}
.chip button svg{ width: 15px; height: 15px; }
.dropdown {
display: none; position: absolute; top: 100%; left: 0; right: 0;
background: var(--smart-select-background, #ffffff);
border: 1px solid var(--smart-select-border-color, #ccc);
border-radius: var(--smart-select-border-radius-1, 6px); z-index: 9999; margin-top: 4px;
flex-direction: column; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
}
:host([open]) .dropdown { display: flex; }
input[type="search"] {
margin: 10px;
padding: 8px 10px;
border-radius: var(--smart-select-border-radius-2, 4px);
border: 1px solid var(--smart-select-search-border, #ccc);
background: transparent;
color: var(--smart-select-search-color, #000);
}
.options-list {max-height: 200px; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
::slotted([slot="option"]) {
padding: 8px 12px !important;
cursor: pointer;
border-radius: var(--smart-select-border-radius-2, 4px);
display: block;
color: var(--smart-select-option-color, #000);
font-size: var(--smart-select-font-size-2, 14px);
}
@media (hover: hover) {
::slotted([slot="option"]:hover) {
background: var(--smart-select-hover-background, #475569);
color: var(--smart-select-hover-color, #fff);
}
}
::slotted([slot="option"].selected) {
background: var(--smart-select-selected-background, #dbe3eb) !important;
color: var(--smart-select-selected-color, #000) !important;
}
`;
// Створення об'єкта CSSStyleSheet (якщо підтримується)
let SmartSelectStyles = null;
if (typeof CSSStyleSheet !== 'undefined' && CSSStyleSheet.prototype.replaceSync) {
SmartSelectStyles = new CSSStyleSheet(); // (2) Визначення об'єкта тут
SmartSelectStyles.replaceSync(SMART_SELECT_STYLES_CSS);
}
class SmartSelect extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._selectedValues = new Set();
// Додаємо стилі в конструкторі, якщо це adoptable
if (this.shadowRoot.adoptedStyleSheets) {
this.shadowRoot.adoptedStyleSheets = [SmartSelectStyles];
} else {
// FALLBACK для старих браузерів (наприклад, iOS < 16.4)
const style = document.createElement('style');
style.textContent = SMART_SELECT_STYLES_CSS;
this.shadowRoot.appendChild(style);
}
}
connectedCallback() {
this.render();
const searchInput = this.shadowRoot.querySelector('input[type="search"]');
searchInput.addEventListener('input', (e) => this.handleSearch(e));
const wrapper = this.shadowRoot.querySelector('.wrapper');
wrapper.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
this.toggleAttribute('open');
if (this.hasAttribute('open')) {
setTimeout(() => searchInput.focus(), 50);
}
});
// Слушаем клики по элементам в слоте
this.addEventListener('click', (e) => {
const opt = e.target.closest('[slot="option"]');
if (opt) {
this.toggleValue(opt.getAttribute('data-value'), opt);
}
});
// Слушаем изменение слота для инициализации (фикс Safari)
this.shadowRoot.querySelector('slot').addEventListener('slotchange', () => {
this.syncOptions();
});
this.syncOptions();
}
_formatValue(val) {
const isNumber = this.getAttribute('type') === 'number';
return isNumber ? Number(val) : String(val);
}
syncOptions() {
const options = Array.from(this.querySelectorAll('[slot="option"]'));
options.forEach(opt => {
// Используем data-selected вместо атрибута selected
if (opt.hasAttribute('data-selected')) {
// Зберігаємо вже у потрібному форматі
const val = this._formatValue(opt.getAttribute('data-value'));
this._selectedValues.add(val);
}
});
this.updateDisplay();
}
handleSearch(e) {
const term = e.target.value.toLowerCase().trim();
const options = this.querySelectorAll('[slot="option"]');
options.forEach(opt => {
const text = opt.textContent.toLowerCase();
opt.style.display = text.includes(term) ? '' : 'none';
});
}
toggleValue(val) {
const max = this.getAttribute('max') ? parseInt(this.getAttribute('max')) : null;
const formattedVal = this._formatValue(val);
if (this._selectedValues.has(formattedVal)) {
this._selectedValues.delete(formattedVal);
this._click = {
value: formattedVal,
state: "delete"
};
} else {
if (max && this._selectedValues.size >= max) return;
this._selectedValues.add(formattedVal);
this._click = {
value: formattedVal,
state: "add"
};
}
this.updateDisplay();
this.dispatchEvent(new Event('change', { bubbles: true }));
}
updateDisplay() {
const container = this.shadowRoot.getElementById('tags');
const placeholder = this.shadowRoot.getElementById('placeholder');
const optionsElements = this.querySelectorAll('[slot="option"]');
// Керування видимістю плейсхолдера
if (this._selectedValues.size > 0) {
placeholder.style.display = 'none';
} else {
placeholder.style.display = 'block';
}
container.innerHTML = '';
this._selectedValues.forEach(val => {
// Важливо: при пошуку елемента в DOM атрибут data-value завжди рядок,
// тому використовуємо == для порівняння числа з рядком
const opt = Array.from(optionsElements).find(o => o.getAttribute('data-value') == val);
if (opt) {
const chip = document.createElement('div');
chip.className = 'chip';
chip.innerHTML = `${opt.textContent} <button><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26"><path d="M 6.65625 4 C 6.367188 4 6.105469 4.113281 5.90625 4.3125 L 4.3125 5.90625 C 3.914063 6.304688 3.914063 7 4.3125 7.5 L 9.8125 13 L 4.3125 18.5 C 3.914063 19 3.914063 19.695313 4.3125 20.09375 L 5.90625 21.6875 C 6.40625 22.085938 7.101563 22.085938 7.5 21.6875 L 13 16.1875 L 18.5 21.6875 C 19 22.085938 19.695313 22.085938 20.09375 21.6875 L 21.6875 20.09375 C 22.085938 19.59375 22.085938 18.898438 21.6875 18.5 L 16.1875 13 L 21.6875 7.5 C 22.085938 7 22.085938 6.304688 21.6875 5.90625 L 20.09375 4.3125 C 19.59375 3.914063 18.898438 3.914063 18.5 4.3125 L 13 9.8125 L 7.5 4.3125 C 7.25 4.113281 6.945313 4 6.65625 4 Z"></path></svg></button>`;
chip.querySelector('button').onclick = (e) => {
e.stopPropagation();
this.toggleValue(val);
};
container.appendChild(chip);
}
});
const max = this.getAttribute('max') ? parseInt(this.getAttribute('max')) : null;
const isFull = max && this._selectedValues.size >= max;
optionsElements.forEach(opt => {
const optVal = this._formatValue(opt.getAttribute('data-value'));
const isSelected = this._selectedValues.has(optVal);
opt.classList.toggle('selected', isSelected);
// Если лимит исчерпан, делаем невыбранные опции полупрозрачными
if (isFull && !isSelected) {
opt.style.opacity = '0.5';
opt.style.cursor = 'not-allowed';
} else {
opt.style.opacity = '1';
opt.style.cursor = 'pointer';
}
});
}
get value() {
return Array.from(this._selectedValues);
}
get getClick() {
return this._click;
}
render() {
this.shadowRoot.innerHTML = `
<div class="wrapper">
<div class="trigger" id="tags"></div>
<div id="placeholder" class="placeholder-text">
${this.getAttribute('placeholder') || 'Оберіть значення...'}
</div>
</div>
<div class="dropdown">
<input type="search" placeholder="Пошук...">
<div class="options-list">
<slot name="option"></slot>
</div>
</div>
`;
}
}
customElements.define('smart-select', SmartSelect);

View File

@@ -0,0 +1,234 @@
/**
* Вебкомпонент для ініціації оновлення сторінки (Pull-to-Refresh)
* за допомогою свайпу вниз на пристроях з iOS/iPadOS у режимі PWA.
*/
class SwipeUpdater extends HTMLElement {
constructor() {
super();
// 1. Створення Shadow DOM
// Використовуємо тіньовий DOM для інкапсуляції стилів та структури
const shadow = this.attachShadow({ mode: 'open' });
// 2. Внутрішня функція оновлення за замовчуванням
this._appReload = () => {
console.log('Стандартна функція: Перезавантаження сторінки');
// Стандартна дія - перезавантаження сторінки
window.location.reload();
};
// 3. Внутрішній стан
this._isReadyToReload = false; // Прапорець, що вказує на готовність до оновлення
// 4. Створення елементів (Внутрішній HTML)
shadow.innerHTML = `
<div id="swipe_updater">
<div id="swipe_block">
<svg id="swipe_icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" data-state="active">
<path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z"></path>
</svg>
</div>
</div>
<style>
:host {
display: block; /* Важливо для позиціонування кореневого елемента */
}
#swipe_updater {
position: absolute;
top: 0px;
width: 100%;
z-index: 0; /* Базовий z-index */
/* Використання CSS-змінних для кастомізації кольорів */
--swipe-color-theme1: var(--ColorThemes2, #525151); /* Колір фону іконки */
--swipe-color-theme2: var(--ColorThemes3, #f3f3f3); /* Колір іконки та рамки */
}
#swipe_block {
/* Розрахунок ширини та відступу для центрифікації в певних макетах */
width: calc(100% - 252px);
margin-left: 252px;
height: 50px;
display: flex;
justify-content: center;
align-items: flex-end;
position: relative;
}
#swipe_icon {
width: 20px;
fill: var(--swipe-color-theme2);
transform: rotate(0deg); /* Початковий стан: стрілка вниз */
position: absolute;
/* Початкове приховане позиціонування */
margin-top: -30px;
top: -30px;
background: var(--swipe-color-theme1);
border: 2px solid var(--swipe-color-theme2);
border-radius: 50%;
padding: 10px;
display: flex;
overflow: hidden;
height: 0;
opacity: 0;
/* Анімація: прихована іконка плавно з'являється (активується/деактивується) */
transition: height 0ms 450ms, opacity 450ms 0ms, transform 450ms;
}
#swipe_icon[data-state="active"] {
height: 20px;
margin-top: -45px;
top: -45px;
opacity: 1;
/* Анімація: активна іконка видима */
transition: height 0ms 0ms, opacity 450ms 0ms, transform 450ms;
}
/* Адаптивні стилі для зміни центрифікації на різних екранах */
@media (max-width: 1100px){
#swipe_block {
width: calc(100% - 122px);
margin-left: 122px;
}
}
@media (max-width: 700px), (max-height: 540px) {
#swipe_block {
width: 100%;
margin-left: 0;
}
/* Зміна кольорів для менших екранів */
#swipe_updater {
--swipe-color-theme1: var(--ColorThemes0, #525151);
--swipe-color-theme2: var(--ColorThemes3, #f3f3f3);;
}
}
</style>
`;
// 5. Збереження посилань на елементи Shadow DOM
this._animID = shadow.getElementById('swipe_updater');
this._animIconID = shadow.getElementById('swipe_icon');
// 6. Прив'язка контексту `this` для обробника подій (важливо для коректної роботи `this.handleScroll`)
this.handleScroll = this.handleScroll.bind(this);
}
/**
* Метод для встановлення користувацької функції оновлення.
* Замінює стандартне перезавантаження сторінки.
* @param {function} func - Користувацька функція, що буде викликана при свайпі.
*/
setReloadFunction(func) {
if (typeof func === 'function') {
this._appReload = func;
} else {
console.error('setReloadFunction вимагає передати функцію.');
}
}
/**
* Обробник події скролу (головна логіка Pull-to-Refresh).
* Відстежує прокручування вище верхньої межі сторінки (`window.scrollY < 0`).
*/
handleScroll() {
// Перевірка на режим Standalone (PWA) - функціональність актуальна переважно тут
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
if (isStandalone) {
let scrollY = window.scrollY;
// 1. Анімація іконки під час прокручування за верхню межу (scrollY < 0)
if (scrollY <= -10) {
// Зміщення іконки разом із прокруткою
this._animIconID.style.top = `${scrollY / 1.5}px`;
this._animID.style.zIndex = '115'; // Піднімаємо z-index для видимості над контентом
} else {
this._animID.style.zIndex = '0'; // Повертаємо базовий z-index
}
const threshold = -125; // Поріг прокрутки (наприклад, -125px) для активації оновлення
// 2. Логіка активації "готовий до оновлення"
if (scrollY <= threshold) {
if (!this._isReadyToReload) {
this._isReadyToReload = true;
// Поворот іконки на 180 градусів
this._animIconID.style.transform = 'rotate(180deg)';
this._animIconID.setAttribute('data-state', ''); // Деактивація стану "active"
}
}
// 3. Логіка виклику оновлення та скидання стану
// Якщо користувач відпускає свайп (scrollY повертається до >= 0) І був готовий до оновлення
else if (scrollY >= 0) {
if (this._isReadyToReload) {
// Виклик користувацької функції (або стандартного window.location.reload())
this._appReload();
// Скидання стану та анімації
this._isReadyToReload = false;
this._animIconID.style.transform = 'rotate(0deg)';
this._animIconID.setAttribute('data-state', 'active');
}
}
}
}
/**
* Lifecycle hook: викликається при додаванні елемента в DOM.
* Додаємо обробник події прокручування.
*/
connectedCallback() {
// Прослуховування глобальної події скролу
window.addEventListener('scroll', this.handleScroll);
}
/**
* Lifecycle hook: викликається при видаленні елемента з DOM.
* Видаляємо обробник події прокручування для запобігання витоку пам'яті.
*/
disconnectedCallback() {
window.removeEventListener('scroll', this.handleScroll);
}
}
// Реєстрація веб-компонента
customElements.define('swipe-updater', SwipeUpdater);
/*
============================
ПРИКЛАД ВИКОРИСТАННЯ
============================
*/
/*
1. Додайте цей елемент у свій HTML:
<swipe-updater id="swipe-updater"></swipe-updater>
2. Отримайте посилання на компонент у JS:
const Updater = document.querySelector('swipe-updater');
3. Користувацька функція оновлення
function customReload() {
const now = new Date().toLocaleTimeString();
console.log(`Користувацьке оновлення: оновлено о ${now}`);
document.querySelector('h1').textContent = `Сторінка оновлена о ${now}`;
// Тут можна виконати AJAX-запит, оновити DOM тощо.
}
4. Перевизначення функції оновлення компонента
if (Updater && Updater.setReloadFunction) {
Updater.setReloadFunction(customReload);
} else {
console.error('Компонент SwipeUpdater не знайдено або не готовий.');
}
💡 Приклад стандартного використання (якщо не викликати setReloadFunction):
<swipe-updater></swipe-updater>
При свайпі буде викликано window.location.reload();
*/

View File

@@ -1,5 +1,5 @@
const appTerritoryCardStyles = new CSSStyleSheet();
appTerritoryCardStyles.replaceSync(`
// Заміна вмісту таблиці стилів на надані CSS-правила.
const CARD_STYLES_CSS = `
:host {
display: inline-block;
box-sizing: border-box;
@@ -54,14 +54,15 @@ appTerritoryCardStyles.replaceSync(`
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
filter: blur(3px);
border-radius: calc(var(--border-radius, 15px) - 5px);
}
.contents {
position: absolute;
top: 0;
left: 0;
z-index: 1;
filter: blur(3px);
}
.contents {
position: relative;
z-index: 2;
background: rgb(64 64 64 / 0.7);
width: 100%;
@@ -89,7 +90,7 @@ appTerritoryCardStyles.replaceSync(`
/* Стили для режима 'sheep' */
/* Стилі для режиму 'sheep' */
.sheep {
margin: 10px;
max-height: 50px;
@@ -116,7 +117,7 @@ appTerritoryCardStyles.replaceSync(`
}
/* Стили для режима 'info' (прогресс) */
/* Стилі для режиму 'info' (прогресс) */
.info {
margin: 10px;
}
@@ -133,7 +134,7 @@ appTerritoryCardStyles.replaceSync(`
}
.info span {
z-index: 2;
font-size: var(--FontSize1, 12px);
font-size: var(--FontSize3, 14px);
color: var(--ColorThemes3, #f3f3f3);
}
.info p {
@@ -158,24 +159,45 @@ appTerritoryCardStyles.replaceSync(`
width: 100%;
height: 100%;
z-index: 10;
border-radius: calc(var(--border-radius, 15px) - 5px);
}
`);
`;
// Створення об'єкта CSSStyleSheet (якщо підтримується)
let appTerritoryCardStyles = null;
if (typeof CSSStyleSheet !== 'undefined' && CSSStyleSheet.prototype.replaceSync) {
appTerritoryCardStyles = new CSSStyleSheet(); // (2) Визначення об'єкта тут
appTerritoryCardStyles.replaceSync(CARD_STYLES_CSS);
}
/**
* Веб-компонент AppTerritoryCard.
* Відображає картку території з фоновим зображенням та різними режимами відображення.
*/
class AppTerritoryCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Додаємо стилі в конструкторі, якщо це adoptable
if (this.shadowRoot.adoptedStyleSheets) {
this.shadowRoot.adoptedStyleSheets = [appTerritoryCardStyles];
} else {
// FALLBACK для старих браузерів (наприклад, iOS < 16.4)
const style = document.createElement('style');
style.textContent = CARD_STYLES_CSS;
this.shadowRoot.appendChild(style);
}
}
// Определяем, какие атрибуты будем отслеживать
// Вказуємо, які атрибути ми хочемо відстежувати
static get observedAttributes() {
return ['image', 'address', 'sheep', 'link', 'atWork', 'quantity'];
return ['image', 'address', 'sheep', 'link', 'atWork', 'quantity', 'overdue'];
}
// Геттери та Сеттери для атрибутів
// Вони спрощують роботу з атрибутами як з властивостями DOM-елемента
get image() {
return this.getAttribute('image');
}
@@ -198,6 +220,11 @@ class AppTerritoryCard extends HTMLElement {
}
}
/** * Атрибут 'sheep' може приймати три стани:
* 1. null / відсутній: відключення блоку sheep та info
* 2. порожній рядок ('') / присутній без значення: Режим "Територія не опрацьовується"
* 3. рядок зі значенням: Режим "Територію опрацьовує: [значення]"
*/
get sheep() {
return this.getAttribute('sheep');
}
@@ -228,6 +255,7 @@ class AppTerritoryCard extends HTMLElement {
if (newValue === null) {
this.removeAttribute('atWork');
} else {
// Приводимо до рядка, оскільки атрибути завжди є рядками
this.setAttribute('atWork', String(newValue));
}
}
@@ -239,41 +267,74 @@ class AppTerritoryCard extends HTMLElement {
if (newValue === null) {
this.removeAttribute('quantity');
} else {
// Приводимо до рядка
this.setAttribute('quantity', String(newValue));
}
}
// Вызывается при добавлении элемента в DOM
get overdue() {
return this.getAttribute('address');
}
set overdue(newValue) {
if (newValue === null) {
this.removeAttribute('overdue');
} else {
this.setAttribute('overdue', newValue);
}
}
/**
* connectedCallback викликається, коли елемент додається в DOM.
* Тут ми викликаємо початкове рендеринг.
*/
connectedCallback() {
this.render();
}
// Вызывается при изменении одного из отслеживаемых атрибутов
/**
* attributeChangedCallback викликається при зміні одного зі спостережуваних атрибутів.
*/
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
this.render(); // Перерендеринг при зміні атрибута
}
}
/**
* Логіка рендерингу (відображення) вмісту компонента.
*/
render() {
const image = this.getAttribute('image') || '';
const address = this.getAttribute('address') || '';
const sheep = this.getAttribute('sheep'); // Может быть null или ""
const sheep = this.getAttribute('sheep');
const link = this.getAttribute('link') || '#';
const atWork = this.getAttribute('atWork'); // Может быть null
const quantity = this.getAttribute('quantity'); // Может быть null
const atWork = this.getAttribute('atWork');
const quantity = this.getAttribute('quantity');
const overdue = this.getAttribute('overdue') == 'true' ? true : false;
this.shadowRoot.innerHTML = ``;
// Додаємо стилі для старих браузерів
if (!this.shadowRoot.adoptedStyleSheets) {
const style = document.createElement('style');
style.textContent = CARD_STYLES_CSS;
this.shadowRoot.appendChild(style);
}
// --- Логика определения контента ---
let contentHTML = '';
// Перевіряємо, чи має бути увімкнений режим прогресу ('info'):
// обидва атрибути 'atWork' та 'quantity' присутні і є коректними числами.
const isProgressMode = atWork !== null && quantity !== null && !isNaN(parseInt(atWork)) && !isNaN(parseInt(quantity));
const hasSheep = sheep !== null && sheep !== '';
if (isProgressMode) {
// Режим прогресса (свободные подъезды)
// Режим прогресу (вільні під'їзди)
const atWorkNum = parseInt(atWork);
const quantityNum = parseInt(quantity);
const free = quantityNum - atWorkNum;
// Обчислення відсотка прогресу. Уникнення ділення на нуль.
const progressPercent = quantityNum > 0 ? (atWorkNum / quantityNum) * 100 : 100;
contentHTML = `
@@ -286,15 +347,15 @@ class AppTerritoryCard extends HTMLElement {
</div>
`;
} else if (sheep !== null && sheep !== '') {
// Режим ответственного
// Режим опрацювання (значення атрибута 'sheep' є ім'ям опрацювача)
contentHTML = `
<div class="sheep">
<div class="sheep" ${overdue ? `style="background: #bb4444;"` : ``}>
<span>Територію опрацьовує:</span>
<p>${sheep}</p>
</div>
`;
} else if (sheep !== null) {
// Режим "не опрацьовується"
} else if (sheep !== null && sheep === '') {
// Режим "не опрацьовується" (атрибут 'sheep' присутній, але порожній)
contentHTML = `
<div class="sheep">
<span>Територія не опрацьовується</span>
@@ -302,9 +363,9 @@ class AppTerritoryCard extends HTMLElement {
`;
}
// --- Сборка всего шаблона ---
this.shadowRoot.innerHTML = `
<div class="card">
// --- Складання всього шаблону ---
this.shadowRoot.innerHTML += `
<div class="card" ${overdue ? `title="Термін опрацювання минув!"` : ``}>
<img src="${image}" alt="${address}" />
<div class="contents">
<h1 class="address">${address}</h1>
@@ -316,8 +377,42 @@ class AppTerritoryCard extends HTMLElement {
}
}
// Регистрируем веб-компонент
// Реєструємо веб-компонент у браузері
customElements.define('app-territory-card', AppTerritoryCard);
// document.getElementById('app-territory-card-1').setAttribute('sheep', 'test')
/*
============================
ПРИКЛАД ВИКОРИСТАННЯ
============================
*/
/*
<app-territory-card
address="Вул. Прикладна, 15А"
image="https://example.com/images/territory-1.jpg"
link="/territory/15a"
atWork="12"
quantity="20"
></app-territory-card>
<app-territory-card
address="Просп. Науковий, 5"
image="https://example.com/images/territory-2.jpg"
link="/territory/naukovyi-5"
sheep="Іван Петренко"
></app-territory-card>
<app-territory-card
address="Майдан Свободи, 1"
image="https://example.com/images/territory-3.jpg"
link="/territory/svobody-1"
sheep=""
></app-territory-card>
<app-territory-card
address="Вул. Безіменна, 99"
image="https://example.com/images/territory-4.jpg"
link="/territory/bezymenna-99"
></app-territory-card>
*/