193 lines
5.3 KiB
JavaScript
193 lines
5.3 KiB
JavaScript
/**
|
|
* Horizontal product rails with under-rail dots + 3s autoplay ([data-product-slider]).
|
|
* RTL-safe scrollLeft (Chrome negative vs Firefox positive).
|
|
*/
|
|
export function initProductSliders() {
|
|
document.querySelectorAll("[data-product-slider]").forEach((root) => {
|
|
const rail = root.querySelector("[data-slider-rail]");
|
|
const dotsHost = root.querySelector("[data-slider-dots]");
|
|
if (!(rail instanceof HTMLElement)) return;
|
|
|
|
const rtl =
|
|
getComputedStyle(rail).direction === "rtl" ||
|
|
document.documentElement.dir === "rtl";
|
|
const AUTO_MS = 3000;
|
|
|
|
// Chrome/Safari RTL: scrollLeft goes negative; Firefox RTL: positive.
|
|
let rtlNegative = false;
|
|
if (rtl) {
|
|
const prevLeft = rail.scrollLeft;
|
|
rail.scrollLeft = -1;
|
|
rtlNegative = rail.scrollLeft < 0;
|
|
rail.scrollLeft = prevLeft;
|
|
}
|
|
|
|
const step = () => {
|
|
const card = rail.querySelector(".product-card");
|
|
const w = card instanceof HTMLElement ? card.getBoundingClientRect().width : 200;
|
|
const styles = getComputedStyle(rail);
|
|
const gap = Number.parseFloat(styles.columnGap || styles.gap || "1") || 1;
|
|
return Math.max(w + gap, 120);
|
|
};
|
|
|
|
function maxScroll() {
|
|
return Math.max(0, rail.scrollWidth - rail.clientWidth);
|
|
}
|
|
|
|
function canScroll() {
|
|
return maxScroll() > 4;
|
|
}
|
|
|
|
/** Absolute distance from the logical start (0…max), independent of engine sign. */
|
|
function scrollPos() {
|
|
return Math.abs(rail.scrollLeft);
|
|
}
|
|
|
|
function scrollToPos(pos, smooth) {
|
|
const max = maxScroll();
|
|
const target = Math.min(max, Math.max(0, pos));
|
|
let left = target;
|
|
if (rtl) left = rtlNegative ? -target : target;
|
|
rail.scrollTo({ left, behavior: smooth ? "smooth" : "auto" });
|
|
}
|
|
|
|
function pageCount() {
|
|
const max = maxScroll();
|
|
if (max <= 4) return 1;
|
|
return Math.floor(max / step()) + 1;
|
|
}
|
|
|
|
function currentPage() {
|
|
const pages = pageCount();
|
|
if (pages <= 1) return 0;
|
|
const s = step();
|
|
return Math.min(pages - 1, Math.round(scrollPos() / s));
|
|
}
|
|
|
|
function goToPage(index, smooth = true) {
|
|
const pages = pageCount();
|
|
const i = Math.max(0, Math.min(pages - 1, index));
|
|
const max = maxScroll();
|
|
if (i >= pages - 1) scrollToPos(max, smooth);
|
|
else scrollToPos(i * step(), smooth);
|
|
}
|
|
|
|
function goNext() {
|
|
const max = maxScroll();
|
|
if (max <= 4) return;
|
|
const pages = pageCount();
|
|
const next = currentPage() + 1;
|
|
if (next >= pages) goToPage(0, true);
|
|
else goToPage(next, true);
|
|
}
|
|
|
|
function renderDots() {
|
|
if (!(dotsHost instanceof HTMLElement)) return;
|
|
const pages = pageCount();
|
|
if (pages <= 1) {
|
|
dotsHost.hidden = true;
|
|
dotsHost.replaceChildren();
|
|
return;
|
|
}
|
|
dotsHost.hidden = false;
|
|
const active = currentPage();
|
|
if (dotsHost.childElementCount !== pages) {
|
|
const frag = document.createDocumentFragment();
|
|
for (let i = 0; i < pages; i++) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "slider-dot";
|
|
btn.setAttribute("role", "tab");
|
|
btn.setAttribute("aria-label", `صفحه ${i + 1}`);
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
goToPage(i, true);
|
|
startAuto();
|
|
});
|
|
frag.appendChild(btn);
|
|
}
|
|
dotsHost.replaceChildren(frag);
|
|
}
|
|
Array.from(dotsHost.children).forEach((el, i) => {
|
|
if (!(el instanceof HTMLElement)) return;
|
|
const on = i === active;
|
|
el.classList.toggle("is-active", on);
|
|
el.setAttribute("aria-selected", on ? "true" : "false");
|
|
if (on) el.setAttribute("aria-current", "true");
|
|
else el.removeAttribute("aria-current");
|
|
});
|
|
}
|
|
|
|
let timer = 0;
|
|
function stopAuto() {
|
|
window.clearInterval(timer);
|
|
timer = 0;
|
|
}
|
|
function startAuto() {
|
|
stopAuto();
|
|
if (!canScroll()) return;
|
|
timer = window.setInterval(goNext, AUTO_MS);
|
|
}
|
|
|
|
root.addEventListener("mouseenter", stopAuto);
|
|
root.addEventListener("mouseleave", startAuto);
|
|
root.addEventListener(
|
|
"focusin",
|
|
(e) => {
|
|
if (e.target instanceof Element && e.target.closest(".slider-dot")) {
|
|
/* keep running */
|
|
} else {
|
|
stopAuto();
|
|
}
|
|
},
|
|
true
|
|
);
|
|
root.addEventListener("focusout", () => {
|
|
window.setTimeout(() => {
|
|
if (!root.contains(document.activeElement)) startAuto();
|
|
}, 0);
|
|
});
|
|
|
|
rail.addEventListener(
|
|
"pointerdown",
|
|
() => {
|
|
stopAuto();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
rail.addEventListener(
|
|
"pointerup",
|
|
() => {
|
|
startAuto();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
|
|
rail.addEventListener(
|
|
"scroll",
|
|
() => {
|
|
renderDots();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
window.addEventListener(
|
|
"resize",
|
|
() => {
|
|
renderDots();
|
|
startAuto();
|
|
},
|
|
{ passive: true }
|
|
);
|
|
|
|
// Layout may not be final on first paint (images / fonts).
|
|
const kick = () => {
|
|
renderDots();
|
|
startAuto();
|
|
};
|
|
kick();
|
|
window.requestAnimationFrame(kick);
|
|
window.setTimeout(kick, 400);
|
|
window.addEventListener("load", kick, { once: true });
|
|
});
|
|
}
|