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

+2
View File
@@ -172,6 +172,8 @@
| `.case-media--natural` | Изображение в натуральном масштабе |
| `.case-media--content` | По ширине текстовой колонки |
| `.case-media--wide` | Шире колонки статьи |
| `.case-media-gallery--wide` | Шире колонки статьи (галерея) |
| `.case-media-slider` | Слайдер: одна картинка, стрелки и точки-пагинация |
---
+43 -1
View File
@@ -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 на точках;
- тач/мышь-свайп (только горизонтальный);
- ленивая подгрузка соседних кадров: у `<img>` можно задать `data-src` (временный `src` — прозрачный GIF), скрипт подставит при подходе, как у галереи; пока есть `data-src`, клик по viewer не открывается;
- клик по активному кадру открывает общий viewer кейса (`data-image-viewer`).
**Разметка** (число `data-slider-dot` должно равняться числу `.case-media-slider__slide`; `aria-label` точек — «Перейти к изображению N»):
```html
<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 src="../assets/<папка>/<файл-1>.webp" alt="…" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="../assets/<папка>/<файл-2>.webp" alt="…" loading="lazy" decoding="async" data-image-viewer />
</div>
</div>
</div>
<button type="button" class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--right" aria-hidden="true"></span>
</button>
<div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 1"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 2"></button>
</div>
</figure>
```
Живой пример: [`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`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+15 -2
View File
@@ -953,6 +953,19 @@
></div>
<p class="sg-label">.has-img-loading · .is-img-loading · .img-ph-demo (демо заглушки)</p>
<h3>TUI braille-спиннер</h3>
<p class="section__prose">
Загрузка тяжёлых изображений в просмотрщике (<code>.case-image-viewer</code>) показывается
моноширинным braille-спиннером — как в TUI. Чистый CSS, без JS: 10 кадров переключаются
через <code>steps()</code>-анимацию. Переиспользуемый класс <code>.tui-spinner</code>
(цвет — <code>--accent</code>, шрифт — системный mono).
</p>
<div class="sg-stack sg-stack--row sg-stack--center" style="gap: 1.5rem; padding: 1.25rem; background: color-mix(in srgb, var(--surface) 70%, transparent); border: 1px solid var(--line); border-radius: var(--radius-card, 14px);">
<span class="tui-spinner" aria-label="Загрузка" role="status"><span>⠋</span><span>⠙</span><span>⠹</span><span>⠸</span><span>⠼</span><span>⠴</span><span>⠦</span><span>⠧</span><span>⠇</span><span>⠏</span></span>
<code class="tui-spinner-seq" style="font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; color: var(--text-muted); font-size: 0.95rem;">⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏</code>
</div>
<p class="sg-label">.tui-spinner · кадры: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ · prefers-reduced-motion показывает ⠋</p>
<h3>Карточка навыка</h3>
<article class="skill-task-card">
<p class="skill-task-card__task">skill-task-card__task — формулировка задачи от бизнеса.</p>
@@ -1044,7 +1057,7 @@
Живой эталон со всеми вариантами — на отдельной странице; оттуда копируют куски при сборке нового кейса.
</p>
<p>
<a class="btn btn--secondary btn--flat" href="template-elements-demo/">Демо элементов кейса</a>
<a class="btn btn--secondary btn--flat" href="../template-elements-demo/">Демо элементов кейса</a>
</p>
</div>
@@ -1055,7 +1068,7 @@
раскладка вынесены на отдельную страницу.
</p>
<p>
<a class="btn btn--secondary btn--flat" href="mini-projects-showcase/">Каталог карточек активности</a>
<a class="btn btn--secondary btn--flat" href="../mini-projects-showcase/">Каталог карточек активности</a>
</p>
</div>
</div>
@@ -430,6 +430,44 @@
<figcaption>Ширина как у wide-картинки; на узком экране wide-обёртка схлопывается в 100% ширины колонки (см. медиазапросы).</figcaption>
</figure>
<h3>Слайдер: одна картинка, стрелки и точки</h3>
<p class="section__prose">
Блок <code>.case-media-slider</code> показывает слайды по одному: навигация стрелками «пред/след»,
пагинация точками (активная — акцентным цветом). Поддерживается клавиатура (←/→, Home/End на точках),
тач/мышь-свайп и ленивая подгрузка соседних кадров через <code>data-src</code>. Клик по активному
кадру открывает общий viewer кейса.
</p>
<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 src="../../assets/template-elements-demo/slider-demo-1.webp" alt="Слайдер, кадр 1" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="../../assets/template-elements-demo/slider-demo-2.webp" alt="Слайдер, кадр 2" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="../../assets/template-elements-demo/slider-demo-3.webp" alt="Слайдер, кадр 3" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="../../assets/template-elements-demo/slider-demo-4.webp" alt="Слайдер, кадр 4" loading="lazy" decoding="async" data-image-viewer />
</div>
</div>
</div>
<button type="button" class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--right" aria-hidden="true"></span>
</button>
<div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 1"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 2"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 3"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 4"></button>
</div>
</figure>
<h3>Callout: важный абзац с эмоджи</h3>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>
+318 -13
View File
@@ -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;
}
}
+2
View File
@@ -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();
+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");
});
}
+15 -2
View File
@@ -765,6 +765,19 @@
></div>
<p class="sg-label">.has-img-loading · .is-img-loading · .img-ph-demo (демо заглушки)</p>
<h3>TUI braille-спиннер</h3>
<p class="section__prose">
Загрузка тяжёлых изображений в просмотрщике (<code>.case-image-viewer</code>) показывается
моноширинным braille-спиннером — как в TUI. Чистый CSS, без JS: 10 кадров переключаются
через <code>steps()</code>-анимацию. Переиспользуемый класс <code>.tui-spinner</code>
(цвет — <code>--accent</code>, шрифт — системный mono).
</p>
<div class="sg-stack sg-stack--row sg-stack--center" style="gap: 1.5rem; padding: 1.25rem; background: color-mix(in srgb, var(--surface) 70%, transparent); border: 1px solid var(--line); border-radius: var(--radius-card, 14px);">
<span class="tui-spinner" aria-label="Загрузка" role="status"><span>⠋</span><span>⠙</span><span>⠹</span><span>⠸</span><span>⠼</span><span>⠴</span><span>⠦</span><span>⠧</span><span>⠇</span><span>⠏</span></span>
<code class="tui-spinner-seq" style="font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; color: var(--text-muted); font-size: 0.95rem;">⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏</code>
</div>
<p class="sg-label">.tui-spinner · кадры: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ · prefers-reduced-motion показывает ⠋</p>
<h3>Карточка навыка</h3>
<article class="skill-task-card">
<p class="skill-task-card__task">skill-task-card__task — формулировка задачи от бизнеса.</p>
@@ -856,7 +869,7 @@
Живой эталон со всеми вариантами — на отдельной странице; оттуда копируют куски при сборке нового кейса.
</p>
<p>
<a class="btn btn--secondary btn--flat" href="template-elements-demo/">Демо элементов кейса</a>
<a class="btn btn--secondary btn--flat" href="../template-elements-demo/">Демо элементов кейса</a>
</p>
</div>
@@ -867,7 +880,7 @@
раскладка вынесены на отдельную страницу.
</p>
<p>
<a class="btn btn--secondary btn--flat" href="mini-projects-showcase/">Каталог карточек активности</a>
<a class="btn btn--secondary btn--flat" href="../mini-projects-showcase/">Каталог карточек активности</a>
</p>
</div>
</div>
@@ -241,6 +241,44 @@
<figcaption>Ширина как у wide-картинки; на узком экране wide-обёртка схлопывается в 100% ширины колонки (см. медиазапросы).</figcaption>
</figure>
<h3>Слайдер: одна картинка, стрелки и точки</h3>
<p class="section__prose">
Блок <code>.case-media-slider</code> показывает слайды по одному: навигация стрелками «пред/след»,
пагинация точками (активная — акцентным цветом). Поддерживается клавиатура (←/→, Home/End на точках),
тач/мышь-свайп и ленивая подгрузка соседних кадров через <code>data-src</code>. Клик по активному
кадру открывает общий viewer кейса.
</p>
<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 src="{{assetPrefix}}assets/template-elements-demo/slider-demo-1.webp" alt="Слайдер, кадр 1" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="{{assetPrefix}}assets/template-elements-demo/slider-demo-2.webp" alt="Слайдер, кадр 2" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="{{assetPrefix}}assets/template-elements-demo/slider-demo-3.webp" alt="Слайдер, кадр 3" loading="lazy" decoding="async" data-image-viewer />
</div>
<div class="case-media-slider__slide">
<img src="{{assetPrefix}}assets/template-elements-demo/slider-demo-4.webp" alt="Слайдер, кадр 4" loading="lazy" decoding="async" data-image-viewer />
</div>
</div>
</div>
<button type="button" class="case-media-slider__nav case-media-slider__nav--prev" data-slider-prev aria-label="Предыдущее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--left" aria-hidden="true"></span>
</button>
<button type="button" class="case-media-slider__nav case-media-slider__nav--next" data-slider-next aria-label="Следующее изображение">
<span class="case-media-slider__nav-ico case-media-slider__nav-ico--right" aria-hidden="true"></span>
</button>
<div class="case-media-slider__dots" data-slider-dots role="tablist" aria-label="Выбор изображения">
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 1"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 2"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 3"></button>
<button type="button" class="case-media-slider__dot" data-slider-dot aria-label="Перейти к изображению 4"></button>
</div>
</figure>
<h3>Callout: важный абзац с эмоджи</h3>
<aside class="case-callout" data-callout>
<span class="case-callout__emoji case-callout__emoji--pulse" aria-hidden="true">📌</span>