Додані повідомлення та перепрацьована структура застосунку та 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);