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:
1 parent
9b91ed165f
commit
95b4eaca93
14 files changed
+753
-34
No files matched your search
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user