diff --git a/DESIGN.md b/DESIGN.md
index 50209c3..e6af351 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -172,6 +172,8 @@
| `.case-media--natural` | Изображение в натуральном масштабе |
| `.case-media--content` | По ширине текстовой колонки |
| `.case-media--wide` | Шире колонки статьи |
+| `.case-media-gallery--wide` | Шире колонки статьи (галерея) |
+| `.case-media-slider` | Слайдер: одна картинка, стрелки и точки-пагинация |
---
diff --git a/docs/case-page-blocks.md b/docs/case-page-blocks.md
index 3c7237b..1f076ee 100644
--- a/docs/case-page-blocks.md
+++ b/docs/case-page-blocks.md
@@ -141,6 +141,48 @@
**Сетка + отложенный `src`:** в ячейках можно задать реальный URL в **`data-src`** и плейсхолдер в `src` (крошечный `data:image/gif;base64,…`), чтобы [`initCaseSequentialImages`](public/js/modules/cases/sequential-images.js) подставлял картинки **по порядку в DOM** (в т.ч. строка за строкой в галерее). Пока у `img` есть `data-src`, просмотр по клику не открывается. Пример: [`src/pages/cases/lunacy.html`](src/pages/cases/lunacy.html).
+### Слайдер изображений с точками (`case-media-slider`)
+
+Одна картинка во весь блок, навигация стрелками «пред/след» и пагинация точками (активная — акцентным цветом). Переиспользует маски стрелок `shevron-left/right.svg` и токены темы. Логика: [`public/js/modules/cases/media-slider.js`](public/js/modules/cases/media-slider.js) (`initCaseMediaSlider()` вызывается из [`public/js/main.js`](public/js/main.js)).
+
+**Возможности:**
+
+- стрелки `data-slider-prev` / `data-slider-next` — переключают соседний слайд, блокируются (`disabled`) на краях;
+- точки `data-slider-dot` (в `data-slider-dots`) — переход на конкретный слайд, активная получает `is-active` + `aria-selected`;
+- клавиатура: ←/→ внутри блока, Home/End на точках;
+- тач/мышь-свайп (только горизонтальный);
+- ленивая подгрузка соседних кадров: у ` ` можно задать `data-src` (временный `src` — прозрачный GIF), скрипт подставит при подходе, как у галереи; пока есть `data-src`, клик по viewer не открывается;
+- клик по активному кадру открывает общий viewer кейса (`data-image-viewer`).
+
+**Разметка** (число `data-slider-dot` должно равняться числу `.case-media-slider__slide`; `aria-label` точек — «Перейти к изображению N»):
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Живой пример: [`src/pages/cases/template-elements-demo.html`](src/pages/cases/template-elements-demo.html) (секция «Слайдер»).
+
### Последовательная подгрузка (`data-case-sequential-images`)
- Оберни фрагмент статьи с картинками (часто весь `.case-article`): **`data-case-sequential-images`** на контейнере.
@@ -183,7 +225,7 @@ PNG/JPEG → WebP по папке: `docs/image-optimization.md`, скрипт `s
Подключаемые через `{{> head-links}}` сценарии уже инициализируют:
- последовательную подгрузку `data-src` внутри `[data-case-sequential-images]` (`initCaseSequentialImages`);
-- просмотр картинок по клику (`data-image-viewer`, делегирование на документ);
+- просмотр картинок по клику (`data-image-viewer`, делегирование на документ); при открытии из **галереи** (`.case-media-gallery`) или **слайдера** (`.case-media-slider`) в нижней панели просмотрщика появляются **кнопки-пилюли «пред/след»** и **точки-пагинация** — переключают только картинки этого блока (клавиши ←/→, свайп по горизонтали, клик по точке); у одиночной картинки кнопки и точки скрыты;
- плеер видео кейса (`data-case-video-player`);
- натуральный размер для `.case-media--natural img`;
- сворачивание длинного скрина (`[data-case-tall-media]`, `initCaseTallMediaExpand`).
diff --git a/public/assets/template-elements-demo/slider-demo-1.webp b/public/assets/template-elements-demo/slider-demo-1.webp
new file mode 100644
index 0000000..66ec9a9
Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-1.webp differ
diff --git a/public/assets/template-elements-demo/slider-demo-2.webp b/public/assets/template-elements-demo/slider-demo-2.webp
new file mode 100644
index 0000000..bf53716
Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-2.webp differ
diff --git a/public/assets/template-elements-demo/slider-demo-3.webp b/public/assets/template-elements-demo/slider-demo-3.webp
new file mode 100644
index 0000000..e48474a
Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-3.webp differ
diff --git a/public/assets/template-elements-demo/slider-demo-4.webp b/public/assets/template-elements-demo/slider-demo-4.webp
new file mode 100644
index 0000000..586ac54
Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-4.webp differ
diff --git a/public/cases/design-system/index.html b/public/cases/design-system/index.html
index b8b6c3f..cb81218 100644
--- a/public/cases/design-system/index.html
+++ b/public/cases/design-system/index.html
@@ -953,6 +953,19 @@
>
.has-img-loading · .is-img-loading · .img-ph-demo (демо заглушки)
+ TUI braille-спиннер
+
+ Загрузка тяжёлых изображений в просмотрщике (.case-image-viewer) показывается
+ моноширинным braille-спиннером — как в TUI. Чистый CSS, без JS: 10 кадров переключаются
+ через steps()-анимацию. Переиспользуемый класс .tui-spinner
+ (цвет — --accent, шрифт — системный mono).
+
+
+ ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
+ ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
+
+ .tui-spinner · кадры: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ · prefers-reduced-motion показывает ⠋
+
Карточка навыка
skill-task-card__task — формулировка задачи от бизнеса.
@@ -1044,7 +1057,7 @@
Живой эталон со всеми вариантами — на отдельной странице; оттуда копируют куски при сборке нового кейса.
- Демо элементов кейса
+ Демо элементов кейса
@@ -1055,7 +1068,7 @@
раскладка вынесены на отдельную страницу.
- Каталог карточек активности
+ Каталог карточек активности
diff --git a/public/cases/template-elements-demo/index.html b/public/cases/template-elements-demo/index.html
index f65d344..d01804a 100644
--- a/public/cases/template-elements-demo/index.html
+++ b/public/cases/template-elements-demo/index.html
@@ -430,6 +430,44 @@
Ширина как у wide-картинки; на узком экране wide-обёртка схлопывается в 100% ширины колонки (см. медиазапросы).
+ Слайдер: одна картинка, стрелки и точки
+
+ Блок .case-media-slider показывает слайды по одному: навигация стрелками «пред/след»,
+ пагинация точками (активная — акцентным цветом). Поддерживается клавиатура (←/→, Home/End на точках),
+ тач/мышь-свайп и ленивая подгрузка соседних кадров через data-src. Клик по активному
+ кадру открывает общий viewer кейса.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Callout: важный абзац с эмоджи
📌
diff --git a/public/css/main.css b/public/css/main.css
index b270bca..e561d1a 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -2162,11 +2162,18 @@ html[data-theme="dark"] {
}
.case-image-viewer__zoom {
- min-width: 3.5rem;
+ min-width: 2.5rem;
text-align: center;
font-size: 0.82rem;
- color: var(--text);
- font-weight: 600;
+ color: var(--text-muted);
+ font-weight: 400;
+}
+
+/* Группа слайдер + текст масштаба с уменьшенным внутренним зазором */
+.case-image-viewer__scale-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
}
.case-image-viewer__slider {
@@ -2310,29 +2317,82 @@ html[data-theme="dark"] {
opacity 0.16s ease;
}
-.case-image-viewer__loading {
+/* TUI braille-спиннер (переиспользуемый, чистый CSS) */
+.tui-spinner {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.1em;
+ height: 1.1em;
+ font-family: ui-monospace, "Cascadia Code", "Consolas", "Courier New", monospace;
+ font-size: 1.5rem;
+ line-height: 1;
+ letter-spacing: 0.1em;
+ color: var(--accent);
+}
+
+.tui-spinner > span {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ animation: tui-spinner-step 0.9s steps(1, end) infinite;
+}
+
+.tui-spinner > span:nth-child(1) { animation-delay: 0s; }
+.tui-spinner > span:nth-child(2) { animation-delay: 0.09s; }
+.tui-spinner > span:nth-child(3) { animation-delay: 0.18s; }
+.tui-spinner > span:nth-child(4) { animation-delay: 0.27s; }
+.tui-spinner > span:nth-child(5) { animation-delay: 0.36s; }
+.tui-spinner > span:nth-child(6) { animation-delay: 0.45s; }
+.tui-spinner > span:nth-child(7) { animation-delay: 0.54s; }
+.tui-spinner > span:nth-child(8) { animation-delay: 0.63s; }
+.tui-spinner > span:nth-child(9) { animation-delay: 0.72s; }
+.tui-spinner > span:nth-child(10) { animation-delay: 0.81s; }
+
+@keyframes tui-spinner-step {
+ 0%, 9.999% { opacity: 1; }
+ 10%, 100% { opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .tui-spinner > span { animation: none; opacity: 0; }
+ .tui-spinner > span:nth-child(1) { opacity: 1; }
+}
+
+/* Лоадер изображения в просмотрщике (обёртка вокруг .tui-spinner) */
+.case-image-viewer__loader {
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
transform: translate(-50%, -50%);
- min-width: 8.5rem;
- padding: 0.5rem 0.8rem;
- border-radius: 999px;
- text-align: center;
- font-size: 0.8rem;
- color: var(--text);
- background: color-mix(in srgb, var(--surface) 86%, transparent);
- border: 1px solid color-mix(in srgb, var(--line) 88%, transparent);
+ display: inline-flex;
+ align-items: center;
+ text-shadow: 0 1px 6px hsla(0, 0%, 0%, 0.35);
pointer-events: none;
opacity: 0;
transition: opacity 0.14s ease;
}
-.case-image-viewer.is-loading .case-image-viewer__loading {
+.case-image-viewer.is-loading .case-image-viewer__loader {
opacity: 1;
}
+.case-image-viewer__loader.is-error .tui-spinner {
+ display: none;
+}
+
+.case-image-viewer__loader-msg {
+ font-family: var(--font, inherit);
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ margin-left: 0.5rem;
+}
+
.case-image-viewer.is-loading .case-image-viewer__img {
opacity: 0;
}
@@ -2347,6 +2407,80 @@ html[data-theme="dark"] {
cursor: grabbing;
}
+/* Кнопки пред/след в тулбаре: группа с отрицательным зазором и скруглением только снаружи */
+.case-image-viewer__nav-group {
+ display: inline-flex;
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.case-image-viewer__btn--nav {
+ --imgv-nav-size: 2rem;
+ width: var(--imgv-nav-size);
+ height: var(--imgv-nav-size);
+ padding: 0;
+ border-radius: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Соседняя кнопка сдвигается на 1px, перекрывая разделительный бордер */
+.case-image-viewer__btn--nav + .case-image-viewer__btn--nav {
+ margin-left: -1px;
+}
+
+.case-image-viewer__btn--nav[hidden] {
+ display: none;
+}
+
+/* Скругления только на крайних кнопках группы (все 4 угла заданы явно) */
+.case-image-viewer__btn--nav:first-child {
+ border-radius: 999px 0 0 999px;
+}
+
+.case-image-viewer__btn--nav:last-child {
+ border-radius: 0 999px 999px 0;
+}
+
+/* У одиночной кнопки в группе — скругление со всех сторон */
+.case-image-viewer__btn--nav:only-child {
+ border-radius: 999px;
+}
+
+/* Точки-пагинация в тулбаре просмотрщика */
+.case-image-viewer__dots {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0 0.15rem;
+}
+
+.case-image-viewer__dots[hidden] {
+ display: none;
+}
+
+.case-image-viewer__dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--text-muted) 40%, transparent);
+ cursor: pointer;
+ transition: background 0.18s ease, transform 0.18s ease;
+}
+
+.case-image-viewer__dot:hover,
+.case-image-viewer__dot:focus-visible {
+ background: color-mix(in srgb, var(--text-muted) 65%, transparent);
+}
+
+.case-image-viewer__dot.is-active {
+ background: var(--accent);
+ transform: scale(1.25);
+}
+
@media (max-width: 640px) {
.case-link-card {
grid-template-columns: 1fr;
@@ -5194,3 +5328,174 @@ body.content-edit-shift-active.content-edit-mode-active .content-edit-fab-stack
.sep--air-lg {
height: 2.5rem;
}
+
+/* Слайдер изображений в лонгриде кейса: одна картинка + стрелки + точки */
+.case-media-slider {
+ --slider-gap: 0;
+ --slider-radius: var(--radius-card);
+ --slider-ctrl-size: 2.5rem;
+ --slider-ctrl-inset: 0.75rem;
+ --slider-dot-size: 0.5rem;
+ --slider-dot-gap: 0.5rem;
+ position: relative;
+ margin: 0;
+ overflow: hidden;
+ border-radius: var(--slider-radius);
+}
+
+.case-media-slider__viewport {
+ overflow: hidden;
+ width: 100%;
+ touch-action: pan-y;
+ cursor: grab;
+}
+
+.case-media-slider__viewport:active {
+ cursor: grabbing;
+}
+
+.case-media-slider__track {
+ display: flex;
+ width: 100%;
+ transition: transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1);
+ will-change: transform;
+}
+
+.case-media-slider__slide {
+ flex: 0 0 100%;
+ min-width: 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.case-media-slider__slide img {
+ display: block;
+ width: 100%;
+ height: auto;
+ max-height: 80vh;
+ object-fit: contain;
+ border-radius: var(--slider-radius);
+}
+
+/* Подпись под активным слайдом (как у case-media) */
+.case-media-slider__slide figcaption,
+.case-media-slider figcaption {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 0.6rem 0.9rem;
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ text-align: center;
+ background: color-mix(in srgb, var(--surface) 78%, transparent);
+ backdrop-filter: blur(6px);
+ border-bottom-left-radius: var(--slider-radius);
+ border-bottom-right-radius: var(--slider-radius);
+}
+
+/* Стрелки */
+.case-media-slider__nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: var(--slider-ctrl-size);
+ height: var(--slider-ctrl-size);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--surface) 86%, transparent);
+ color: var(--text-muted);
+ cursor: pointer;
+ z-index: 3;
+ transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease,
+ opacity 0.18s ease;
+}
+
+.case-media-slider__nav--prev {
+ left: var(--slider-ctrl-inset);
+}
+
+.case-media-slider__nav--next {
+ right: var(--slider-ctrl-inset);
+}
+
+.case-media-slider__nav:hover:not(:disabled),
+.case-media-slider__nav:focus-visible:not(:disabled) {
+ color: var(--accent-text);
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
+ background: var(--accent-soft);
+}
+
+.case-media-slider__nav:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+.case-media-slider__nav-ico {
+ width: 1rem;
+ height: 1rem;
+ display: block;
+ background: currentColor;
+ mask-size: contain;
+ -webkit-mask-size: contain;
+ mask-repeat: no-repeat;
+ -webkit-mask-repeat: no-repeat;
+ mask-position: center;
+ -webkit-mask-position: center;
+}
+
+.case-media-slider__nav-ico--left {
+ mask-image: url("../assets/shared/icons/shevron-left.svg");
+ -webkit-mask-image: url("../assets/shared/icons/shevron-left.svg");
+}
+
+.case-media-slider__nav-ico--right {
+ mask-image: url("../assets/shared/icons/shevron-right.svg");
+ -webkit-mask-image: url("../assets/shared/icons/shevron-right.svg");
+}
+
+/* Точки-пагинация */
+.case-media-slider__dots {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--slider-dot-gap);
+ margin-top: 0.9rem;
+}
+
+.case-media-slider__dot {
+ width: var(--slider-dot-size);
+ height: var(--slider-dot-size);
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--text-muted) 40%, transparent);
+ cursor: pointer;
+ transition: background 0.18s ease, transform 0.18s ease;
+}
+
+.case-media-slider__dot:hover,
+.case-media-slider__dot:focus-visible {
+ background: color-mix(in srgb, var(--text-muted) 65%, transparent);
+}
+
+.case-media-slider__dot.is-active {
+ background: var(--accent);
+ transform: scale(1.25);
+}
+
+/* Неактивные слайды скрыты от скринридеров и не ловят клики по viewer */
+.case-media-slider__slide:not(.is-active) {
+ visibility: hidden;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .case-media-slider__track {
+ transition: none;
+ }
+}
diff --git a/public/js/main.js b/public/js/main.js
index a83bd71..f3f0303 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -20,6 +20,7 @@ import { initHeroMotionVideo } from "./modules/ui/hero-motion-video.js";
import { initPageMeshBackground } from "./modules/ui/page-mesh-bg.js";
import { initPrimaryButtonShine } from "./modules/ui/primary-button-shine.js";
import { initProjectsCarousel } from "./modules/ui/projects-carousel.js";
+import { initCaseMediaSlider } from "./modules/cases/media-slider.js";
import { initSkillsAccordion } from "./modules/ui/skills-accordion.js";
import { initSkillsIntroMessages } from "./modules/ui/skills-intro.js";
import { initContentEditMode } from "./modules/ui/content-edit/content-edit-mode.js";
@@ -35,6 +36,7 @@ const miniProjectsMasonry = initMiniProjectsMasonry();
initThemeControls();
initPrimaryButtonShine();
initProjectsCarousel();
+initCaseMediaSlider();
initDeferredPortfolioCovers();
initImageLoadPlaceholder();
initSkillsAccordion();
diff --git a/public/js/modules/cases/image-viewer.js b/public/js/modules/cases/image-viewer.js
index fee9394..e73fd6f 100644
--- a/public/js/modules/cases/image-viewer.js
+++ b/public/js/modules/cases/image-viewer.js
@@ -15,8 +15,19 @@ export function initCaseImageViewer() {
overlay.hidden = true;
overlay.innerHTML = `
-
Загрузка...
+
⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
`;
@@ -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) =>
+ ` `,
+ )
+ .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;
}
});
}
diff --git a/public/js/modules/cases/media-slider.js b/public/js/modules/cases/media-slider.js
new file mode 100644
index 0000000..5943f78
--- /dev/null
+++ b/public/js/modules/cases/media-slider.js
@@ -0,0 +1,141 @@
+// Слайдер изображений для лонгридов кейсов (одна картинка + стрелки + точки).
+// Разметка:
+//
+//
+// …
+// …
+//
+//
+// ...
+//
+//
+
+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");
+ });
+}
diff --git a/src/pages/cases/design-system.html b/src/pages/cases/design-system.html
index 428f09a..be42313 100644
--- a/src/pages/cases/design-system.html
+++ b/src/pages/cases/design-system.html
@@ -765,6 +765,19 @@
>
.has-img-loading · .is-img-loading · .img-ph-demo (демо заглушки)
+ TUI braille-спиннер
+
+ Загрузка тяжёлых изображений в просмотрщике (.case-image-viewer) показывается
+ моноширинным braille-спиннером — как в TUI. Чистый CSS, без JS: 10 кадров переключаются
+ через steps()-анимацию. Переиспользуемый класс .tui-spinner
+ (цвет — --accent, шрифт — системный mono).
+
+
+ ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
+ ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
+
+ .tui-spinner · кадры: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ · prefers-reduced-motion показывает ⠋
+
Карточка навыка
skill-task-card__task — формулировка задачи от бизнеса.
@@ -856,7 +869,7 @@
Живой эталон со всеми вариантами — на отдельной странице; оттуда копируют куски при сборке нового кейса.
- Демо элементов кейса
+ Демо элементов кейса
@@ -867,7 +880,7 @@
раскладка вынесены на отдельную страницу.
- Каталог карточек активности
+ Каталог карточек активности
diff --git a/src/pages/cases/template-elements-demo.html b/src/pages/cases/template-elements-demo.html
index e2e92f4..7222831 100644
--- a/src/pages/cases/template-elements-demo.html
+++ b/src/pages/cases/template-elements-demo.html
@@ -241,6 +241,44 @@
Ширина как у wide-картинки; на узком экране wide-обёртка схлопывается в 100% ширины колонки (см. медиазапросы).
+ Слайдер: одна картинка, стрелки и точки
+
+ Блок .case-media-slider показывает слайды по одному: навигация стрелками «пред/след»,
+ пагинация точками (активная — акцентным цветом). Поддерживается клавиатура (←/→, Home/End на точках),
+ тач/мышь-свайп и ленивая подгрузка соседних кадров через data-src. Клик по активному
+ кадру открывает общий viewer кейса.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Callout: важный абзац с эмоджи