first deployment of kikomiko with url photos
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8d98304137
commit
ae3ed7c7b7
Vendored
+192
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user