210 lines
7.9 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|