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 при повторном открытии
This commit is contained in:
root committed 2026-09-09 17:49:56 +02:00
1 parent 9b91ed165f
commit 95b4eaca93
14 files changed
+753 -34

No files matched your search

+141 -16
View File
@@ -15,8 +15,19 @@ export function initCaseImageViewer() {
overlay.hidden = true;
overlay.innerHTML = `
<div class="case-image-viewer__toolbar glass-neutral" role="toolbar" aria-label="Просмотр изображения">
<span class="case-image-viewer__zoom" data-imgv-zoom-label>100%</span>
<input class="case-image-viewer__slider" type="range" min="1" max="150" step="1" value="100" data-imgv-slider aria-label="Масштаб изображения" />
<div class="case-image-viewer__nav-group" role="group" aria-label="Навигация по изображениям">
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn case-image-viewer__btn--nav" data-imgv-prev aria-label="Предыдущее изображение" hidden>
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/shevron-left.svg')" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn case-image-viewer__btn--nav" data-imgv-next aria-label="Следующее изображение" hidden>
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/shevron-right.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="case-image-viewer__dots" data-imgv-dots role="tablist" aria-label="Выбор изображения" hidden></div>
<div class="case-image-viewer__scale-group" role="group" aria-label="Масштаб">
<input class="case-image-viewer__slider" type="range" min="1" max="150" step="1" value="100" data-imgv-slider aria-label="Масштаб изображения" />
<span class="case-image-viewer__zoom" data-imgv-zoom-label>100%</span>
</div>
<button type="button" class="btn btn--secondary btn--flat case-image-viewer__btn" data-imgv-fit aria-label="Уместить на экран">
<span class="case-image-viewer__btn-icon" style="--imgv-icon:url('../assets/shared/icons/zoom_to_fit.svg')" aria-hidden="true"></span>
</button>
@@ -28,7 +39,7 @@ export function initCaseImageViewer() {
</button>
</div>
<div class="case-image-viewer__stage" data-imgv-stage tabindex="0">
<div class="case-image-viewer__loading" data-imgv-loading aria-live="polite">Загрузка...</div>
<div class="case-image-viewer__loader" data-imgv-loading aria-label="Загрузка изображения" role="status"><span class="tui-spinner" aria-hidden="true"><span>⠋</span><span>⠙</span><span>⠹</span><span>⠸</span><span>⠼</span><span>⠴</span><span>⠦</span><span>⠧</span><span>⠇</span><span>⠏</span></span><span class="case-image-viewer__loader-msg" data-imgv-loader-msg></span></div>
<img class="case-image-viewer__img" data-imgv-img alt="" />
</div>
`;
@@ -37,11 +48,15 @@ export function initCaseImageViewer() {
const stage = overlay.querySelector("[data-imgv-stage]");
const viewImg = overlay.querySelector("[data-imgv-img]");
const loading = overlay.querySelector("[data-imgv-loading]");
const loaderMsg = overlay.querySelector("[data-imgv-loader-msg]");
const zoomLabel = overlay.querySelector("[data-imgv-zoom-label]");
const zoomSlider = overlay.querySelector("[data-imgv-slider]");
const btnFit = overlay.querySelector("[data-imgv-fit]");
const btnOriginal = overlay.querySelector("[data-imgv-original]");
const btnClose = overlay.querySelector("[data-imgv-close]");
const btnPrev = overlay.querySelector("[data-imgv-prev]");
const btnNext = overlay.querySelector("[data-imgv-next]");
const dotsWrap = overlay.querySelector("[data-imgv-dots]");
let scale = 1;
let naturalW = 0;
@@ -56,11 +71,33 @@ export function initCaseImageViewer() {
let panScrollLeft = 0;
let panScrollTop = 0;
let panMoved = false;
let panDirX = 0;
let closeTimer = 0;
let openRaf = 0;
const viewerAnimMs = 210;
let centeredAnchor = null;
// Состояние листалки: группа картинок (например, одна галерея) + индекс в ней.
let groupImgs = [];
let groupIndex = -1;
function resolveGroup(img) {
// Поднимаемся до ближайшего контейнера группы, пропуская отдельные ячейки/слайды.
const container = img.closest(
".case-media-gallery, .case-media-slider, .case-media, figure",
);
const scope = container || img.parentElement || document;
const list = Array.from(
scope.querySelectorAll("img[data-image-viewer]"),
).filter((el) => {
if (el.hasAttribute("data-src")) return false;
const s = el.getAttribute("src") || el.currentSrc || "";
if (isViewerThumbnailPlaceholder(s)) return false;
return true;
});
return list;
}
function measureToolbarSpace() {
const toolbarRect = overlay.querySelector(".case-image-viewer__toolbar")?.getBoundingClientRect();
const next = toolbarRect ? Math.ceil(toolbarRect.height + 22) : 0;
@@ -160,7 +197,12 @@ export function initCaseImageViewer() {
centeredAnchor = null;
}
function openViewer(src, alt) {
function showAt(group, index) {
if (index < 0 || index >= group.length) return;
groupIndex = index;
const img = group[groupIndex];
const src = img.getAttribute("src") || img.currentSrc || "";
const alt = img.alt || "";
if (closeTimer) {
window.clearTimeout(closeTimer);
closeTimer = 0;
@@ -171,15 +213,52 @@ export function initCaseImageViewer() {
}
overlay.classList.remove("is-open");
overlay.classList.add("is-loading");
if (loading) loading.classList.remove("is-error");
if (loaderMsg) loaderMsg.textContent = "";
centeredAnchor = null;
viewImg.onload = null;
viewImg.onerror = null;
viewImg.src = src;
viewImg.alt = alt || "";
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
overlay.hidden = false;
document.documentElement.classList.add("imgv-lock");
active = true;
viewImg.alt = alt;
// Открываем просмотрщик сразу (поверх показывается спиннер), не дожидаясь загрузки.
openRaf = window.requestAnimationFrame(() => {
overlay.classList.add("is-open");
openRaf = 0;
});
// Навигация внутри группы (галерея / слайдер). Скрываем, если картинка одна.
const multi = group.length > 1;
btnPrev.hidden = !multi;
btnNext.hidden = !multi;
if (multi) {
// Точки-пагинация в тулбаре.
if (dotsWrap.childElementCount !== group.length) {
dotsWrap.innerHTML = group
.map(
(_, i) =>
`<button type="button" class="case-image-viewer__dot" data-imgv-dot="${i}" role="tab" aria-label="Перейти к изображению ${i + 1}"></button>`,
)
.join("");
dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot) => {
dot.addEventListener("click", (e) => {
e.stopPropagation();
const i = Number(dot.getAttribute("data-imgv-dot"));
showAt(group, i);
});
});
}
dotsWrap.hidden = false;
dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot, i) => {
const active = i === groupIndex;
dot.classList.toggle("is-active", active);
dot.setAttribute("aria-selected", active ? "true" : "false");
dot.tabIndex = active ? 0 : -1;
});
btnPrev.disabled = groupIndex === 0;
btnNext.disabled = groupIndex === group.length - 1;
} else {
dotsWrap.hidden = true;
}
const applyAfterLoad = () => {
overlay.classList.remove("is-loading");
@@ -203,10 +282,10 @@ export function initCaseImageViewer() {
const applyLoadError = () => {
overlay.classList.remove("is-loading");
if (loading) loading.textContent = "Не удалось загрузить";
if (loading) loading.classList.add("is-error");
if (loaderMsg) loaderMsg.textContent = "Не удалось загрузить";
};
if (loading) loading.textContent = "Загрузка...";
if (viewImg.complete && viewImg.naturalWidth > 0) applyAfterLoad();
else {
viewImg.onload = applyAfterLoad;
@@ -228,7 +307,8 @@ export function initCaseImageViewer() {
overlay.hidden = true;
stage.classList.remove("is-center-y");
stage.classList.remove("is-centered");
if (loading) loading.textContent = "Загрузка...";
if (loading) loading.classList.remove("is-error");
if (loaderMsg) loaderMsg.textContent = "";
viewImg.onload = null;
viewImg.onerror = null;
viewImg.removeAttribute("src");
@@ -249,7 +329,15 @@ export function initCaseImageViewer() {
const src = img.currentSrc || img.getAttribute("src") || "";
if (!src || isViewerThumbnailPlaceholder(src)) return;
e.preventDefault();
openViewer(src, img.alt || "");
const group = resolveGroup(img);
const index = group.indexOf(img);
groupImgs = group;
groupIndex = index >= 0 ? index : 0;
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
overlay.hidden = false;
document.documentElement.classList.add("imgv-lock");
active = true;
showAt(groupImgs, groupIndex >= 0 ? groupIndex : 0);
},
true,
);
@@ -265,8 +353,24 @@ export function initCaseImageViewer() {
btnOriginal.addEventListener("click", () => zoomTo(1));
btnClose.addEventListener("click", closeViewer);
function navigateGroup(dir) {
if (!groupImgs.length) return;
const next = Math.min(Math.max(groupIndex + dir, 0), groupImgs.length - 1);
if (next === groupIndex) return;
showAt(groupImgs, next);
}
btnPrev.addEventListener("click", (e) => {
e.stopPropagation();
navigateGroup(-1);
});
btnNext.addEventListener("click", (e) => {
e.stopPropagation();
navigateGroup(1);
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeViewer();
if (e.target === overlay && !overlay.classList.contains("is-loading")) closeViewer();
});
stage.addEventListener(
@@ -300,7 +404,10 @@ export function initCaseImageViewer() {
if (!active || !isPanning) return;
const dx = e.clientX - panStartX;
const dy = e.clientY - panStartY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) panMoved = true;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
panMoved = true;
panDirX = dx;
}
stage.scrollLeft = panScrollLeft - dx;
stage.scrollTop = panScrollTop - dy;
e.preventDefault();
@@ -314,9 +421,16 @@ export function initCaseImageViewer() {
}
stage.addEventListener("pointerup", (e) => {
const horizSwipe = panMoved && Math.abs(panDirX) > 40 && groupImgs.length > 1;
const shouldClose = !panMoved;
stopPan(e.pointerId);
if (shouldClose && active) {
if (horizSwipe) {
e.preventDefault();
navigateGroup(panDirX < 0 ? 1 : -1);
return;
}
// Пока грузится изображение — не закрываем по тапу/клику.
if (shouldClose && active && !overlay.classList.contains("is-loading")) {
e.preventDefault();
closeViewer();
}
@@ -365,6 +479,17 @@ export function initCaseImageViewer() {
if (e.key === "0") {
e.preventDefault();
zoomTo(fitScale());
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
navigateGroup(-1);
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
navigateGroup(1);
return;
}
});
}
+141
View File
@@ -0,0 +1,141 @@
// Слайдер изображений для лонгридов кейсов (одна картинка + стрелки + точки).
// Разметка:
// <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");
});
}