GrCarousel

  • ядро
  • Данные
  • API · 20 props
  • ⌨ клавиши

Берут, когда галерея товара или объекта.

import { GrCarousel } from '@feugene/granularity/components/GrCarousel'

Обзор

Когда брать

  • галерея товара или объекта — несколько снимков там, где место есть только под один: полоса миниатюр показывает, сколько ещё, и ведёт сразу к нужному;
  • онбординг и промо-экраны — три-пять кадров подряд, которые пролистывают пальцем, а не выбирают по имени;
  • лента карточек шире экрана — отзывы, подборки, связанные материалы: кадром здесь работает произвольная разметка, а не картинка;
  • экран без зрителя — витрина или монитор в зале: показ идёт сам, а кнопка паузы и prefers-reduced-motion его останавливают.

Когда взять другое

НужноБерите
Разделы с именами, между которыми переключаютсяGrTabs + GrTabPanels
Рассмотреть картинку: масштаб, поворот, полный экранGrImageViewer
Показать всё сразу, свернув лишнее по местуGrCollapse
Вертикальный список, где видны все строкиGrList / GrDataTable
Порядок элементов меняет пользовательGrSortableList
Этапы процесса, где важен статус каждогоGrSteps

Варианты все тона и виды — настоящим компонентом

tone

  • primary
  • neutral
  • success
  • warning
  • danger
  • info
  • slate
  • azure

Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 4 у каждого — живое демо и его код

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
tone
"primary" | "neutral" | "success" | "warning" | "danger" | ""primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined
undefinedТон текущего переключателя по общей шкале пакета.
modelValuev-modelnumber | undefinedundefinedИндекс текущего кадра. Не передан — лента ведёт позицию сама.
ariaLabelstring | undefinedundefinedИмя карусели. APG требует его: `aria-roledescription` именем не считается.
virtualboolean | undefinedfalseНе рисовать содержимое кадров вдали от текущего. Виртуализируется **нутро кадра, а не кадр**: кадры приходят слотом, и не отрисовать чужой узел лента не может. Пустая обёртка держит место в ряду и почти ничего не весит, а картинки и карточки из DOM уходят.
orientation"horizontal" | "vertical" | undefined"horizontal"Ось движения ленты. По вертикали вьюпорту нужна **определённая высота**: шаг считается от неё, и без высоты лента вырождается в столбец кадров.
autoplayboolean | undefinedfalseЗапустить показ. Под `prefers-reduced-motion` не стартует — тумблер остаётся.
autoplayIntervalnumber | undefined5000Пауза между кадрами, мс.
loopboolean | undefinedtrueЗамкнуть ленту. Выключено — стрелки на краях гаснут.
virtualOverscannumber | undefined1Сколько кадров по обе стороны от текущего рисовать сверх него. Меньше единицы ставить нельзя: следующий кадр обязан быть готов **до** перехода, иначе лента поедет на пустоту, а содержимое проявится уже на месте.
indicators"none" | "dots" | "thumbnails" | undefinedundefinedВид переключателя кадров. `none` меняет и роль самих слайдов.
activationMode"automatic" | "manual" | undefinedundefinedЛистает ли стрелка по переключателям сразу или только двигает фокус.
arrowsboolean | undefinedtrueСтрелки «назад/вперёд».
swipeboolean | undefinedtrueЛистание протяжкой указателя.
landmarkboolean | undefinedfalseСделать карусель ориентиром страницы (`role="region"`).

Слоты

SlotTypeОписание
defaultanyКадры ленты — `GrCarouselSlide`.
prev{ disabled: boolean; }Своя иконка кнопки «назад». Заменяет содержимое кнопки, но не её саму: имя, `aria-disabled` и поведение на краю остаются за компонентом.
next{ disabled: boolean; }Своя иконка кнопки «вперёд».

События

EventСигнатураОписание
update:modelValue(event: "update:modelValue", index: number): void—

Методы

MethodsСигнатураОписание
indexnumberТекущий кадр — единственный способ прочитать позицию без `v-model`.
countnumberЧисло кадров: состав живёт в слоте, снаружи его посчитать нечем.
playingbooleanИдёт ли показ. Пауза под курсором его не выключает.
next() => voidСледующий кадр. Останавливает показ; без `loop` на последнем кадре ничего не делает.
prev() => voidПредыдущий кадр. Останавливает показ; без `loop` на первом кадре ничего не делает.
goTo(index: number) => voidПерейти к кадру по индексу; индекс за пределами ленты прижимается к краю. Останавливает показ.
play() => voidЗапустить показ — так же, как тумблером.
pause() => voidОстановить показ и объявить остановку скринридеру.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrCarouselConfigurableProps, GrCarouselProps, GrCarouselEmits } from '@feugene/granularity/components/GrCarousel'

Файлdist/types/components/GrCarousel/config.d.ts

grCarouselConfigconst

dependencies пуст намеренно: стрелки и тумблер — обычные <button>, а не GrButton. Тот связывает disabled с нативным атрибутом, а стрелка на краю без loop обязана остаться фокусируемой (aria-disabled), иначе фокус падает в <body> ровно в момент, когда пользователь долистал до конца.

const grCarouselConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCarousel">;

Файлdist/types/components/GrCarousel/defaults.d.ts

GrCarouselConfigurablePropsinterface

Пропы GrCarousel, настраиваемые глобально через componentDefaults.

interface GrCarouselConfigurableProps {
    indicators: GrCarouselIndicators;
    activationMode: GrCarouselActivationMode;
    tone: GrTone;
}

Файлdist/types/components/GrCarousel/GrCarousel.vue.d.ts

GrCarouselPropsinterface
interface GrCarouselProps {
    /** Индекс текущего кадра. Не передан — лента ведёт позицию сама. */
    modelValue?: number;
    /** Запустить показ. Под `prefers-reduced-motion` не стартует — тумблер остаётся. */
    autoplay?: boolean;
    /** Пауза между кадрами, мс. */
    autoplayInterval?: number;
    /** Замкнуть ленту. Выключено — стрелки на краях гаснут. */
    loop?: boolean;
    /**
     * Ось движения ленты. По вертикали вьюпорту нужна **определённая высота**:
     * шаг считается от неё, и без высоты лента вырождается в столбец кадров.
     */
    orientation?: GrCarouselOrientation;
    /**
     * Не рисовать содержимое кадров вдали от текущего.
     *
     * Виртуализируется **нутро кадра, а не кадр**: кадры приходят слотом, и не
     * отрисовать чужой узел лента не может. Пустая обёртка держит место в ряду и
     * почти ничего не весит, а картинки и карточки из DOM уходят.
     */
    virtual?: boolean;
    /**
     * Сколько кадров по обе стороны от текущего рисовать сверх него. Меньше
     * единицы ставить нельзя: следующий кадр обязан быть готов **до** перехода,
     * иначе лента поедет на пустоту, а содержимое проявится уже на месте.
     */
    virtualOverscan?: number;
    /** Вид переключателя кадров. `none` меняет и роль самих слайдов. */
    indicators?: GrCarouselIndicators;
    /** Тон текущего переключателя по общей шкале пакета. */
    tone?: GrTone;
    /** Листает ли стрелка по переключателям сразу или только двигает фокус. */
    activationMode?: GrCarouselActivationMode;
    /** Стрелки «назад/вперёд». */
    arrows?: boolean;
    /** Листание протяжкой указателя. */
    swipe?: boolean;
    /** Сделать карусель ориентиром страницы (`role="region"`). */
    landmark?: boolean;
    /** Имя карусели. APG требует его: `aria-roledescription` именем не считается. */
    ariaLabel?: string;
    /** Имя из заголовка на странице. Сильнее `ariaLabel`. */
    ariaLabelledby?: string;
    /** Доступное имя стрелки «назад»: у кнопки только иконка. По умолчанию — строка локали. */
    prevLabel?: string;
    /** Доступное имя стрелки «вперёд»: у кнопки только иконка. По умолчанию — строка локали. */
    nextLabel?: string;
    /** Имя тумблера показа, пока лента стоит. По умолчанию — строка локали. */
    playLabel?: string;
    /** Имя тумблера показа, пока кадры листаются сами. По умолчанию — строка локали. */
    pauseLabel?: string;
    /** Имя полосы переключателей. */
    indicatorsLabel?: string;
}
GrCarouselEmitsinterface
interface GrCarouselEmits {
    /** Сменился текущий кадр (`v-model`): стрелкой, переключателем, протяжкой или показом. */
    (e: 'update:modelValue', index: number): void;
}

Файлdist/types/components/GrCarousel/GrCarouselSlide.vue.d.ts

GrCarouselSlidePropsinterface
interface GrCarouselSlideProps {
    /** Имя слайда: подпись его переключателя и доступное имя самого кадра. */
    label?: string;
    /** URL миниатюры для `indicators="thumbnails"`. Слот `#thumbnail` сильнее. */
    thumbnail?: string;
}

Файлdist/types/components/GrCarousel/grCarouselStyles.d.ts

GrCarouselIndicatorstype
type GrCarouselIndicators = typeof GR_CAROUSEL_INDICATORS[number];
GrCarouselActivationModetype
type GrCarouselActivationMode = typeof GR_CAROUSEL_ACTIVATION_MODES[number];

Файлdist/types/components/GrCarousel/safelist.d.ts

grCarouselSafelistconst

Только текущий переключатель: его классы склеиваются в рантайме из тона и скобок arbitrary() (grCarouselStyles.ts: grCarouselDotActiveClass — заливка, обвод и его отступ; grCarouselThumbActiveClass — рамка миниатюры), и целиком их нет ни в одной строке кода. Тон приходит пропом, поэтому объявлена вся шкала. Остальное компонент берёт литералами, и granum извлекает это из его чанков сам, включая общие.

const grCarouselSafelist: string[];

Файлdist/types/components/GrCarousel/index.d.ts

GrCarouselInstancetype
type GrCarouselInstance = ComponentExposed<typeof GrCarouselComponent>;

Доступность

Паттерн APG

carousel

Клавиши

Home / End

КлавишаДействие
—Своих клавиш у ленты нет: она не таб-стоп и не скроллер — кадр двигает трансформ. Кольцо живёт у полосы переключателей: ←/→ — по кадрам циклично (независимо от loop)
Home / Endк первому и последнему, полоса держит одну остановку Tab. При activationMode="manual" стрелка двигает только фокус, кадр подтверждает Enter/Space. Стрелки листания и кнопка паузы — обычные кнопки; на краю без loop стрелка объявлена aria-disabled, но остаётся фокусируемой. Фокус, вошедший в карусель, останавливает показ и сам его не возобновляет

Использование из прозы библиотеки

Кольцо замыкается без отката

loop включён по умолчанию, и раньше он просто сбрасывал индекс: лента прокручивалась назад через все кадры. На десятикадровой карусели с автопрокруткой каждый десятый шаг был длинным обратным ходом.

Теперь на границе кольца крайний кадр временно переезжает на противоположный конец, лента доезжает до него как до обычного соседа, и уже после перехода позиция снимается без анимации. Клона тут нет и быть не может: кадры приходят слотом, а копия чужого узла унесла бы с собой его состояние и ссылки.

Модель обновляется сразу, не дожидаясь конца перехода: modelValue про то, какой кадр показан, а не про то, доехала ли лента.

Ось движения
vue
<GrCarousel orientation="vertical" class="h-56">
  <GrCarouselSlide label="Ноябрь">…</GrCarouselSlide>
</GrCarousel>

Вертикальной ленте нужна определённая высота вьюпорта. Шаг считается от неё, и без высоты колонка кадров растёт по содержимому, а «на один кадр» означает всю ленту разом. Высота нулевая — компонент говорит об этом в dev-сборке, а не молча рисует столбец.

Стрелки переезжают на ось движения, поперечная прокрутка страницы остаётся за страницей (touch-action), а полоса переключателей остаётся горизонтальной: она про выбор кадра, а не про направление ленты.

Виртуализация
vue
<GrCarousel virtual :virtual-overscan="1">
  <GrCarouselSlide label="Кадр">…</GrCarouselSlide>
</GrCarousel>

Виртуализируется нутро кадра, а не кадр. Кадры приходят слотом, и не отрисовать чужой узел лента не может — но может попросить его не рисовать своё содержимое. Пустая обёртка держит место в ряду и почти ничего не весит, а картинки и карточки уходят из DOM.

virtualOverscan — сколько кадров по обе стороны рисовать сверх текущего. Меньше единицы не бывает: следующий кадр обязан быть готов до перехода, иначе лента поедет на пустоту, а содержимое проявится уже на месте. При loop окно замкнуто вместе с лентой — соседи первого кадра слева это последние.

Кадры приходят слотом, а не пропом items

Разметка кадра у каждого своя: у галереи это <img>, у онбординга — заголовок, абзац и кнопка, у ленты отзывов — карточка. Массив данных заставил бы либо render-функцию, либо шаблон-проп на каждый вид кадра, поэтому кадр — это GrCarouselSlide с произвольным содержимым внутри.

Следствие, о котором стоит знать: состав ленты компонент узнаёт от самих кадров, при монтировании. На сервере полоса переключателей выходит пустой и заполняется следующим тактом на клиенте — первый клиентский рендер повторяет серверный, и гидрация цела.

Это карусель, а не tablist

Корень — role="group" (или region при landmark) с aria-roledescription="carousel". Имени по умолчанию нет намеренно: aria-roledescription именем не считается, а подставленное «карусель» диктор прочитал бы дважды подряд. Поэтому ariaLabel или ariaLabelledby обязательны, и без них компонент предупреждает в dev.

Роль кадра зависит от наличия переключателя, и только от него: с полосой кадр — tabpanel, привязанный к своей вкладке, без полосы — group с именем «N из M». Вид переключателя на роли не сказывается: точки и миниатюры — один и тот же tablist в двух шкурках, иначе косметический проп переписывал бы контракт доступности.

Кадр, который сейчас не показан, помечен inert. Это не полировка: кадры лежат в одном ряду и обрезаются по вьюпорту, поэтому без него Tab уезжает в невидимый кадр, а браузер прокручивает к нему контейнер и разъезжается с лентой.

Своих клавиш у ленты нет

Лента не фокусируется и скроллером не является — сдвиг делает трансформ, а не прокрутка. Кольцо стрелок живёт у полосы переключателей: ←/→ ходят по кадрам, Home/End — к краям, полоса держит одну остановку Tab. При activationMode="manual" стрелка двигает только фокус, а кадр подтверждает Enter или Space.

Кольцо полосы замкнуто всегда, независимо от loop: loop управляет стрелками и показом, а выбрать любой кадр напрямую можно в любом случае.

Показ останавливается сам

Курсор над лентой, идущий жест и вкладка в фоне ставят отсчёт на паузу: остаток сохраняется, и по возвращении лента досчитывает его, а не начинает интервал заново. Фокус, вошедший внутрь, показ останавливает и сам его не возобновляет — иначе кадр уехал бы из-под пальца, пока пользователь читает ссылку внутри него.

Кнопка паузы рисуется всегда, когда задан autoplay, и спрятать её нечем: APG запрещает движущуюся карусель без механизма остановки.

Под prefers-reduced-motion показ не стартует, но кнопка остаётся и предлагает запустить его — «уменьшить движение» не значит «отнять возможность». Глобальный кламп здесь не помогает: он гасит CSS, а отсчёт ведёт таймер, и лента продолжала бы ехать у того, кто просил её этого не делать.

Свайп не отменяет нажатие

Жест начинается только там, где под указателем нет ссылки, кнопки или поля: интерактив внутри кадра остаётся интерактивом. Дальше жест обязан превысить порог — больший из 60 px и 15 % ширины ленты — и идти горизонтальнее, чем вертикально, иначе это прокрутка страницы, и карусель её не перехватывает. Вертикальная прокрутка страницы остаётся браузеру: лента гасит только горизонталь.

Обрыв жеста кадр не листает. Указатель у страницы забирает браузер — системный жест, звонок, потеря окна, — и пользователь при этом ничего не доводил.

Один кадр и пустая лента — состояния, а не крайние случаи

С одним кадром стрелки, точки и миниатюры не рисуются вовсе, а не рисуются выключенными: выключенный орган управления сообщает, что идти есть куда. Пустая лента — это v-if на стороне потребителя: кадры пишет он, и подставлять за него текст «ничего нет» компонент не станет.

Миниатюра берётся из трёх мест

По убыванию силы: слот #thumbnail у кадра, затем его проп thumbnail с URL, затем номер кадра в плитке того же размера. Третий вариант держит ритм полосы, когда картинки нет вовсе; если миниатюру не дал ни один кадр при indicators="thumbnails", компонент предупреждает в dev.

Первую <img> внутри кадра компонент не ищет: обход разметки не работает на сервере, ломается на кадрах без картинок и прячет контракт от читателя.

Свои иконки стрелок и тон переключателей

Слоты #prev и #next заменяют содержимое кнопки, а не саму кнопку: имя, aria-disabled и поведение на краю остаются за компонентом. Иначе своя стрелка приносила бы с собой и обязанность повторить весь контракт — а его чаще всего не повторяют.

Тон текущего переключателя берётся из общей шкалы пакета (tone), а не из своего набора цветов. Точечная перекраска — по-прежнему через --gr-carousel-dot-active: проп задаёт роль, хук задаёт значение. У neutral насыщенного цвета нет, и текущая точка (и рамка текущей миниатюры) у него цвета основного текста, --gr-fg, — как нейтральный счётчик GrBadgeWrap. Серый поверхности тут не годится: им нарисованы неактивные точки.

Текущая точка несёт два признака: заливку и обвод вокруг неё. Одной заливки мало — при диаметре в восемь пикселей она отличала точку только оттенком, и на приглушённом тоне разница почти пропадала. Обвод добавляет форму, то есть работает и там, где цвет не различают.

Полоса миниатюр двигает себя, а не страницу

Прокрутка к текущей миниатюре считается арифметикой и присваивается scrollLeft самой полосы. scrollIntoView здесь не годится: он двигает всех предков, включая документ, — и карусель, уехавшая под сгиб, на каждом шаге автопрокрутки утаскивала бы страницу обратно к себе.

Границы
  • виртуализации нет — все кадры лежат в DOM. Лента на сотни тяжёлых кадров требует своего решения на стороне потребителя;
  • бесконечной ленты клонированием нет — loop перематывает индекс, поэтому на переходе с последнего кадра на первый лента едет назад, а не продолжает движение;
  • вертикальной ориентации нет;
  • стрелки лежат поверх кадра — они рассчитаны на снимок, где перекрыть нечего. Кадр с текстом обязан развести содержимое с ними полями по краям (кнопка 36 px плюс отступ 8 px с каждой стороны), иначе первые буквы строки уходят под кнопку. Вынести стрелки за пределы ленты компонент не умеет;
  • высоту задаёт самый высокий кадр — кадры лежат в одном ряду. Постоянная высота, если она нужна, задаётся содержимым кадра.