SHA256
145 lines
4.2 KiB
JavaScript
145 lines
4.2 KiB
JavaScript
function resolveElement(value) {
|
|
return typeof value === 'function' ? value() : value;
|
|
}
|
|
|
|
function buildArrowIcon() {
|
|
const icon = document.createElement('span');
|
|
icon.className = 'scroll-to-bottom-btn__icon';
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
icon.textContent = '↓';
|
|
return icon;
|
|
}
|
|
|
|
export function attachScrollToBottomButton({
|
|
scrollContainer,
|
|
mountTarget = document.querySelector('.app-shell'),
|
|
thresholdPx = 160,
|
|
title = 'Вниз',
|
|
} = {}) {
|
|
const target = resolveElement(mountTarget);
|
|
if (!(target instanceof Element)) {
|
|
return {
|
|
button: null,
|
|
refresh() {},
|
|
cleanup() {},
|
|
};
|
|
}
|
|
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'scroll-to-bottom-btn';
|
|
button.title = title;
|
|
button.setAttribute('aria-label', 'Прокрутить ленту вниз');
|
|
button.tabIndex = -1;
|
|
button.append(buildArrowIcon());
|
|
target.append(button);
|
|
|
|
let disposed = false;
|
|
let boundContainer = null;
|
|
let resizeObserver = null;
|
|
let mutationObserver = null;
|
|
let refreshFrame = 0;
|
|
|
|
const hide = () => {
|
|
button.classList.remove('is-visible');
|
|
button.setAttribute('aria-hidden', 'true');
|
|
button.tabIndex = -1;
|
|
};
|
|
|
|
const scheduleRefresh = () => {
|
|
if (disposed || refreshFrame) return;
|
|
refreshFrame = window.requestAnimationFrame(() => {
|
|
refreshFrame = 0;
|
|
refresh();
|
|
});
|
|
};
|
|
|
|
const unbindContainer = () => {
|
|
boundContainer?.removeEventListener('scroll', scheduleRefresh);
|
|
resizeObserver?.disconnect();
|
|
mutationObserver?.disconnect();
|
|
resizeObserver = null;
|
|
mutationObserver = null;
|
|
boundContainer = null;
|
|
};
|
|
|
|
const bindContainer = () => {
|
|
const nextContainer = resolveElement(scrollContainer);
|
|
if (!(nextContainer instanceof Element)) {
|
|
if (boundContainer) unbindContainer();
|
|
return null;
|
|
}
|
|
if (nextContainer === boundContainer) return boundContainer;
|
|
|
|
unbindContainer();
|
|
boundContainer = nextContainer;
|
|
boundContainer.addEventListener('scroll', scheduleRefresh, { passive: true });
|
|
|
|
if (typeof ResizeObserver === 'function') {
|
|
resizeObserver = new ResizeObserver(scheduleRefresh);
|
|
resizeObserver.observe(boundContainer);
|
|
}
|
|
|
|
if (typeof MutationObserver === 'function') {
|
|
mutationObserver = new MutationObserver(scheduleRefresh);
|
|
mutationObserver.observe(boundContainer, { childList: true, subtree: true });
|
|
}
|
|
|
|
return boundContainer;
|
|
};
|
|
|
|
function refresh() {
|
|
if (disposed) return;
|
|
const container = bindContainer();
|
|
if (!container) {
|
|
hide();
|
|
return;
|
|
}
|
|
|
|
const scrollHeight = Number(container.scrollHeight || 0);
|
|
const clientHeight = Number(container.clientHeight || 0);
|
|
const scrollTop = Number(container.scrollTop || 0);
|
|
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
|
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
|
const shouldShow = maxScrollTop > 8 && distanceToBottom > Math.max(24, Number(thresholdPx || 0));
|
|
|
|
button.classList.toggle('is-visible', shouldShow);
|
|
button.setAttribute('aria-hidden', shouldShow ? 'false' : 'true');
|
|
button.tabIndex = shouldShow ? 0 : -1;
|
|
}
|
|
|
|
const scrollToBottom = () => {
|
|
const container = bindContainer();
|
|
if (!container) return;
|
|
if (typeof container.scrollTo === 'function') {
|
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
|
} else {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
scheduleRefresh();
|
|
};
|
|
|
|
button.addEventListener('click', scrollToBottom);
|
|
window.addEventListener('resize', scheduleRefresh);
|
|
window.visualViewport?.addEventListener('resize', scheduleRefresh);
|
|
window.requestAnimationFrame(refresh);
|
|
|
|
return {
|
|
button,
|
|
refresh: scheduleRefresh,
|
|
cleanup() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
if (refreshFrame) {
|
|
window.cancelAnimationFrame(refreshFrame);
|
|
refreshFrame = 0;
|
|
}
|
|
unbindContainer();
|
|
window.removeEventListener('resize', scheduleRefresh);
|
|
window.visualViewport?.removeEventListener('resize', scheduleRefresh);
|
|
button.removeEventListener('click', scrollToBottom);
|
|
button.remove();
|
|
},
|
|
};
|
|
}
|