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
@@ -172,6 +172,8 @@
|
||||
| `.case-media--natural` | Изображение в натуральном масштабе |
|
||||
| `.case-media--content` | По ширине текстовой колонки |
|
||||
| `.case-media--wide` | Шире колонки статьи |
|
||||
| `.case-media-gallery--wide` | Шире колонки статьи (галерея) |
|
||||
| `.case-media-slider` | Слайдер: одна картинка, стрелки и точки-пагинация |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user