SHA256
Обновить UI профиля и навигации
This commit is contained in:
@@ -1,88 +1,157 @@
|
||||
let activeDropdown = null;
|
||||
|
||||
function normalizeItems(items) {
|
||||
const value = typeof items === 'function' ? items() : items;
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function resolvePlacement({ placement, align }) {
|
||||
if (placement === 'bottom-start' || placement === 'top-start') return 'left';
|
||||
if (placement === 'bottom-end' || placement === 'top-end') return 'right';
|
||||
return align === 'left' ? 'left' : 'right';
|
||||
}
|
||||
|
||||
export function createDropdownMenu({
|
||||
anchorEl,
|
||||
items = [],
|
||||
renderContent = null,
|
||||
className = '',
|
||||
minWidth = 210,
|
||||
offset = 7,
|
||||
align = 'right',
|
||||
placement = 'bottom-end',
|
||||
align = null,
|
||||
leftShift = 0,
|
||||
transparent = false,
|
||||
dimBackground = true,
|
||||
keepAnchorPressed = true,
|
||||
onOpen = null,
|
||||
onClose = null,
|
||||
} = {}) {
|
||||
let portal = null;
|
||||
let menuEl = null;
|
||||
let destroyed = false;
|
||||
|
||||
const close = () => {
|
||||
const setAnchorOpen = (isOpen) => {
|
||||
if (!anchorEl) return;
|
||||
anchorEl.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (keepAnchorPressed && isOpen) anchorEl.dataset.open = 'true';
|
||||
else delete anchorEl.dataset.open;
|
||||
};
|
||||
|
||||
const close = ({ focusAnchor = false } = {}) => {
|
||||
if (!portal) return;
|
||||
portal.remove();
|
||||
portal = null;
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
menuEl = null;
|
||||
if (activeDropdown === api) activeDropdown = null;
|
||||
setAnchorOpen(false);
|
||||
onClose?.();
|
||||
if (focusAnchor) anchorEl?.focus?.();
|
||||
};
|
||||
|
||||
const position = () => {
|
||||
if (!portal || !anchorEl) return;
|
||||
if (!portal || !menuEl || !anchorEl) return;
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const menuWidth = portal.offsetWidth || minWidth;
|
||||
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const menuWidth = menuEl.offsetWidth || minWidth;
|
||||
const side = resolvePlacement({ placement, align });
|
||||
const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth;
|
||||
const desiredLeft = baseLeft - Number(leftShift || 0);
|
||||
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
|
||||
let top = rect.bottom + offset;
|
||||
const menuHeight = portal.offsetHeight || 180;
|
||||
if (top + menuHeight > window.innerHeight - margin) {
|
||||
|
||||
const menuHeight = menuEl.offsetHeight || 180;
|
||||
const prefersTop = String(placement || '').startsWith('top-');
|
||||
let top = prefersTop ? rect.top - menuHeight - offset : rect.bottom + offset;
|
||||
if (!prefersTop && top + menuHeight > window.innerHeight - margin) {
|
||||
top = Math.max(margin, rect.top - menuHeight - offset);
|
||||
} else if (prefersTop && top < margin) {
|
||||
top = Math.min(window.innerHeight - menuHeight - margin, rect.bottom + offset);
|
||||
}
|
||||
portal.style.left = `${Math.round(left)}px`;
|
||||
portal.style.top = `${Math.round(top)}px`;
|
||||
|
||||
menuEl.style.left = `${Math.round(left)}px`;
|
||||
menuEl.style.top = `${Math.round(top)}px`;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!anchorEl || portal) return;
|
||||
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
|
||||
const menu = document.createElement('div');
|
||||
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.style.minWidth = `${minWidth}px`;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item?.divider) {
|
||||
const appendItems = () => {
|
||||
normalizeItems(items).forEach((item) => {
|
||||
if (item.divider) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'channel-menu-divider';
|
||||
menu.append(divider);
|
||||
divider.className = 'dropdown-menu__divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
menuEl.append(divider);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
|
||||
btn.setAttribute('role', 'menuitem');
|
||||
if (item?.iconHtml) {
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `dropdown-menu__item${item.selected ? ' is-selected' : ''}${item.danger ? ' is-danger' : ''}${item.className ? ` ${item.className}` : ''}`;
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.disabled = Boolean(item.disabled);
|
||||
|
||||
if (item.iconHtml) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'shared-dropdown-menu__icon';
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.innerHTML = item.iconHtml;
|
||||
btn.append(icon);
|
||||
} else if (item?.iconSrc) {
|
||||
button.append(icon);
|
||||
} else if (item.iconSrc) {
|
||||
const icon = document.createElement('img');
|
||||
icon.className = 'dropdown-menu__icon';
|
||||
icon.src = item.iconSrc;
|
||||
icon.alt = '';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.append(icon);
|
||||
button.append(icon);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(item?.label || '');
|
||||
btn.append(label);
|
||||
btn.addEventListener('click', (event) => {
|
||||
label.textContent = String(item.label || '');
|
||||
button.append(label);
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (button.disabled) return;
|
||||
close();
|
||||
item?.action?.();
|
||||
await item.action?.();
|
||||
});
|
||||
menu.append(btn);
|
||||
menuEl.append(button);
|
||||
});
|
||||
};
|
||||
|
||||
menu.addEventListener('click', (event) => event.stopPropagation());
|
||||
document.body.append(menu);
|
||||
portal = menu;
|
||||
anchorEl.setAttribute('aria-expanded', 'true');
|
||||
const open = () => {
|
||||
if (destroyed || !anchorEl || portal) return;
|
||||
if (activeDropdown && activeDropdown !== api) activeDropdown.close();
|
||||
|
||||
portal = document.createElement('div');
|
||||
portal.className = `dropdown-portal${dimBackground ? ' dropdown-portal--dim' : ''}`;
|
||||
|
||||
if (dimBackground) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'dropdown-backdrop';
|
||||
backdrop.setAttribute('aria-hidden', 'true');
|
||||
backdrop.addEventListener('pointerdown', (event) => {
|
||||
if (event.target === backdrop) close();
|
||||
});
|
||||
portal.append(backdrop);
|
||||
}
|
||||
|
||||
menuEl = document.createElement('div');
|
||||
menuEl.className = `dropdown-menu${transparent ? ' dropdown-menu--transparent' : ''}${className ? ` ${className}` : ''}`;
|
||||
menuEl.setAttribute('role', 'menu');
|
||||
menuEl.style.minWidth = `${minWidth}px`;
|
||||
|
||||
if (typeof renderContent === 'function') {
|
||||
const content = renderContent({ close, menuEl });
|
||||
if (content instanceof Node) menuEl.append(content);
|
||||
} else {
|
||||
appendItems();
|
||||
}
|
||||
|
||||
menuEl.addEventListener('pointerdown', (event) => event.stopPropagation());
|
||||
menuEl.addEventListener('click', (event) => event.stopPropagation());
|
||||
portal.append(menuEl);
|
||||
document.body.append(portal);
|
||||
activeDropdown = api;
|
||||
setAnchorOpen(true);
|
||||
onOpen?.();
|
||||
position();
|
||||
};
|
||||
@@ -97,43 +166,49 @@ export function createDropdownMenu({
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
};
|
||||
const onOutsideClick = (event) => {
|
||||
const onOutsidePointerDown = (event) => {
|
||||
if (!portal) return;
|
||||
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onPeerDropdownOpen = (event) => {
|
||||
if (!portal || event?.detail?.owner === anchorEl) return;
|
||||
if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key !== 'Escape' || !portal) return;
|
||||
close();
|
||||
anchorEl?.focus();
|
||||
close({ focusAnchor: true });
|
||||
};
|
||||
const onNavigation = () => close();
|
||||
const onViewportChange = () => position();
|
||||
|
||||
anchorEl?.setAttribute('aria-haspopup', 'menu');
|
||||
anchorEl?.setAttribute('aria-expanded', 'false');
|
||||
setAnchorOpen(false);
|
||||
anchorEl?.addEventListener('click', onAnchorClick);
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.addEventListener('resize', position, { passive: true });
|
||||
window.addEventListener('scroll', position, { passive: true, capture: true });
|
||||
window.addEventListener('popstate', onNavigation);
|
||||
window.addEventListener('hashchange', onNavigation);
|
||||
window.addEventListener('resize', onViewportChange, { passive: true });
|
||||
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
const api = {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
position,
|
||||
get isOpen() {
|
||||
return Boolean(portal);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
close();
|
||||
anchorEl?.removeEventListener('click', onAnchorClick);
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
|
||||
window.removeEventListener('resize', position);
|
||||
window.removeEventListener('scroll', position, true);
|
||||
window.removeEventListener('popstate', onNavigation);
|
||||
window.removeEventListener('hashchange', onNavigation);
|
||||
window.removeEventListener('resize', onViewportChange);
|
||||
window.removeEventListener('scroll', onViewportChange, true);
|
||||
setAnchorOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
|
||||
const wrap = document.createElement('header');
|
||||
wrap.className = 'page-header app-topbar-shell';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'header-left';
|
||||
if (leftAction) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
const rawLabel = String(leftAction.label || '').trim();
|
||||
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '‹';
|
||||
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
|
||||
btn.textContent = isBackAction ? '←' : rawLabel;
|
||||
if (isBackAction) {
|
||||
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
|
||||
btn.title = leftAction.title || 'Назад';
|
||||
}
|
||||
btn.addEventListener('click', leftAction.onClick);
|
||||
left.append(btn);
|
||||
}
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'header-left-label';
|
||||
label.textContent = leftLabel;
|
||||
left.append(label);
|
||||
}
|
||||
|
||||
const center = document.createElement('div');
|
||||
center.className = 'header-center';
|
||||
if (centerNode instanceof Node) {
|
||||
center.append(centerNode);
|
||||
} else {
|
||||
const h1 = document.createElement('h1');
|
||||
h1.className = 'page-title';
|
||||
h1.textContent = title;
|
||||
center.append(h1);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'header-actions';
|
||||
rightActions.forEach((action) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) btn.title = action.title;
|
||||
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.iconNode instanceof Node) {
|
||||
btn.append(action.iconNode);
|
||||
} else {
|
||||
btn.textContent = action.label;
|
||||
}
|
||||
btn.addEventListener('click', action.onClick);
|
||||
right.append(btn);
|
||||
});
|
||||
|
||||
wrap.append(left, center, right);
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
if (!target || !(node instanceof Node)) return;
|
||||
target.append(node);
|
||||
}
|
||||
|
||||
function createActionButton(action = {}, cleanupFns) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__action${action.className ? ` ${action.className}` : ''}`;
|
||||
if (action.title) button.title = action.title;
|
||||
if (action.ariaLabel) button.setAttribute('aria-label', action.ariaLabel);
|
||||
if (action.id) button.dataset.action = action.id;
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
|
||||
if (action.menu) {
|
||||
const menu = createDropdownMenu({
|
||||
anchorEl: button,
|
||||
transparent: true,
|
||||
dimBackground: true,
|
||||
keepAnchorPressed: true,
|
||||
...(typeof action.menu === 'object' ? action.menu : {}),
|
||||
});
|
||||
cleanupFns.add(() => menu.destroy());
|
||||
} else if (typeof action.onClick === 'function') {
|
||||
button.addEventListener('click', action.onClick);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createTopBar({
|
||||
title = '',
|
||||
className = '',
|
||||
left = null,
|
||||
center = null,
|
||||
back = null,
|
||||
leftLabel = '',
|
||||
actions = [],
|
||||
} = {}) {
|
||||
const cleanupFns = new Set();
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = `topbar${className ? ` ${className}` : ''}`;
|
||||
|
||||
const leftSlot = document.createElement('div');
|
||||
leftSlot.className = 'topbar__left';
|
||||
|
||||
const backAction = back;
|
||||
if (backAction?.visible !== false && typeof backAction?.onClick === 'function') {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
leftSlot.append(button);
|
||||
}
|
||||
|
||||
appendNode(leftSlot, left);
|
||||
|
||||
if (leftLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'topbar__left-label';
|
||||
label.textContent = leftLabel;
|
||||
leftSlot.append(label);
|
||||
}
|
||||
|
||||
const centerSlot = document.createElement('div');
|
||||
centerSlot.className = 'topbar__center';
|
||||
const resolvedCenter = center;
|
||||
if (resolvedCenter instanceof Node) {
|
||||
centerSlot.append(resolvedCenter);
|
||||
} else {
|
||||
const heading = document.createElement('h1');
|
||||
heading.className = 'topbar__title';
|
||||
heading.textContent = String(title || '');
|
||||
centerSlot.append(heading);
|
||||
}
|
||||
|
||||
const rightSlot = document.createElement('div');
|
||||
rightSlot.className = 'topbar__right';
|
||||
const normalizedActions = actions;
|
||||
normalizedActions.forEach((action) => {
|
||||
rightSlot.append(createActionButton(action, cleanupFns));
|
||||
});
|
||||
|
||||
topbar.append(leftSlot, centerSlot, rightSlot);
|
||||
|
||||
topbar.addCleanup = (cleanup) => {
|
||||
if (typeof cleanup === 'function') cleanupFns.add(cleanup);
|
||||
return cleanup;
|
||||
};
|
||||
topbar.cleanup = () => {
|
||||
for (const cleanup of cleanupFns) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
console.warn('[TopBar] cleanup failed', error);
|
||||
}
|
||||
}
|
||||
cleanupFns.clear();
|
||||
};
|
||||
|
||||
return topbar;
|
||||
}
|
||||
Reference in New Issue
Block a user