Files
site-portfolio/public/js/modules/cases/media-slider.js
T

210 lines
7.9 KiB
JavaScript

// Слайдер изображений для лонгридов кейсов (одна картинка + стрелки + точки).
// Разметка:
// <figure class="case-media case-media-slider" data-case-media-slider>
// <div class="case-media-slider__viewport" data-slider-viewport>
// <div class="case-media-slider__track" data-slider-track>
// <div class="case-media-slider__slide"><img ... data-image-viewer [data-src="..."]></div>
// ...
// </div>
// </div>
// <button class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">…</button>
// <button class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">…</button>
// <div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
// <button class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению N"></button>
// ...
// </div>
// </figure>
function clampIndex(i, len) {
if (len <= 0) return 0;
return Math.min(Math.max(i, 0), len - 1);
}
function loadSlide(slide) {
if (!slide) return;
const img = slide.querySelector("img[data-src]");
if (img && img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
}
function loadAround(slides, index) {
loadSlide(slides[index]);
loadSlide(slides[index - 1]);
loadSlide(slides[index + 1]);
}
export function initCaseMediaSlider() {
const roots = document.querySelectorAll("[data-case-media-slider]");
roots.forEach((root) => {
const viewport = root.querySelector("[data-slider-viewport]");
const track = root.querySelector("[data-slider-track]");
const prevBtn = root.querySelector("[data-slider-prev]");
const nextBtn = root.querySelector("[data-slider-next]");
const navBtns = [prevBtn, nextBtn].filter(Boolean);
// Имитация :active для иконки (swipe-обработчик viewport сбрасывает нативный :active)
navBtns.forEach((btn) => {
const press = () => btn.classList.add("is-pressed");
const release = () => btn.classList.remove("is-pressed");
btn.addEventListener("pointerdown", press);
btn.addEventListener("mousedown", press);
btn.addEventListener("pointerup", release);
btn.addEventListener("mouseup", release);
btn.addEventListener("pointerleave", release);
btn.addEventListener("mouseleave", release);
btn.addEventListener("pointercancel", release);
});
const dotsWrap = root.querySelector("[data-slider-dots]");
if (!viewport || !track) return;
// Авто-подстройка ширины под первую картинку — только для --natural (auto) слайдеров,
// и только если --slider-natural-w не задана вручную. Базовый/wide управляются CSS.
const firstImg = root.querySelector(".case-media-slider__slide img");
const applyAutoWidth = (img) => {
if (root.style.getPropertyValue("--slider-natural-w")) return;
const nw = img.naturalWidth || 0;
if (nw > 0) root.style.setProperty("--slider-natural-w", `${nw}px`);
};
if (root.classList.contains("case-media--natural") && firstImg) {
if (firstImg.complete && firstImg.naturalWidth > 0) {
applyAutoWidth(firstImg);
} else {
firstImg.addEventListener("load", () => applyAutoWidth(firstImg), { once: true });
}
}
const slides = Array.from(track.querySelectorAll(":scope > .case-media-slider__slide"));
const dots = dotsWrap
? Array.from(dotsWrap.querySelectorAll(":scope > [data-slider-dot]"))
: [];
const count = slides.length;
if (count === 0) return;
let current = 0;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
// Режим анимации переключения: fade (по умолчанию) / slide / none
let mode = root.getAttribute("data-slider-anim") || "fade";
function applyMode(m) {
mode = m;
root.classList.remove(
"case-media-slider--anim-slide",
"case-media-slider--anim-fade",
"case-media-slider--anim-none"
);
root.classList.add(`case-media-slider--anim-${m}`);
if (m === "fade") {
track.style.transform = "none";
} else {
track.style.transform = `translateX(${-100 * current}%)`;
}
}
applyMode(mode);
function goTo(index, behavior) {
const next = clampIndex(index, count);
current = next;
if (mode !== "fade") {
const offset = -100 * next;
track.style.transform = `translateX(${offset}%)`;
} else {
track.style.transform = "none";
}
track.style.transition = reduceMotion || behavior === "auto"
? "none"
: "";
slides.forEach((s, i) => {
s.classList.toggle("is-active", i === next);
s.setAttribute("aria-hidden", i === next ? "false" : "true");
});
dots.forEach((d, i) => {
const active = i === next;
d.classList.toggle("is-active", active);
d.setAttribute("aria-selected", active ? "true" : "false");
d.tabIndex = active ? 0 : -1;
});
if (prevBtn) prevBtn.disabled = next === 0;
if (nextBtn) nextBtn.disabled = next === count - 1;
loadAround(slides, next);
}
prevBtn?.addEventListener("click", () => goTo(current - 1));
nextBtn?.addEventListener("click", () => goTo(current + 1));
dots.forEach((dot, i) => {
dot.addEventListener("click", () => goTo(i));
});
// Клавиатура: стрелки при фокусе внутри слайдера.
root.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
goTo(current - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
goTo(current + 1);
}
});
// Поддержка свопа по клику на точку при навигации с клавиатуры.
dotsWrap?.addEventListener("keydown", (e) => {
if (e.key === "Home") {
e.preventDefault();
goTo(0);
} else if (e.key === "End") {
e.preventDefault();
goTo(count - 1);
}
});
// Свайп на тач/мыши (только горизонтальный).
let drag = null;
viewport.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
drag = { x: e.clientX, moved: false };
});
viewport.addEventListener("pointermove", (e) => {
if (!drag) return;
if (Math.abs(e.clientX - drag.x) > 6) drag.moved = true;
});
function endDrag(e) {
if (!drag) return;
const dx = e.clientX - drag.x;
if (drag.moved && Math.abs(dx) > 40) {
goTo(current + (dx < 0 ? 1 : -1));
}
drag = null;
}
viewport.addEventListener("pointerup", endDrag);
viewport.addEventListener("pointercancel", endDrag);
// Инициализация.
slides.forEach((s, i) => {
s.setAttribute("aria-hidden", i === 0 ? "false" : "true");
s.classList.toggle("is-active", i === 0);
});
goTo(0, "auto");
// UI-переключатель режима анимации (для демо-примера), если есть в разметке.
const animSwitch = root.querySelector("[data-slider-anim-switch]");
if (animSwitch) {
const btns = Array.from(animSwitch.querySelectorAll("button"));
const sync = () => btns.forEach((b) => {
b.setAttribute("aria-pressed", String(b.dataset.mode === mode));
});
sync();
btns.forEach((b) => {
b.addEventListener("click", () => {
applyMode(b.dataset.mode || "slide");
sync();
});
});
}
});
}