Улучшить раскладку чата при клавиатуре

This commit is contained in:
2026-08-20 07:12:46 +03:00
parent 4656a03ea9
commit 8741be6cba
6 changed files with 529 additions and 104 deletions
+115 -3
View File
@@ -82,6 +82,15 @@ function formatChatRowTime(ts) {
}).format(new Date(value));
}
function compareChatRows(a, b) {
const timeA = Number(a?.lastTimeMs || 0);
const timeB = Number(b?.lastTimeMs || 0);
if (timeA !== timeB) return timeB - timeA;
const nameA = String(a?.name || '').toLowerCase();
const nameB = String(b?.name || '').toLowerCase();
return nameA.localeCompare(nameB, 'ru');
}
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
export function render({ navigate, chrome }) {
@@ -99,11 +108,103 @@ export function render({ navigate, chrome }) {
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
<div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню контактов" aria-haspopup="menu" aria-expanded="false">
<span class="dm-head-menu-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
</button>
</div>
</div>
`;
const headName = head.querySelector('.dm-head-name');
if (headName) headName.textContent = login;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuButton = head.querySelector('.dm-head-menu-btn');
const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
// header bounds. A dropdown overflowing below it can look visible but clicks may
// land on the content layer underneath. Render the open menu as a body portal.
menuTemplate?.remove();
let menuPortal = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuPortal = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuWrap?.classList.remove('is-open');
};
const positionHeadMenu = () => {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
menuPortal.style.top = `${Math.round(rect.bottom + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск контактов</span>
</button>
`;
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeHeadMenu();
navigate('contact-search-view');
});
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuWrap?.classList.add('is-open');
positionHeadMenu();
};
menuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (menuPortal) closeHeadMenu();
else openHeadMenu();
});
const onOutsideClick = (event) => {
if (!menuPortal) return;
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
menuButton?.focus();
};
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const divider = document.createElement('div');
divider.className = 'dm-divider';
@@ -175,6 +276,7 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: false,
lastTimeMs,
};
});
@@ -197,10 +299,11 @@ export function render({ navigate, chrome }) {
time: formatChatRowTime(lastTimeMs),
unread,
notInContacts: true,
lastTimeMs,
};
});
const rows = [...contactRows, ...extraRows];
const rows = [...contactRows, ...extraRows].sort(compareChatRows);
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
@@ -251,5 +354,14 @@ export function render({ navigate, chrome }) {
chrome?.setTopbar(head);
screen.append(divider, list);
loadList();
screen.cleanup = () => {
closeHeadMenu();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen;
}