92 lines
2.9 KiB
JavaScript
92 lines
2.9 KiB
JavaScript
|
|
/**
|
||
|
|
* List page tab bar — horizontal scroll behavior.
|
||
|
|
* Keeps tabs on a single line, scrolls the active tab into view on mount,
|
||
|
|
* and toggles has-overflow/at-start/at-end classes that drive the CSS
|
||
|
|
* gradient fade masks.
|
||
|
|
*/
|
||
|
|
import { resolveHost } from '../core/app-dom.js';
|
||
|
|
|
||
|
|
const BOUND_ATTR = 'data-app-list-tabs-bound';
|
||
|
|
const EDGE_TOLERANCE = 1;
|
||
|
|
|
||
|
|
const updateOverflow = (el) => {
|
||
|
|
const hasOverflow = el.scrollWidth - el.clientWidth > EDGE_TOLERANCE;
|
||
|
|
el.classList.toggle('has-overflow', hasOverflow);
|
||
|
|
if (!hasOverflow) {
|
||
|
|
el.classList.remove('at-start', 'at-end');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const atStart = el.scrollLeft <= EDGE_TOLERANCE;
|
||
|
|
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - EDGE_TOLERANCE;
|
||
|
|
el.classList.toggle('at-start', atStart);
|
||
|
|
el.classList.toggle('at-end', atEnd);
|
||
|
|
};
|
||
|
|
|
||
|
|
const scrollActiveIntoView = (el) => {
|
||
|
|
const active = el.querySelector('.is-active');
|
||
|
|
if (!active) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const activeCenter = active.offsetLeft + active.offsetWidth / 2;
|
||
|
|
const target = activeCenter - el.clientWidth / 2;
|
||
|
|
const max = el.scrollWidth - el.clientWidth;
|
||
|
|
el.scrollLeft = Math.max(0, Math.min(target, max));
|
||
|
|
};
|
||
|
|
|
||
|
|
export function initListTabs(root = document, options = {}) {
|
||
|
|
const selector = String(options.selector || '.app-list-tabs').trim() || '.app-list-tabs';
|
||
|
|
const host = resolveHost(root);
|
||
|
|
const cleanupFns = [];
|
||
|
|
const bound = [];
|
||
|
|
|
||
|
|
const tabs = Array.from(host.querySelectorAll(selector));
|
||
|
|
tabs.forEach((el) => {
|
||
|
|
if (el.getAttribute(BOUND_ATTR) === '1') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
el.setAttribute(BOUND_ATTR, '1');
|
||
|
|
bound.push(el);
|
||
|
|
|
||
|
|
// Initial scroll-into-view uses instant positioning (no smooth behavior
|
||
|
|
// before the strip has finished layout — smooth kicks in on user scroll).
|
||
|
|
const previousBehavior = el.style.scrollBehavior;
|
||
|
|
el.style.scrollBehavior = 'auto';
|
||
|
|
scrollActiveIntoView(el);
|
||
|
|
el.style.scrollBehavior = previousBehavior;
|
||
|
|
|
||
|
|
updateOverflow(el);
|
||
|
|
|
||
|
|
const onScroll = () => updateOverflow(el);
|
||
|
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||
|
|
cleanupFns.push(() => el.removeEventListener('scroll', onScroll));
|
||
|
|
|
||
|
|
if (typeof ResizeObserver !== 'undefined') {
|
||
|
|
const resizeObserver = new ResizeObserver(() => updateOverflow(el));
|
||
|
|
resizeObserver.observe(el);
|
||
|
|
cleanupFns.push(() => resizeObserver.disconnect());
|
||
|
|
} else {
|
||
|
|
const onResize = () => updateOverflow(el);
|
||
|
|
window.addEventListener('resize', onResize, { passive: true });
|
||
|
|
cleanupFns.push(() => window.removeEventListener('resize', onResize));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const destroy = () => {
|
||
|
|
cleanupFns.forEach((fn) => {
|
||
|
|
try {
|
||
|
|
fn();
|
||
|
|
} catch {
|
||
|
|
/* ignore */
|
||
|
|
}
|
||
|
|
});
|
||
|
|
cleanupFns.length = 0;
|
||
|
|
bound.forEach((el) => {
|
||
|
|
el.removeAttribute(BOUND_ATTR);
|
||
|
|
el.classList.remove('has-overflow', 'at-start', 'at-end');
|
||
|
|
});
|
||
|
|
bound.length = 0;
|
||
|
|
};
|
||
|
|
|
||
|
|
return { destroy };
|
||
|
|
}
|