Files
site-portfolio/public/js/modules/cases/media-slider.js
T
root 95b4eaca93 feat: галерея-слайдер, листалка в просмотрщике и TUI braille-спиннер
- case-media-slider: переиспользуемый слайдер (prev/next + точки, swipe, клавиатура, lazy)
- image-viewer: листалка prev/next в тулбаре как пилюли, пагинация-точки в тулбаре слева,
  группа масштаба (slider + текст) с gap 0.25rem, текст масштаба --text-muted/400
- tui-spinner: переиспользуемый braille-спиннер (чистый CSS), лоадер просмотрщика
- design-system.html: живой пример TUI braille-спиннера
- fix: удалён мёртвый textContent='Загрузка...', убивавший .tui-spinner при повторном открытии
2026-09-09 17:49:56 +02:00

142 lines
5.0 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 dotsWrap = root.querySelector("[data-slider-dots]");
if (!viewport || !track) return;
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;
function goTo(index, behavior) {
const next = clampIndex(index, count);
current = next;
const offset = -100 * next;
track.style.transform = `translateX(${offset}%)`;
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");
});
}