GrTimeline

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

Берут, когда события идут во времени.

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

Обзор

Когда брать

  • события идут во времени — история заказа, аудит-лог, статус доставки: порядок и есть смысл;
  • события группируются по дням — groupBy расставляет заголовки, не требуя готовить данные;
  • процесс не закончен — незавершённый шаг рисуется пунктиром, а не выглядит как выполненный;
  • лента строится горизонтально — orientation для шкалы прогресса вместо колонки.

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

НужноБерите
Порядок не важен, строки однородныеGrList
Данные табличныеGrDataTable
Шаги мастера с переходами вперёд и назадGrTabs
Момент относительно сейчасGrRelativeTime
Как менялась величинаGrChartLine

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

Сцена

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

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

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

  • Где сейчас заказ

    Шаги разные, поэтому лента пишется пунктами GrTimelineItem. Каждая метка — <time datetime>, машиночитаемая. То, что ещё не случилось, — pending: полая точка и пунктир на участке к ней, непройденный путь. Слот marker ставит иконку там, где статус важнее точки, а слот по умолчанию несёт ссылку на отслеживание.

    Order BV-20418 · Oak writing deskArrives Thursday, 9 October
    1. Order placed
      Paid by card ending 6411
    2. Packed at the Lisbon warehouse
    3. Handed to the courier
      Tracking number CTT RH4471 9902 PT
    4. Out for delivery
      Between 09:00 and 13:00
    5. Delivered
  • Журнал аудита по дням

    Однородный набор отдаётся items — только так лента может его сгруппировать. group-by режет события по дням в том порядке, в каком они пришли, без сортировки и разбора дат, а слот group формулирует заголовок уровня group-heading-level 3. layout="time" ставит время колонкой, density="compact" держит событие в одну строку, loading кладёт заглушки на место строк, а слот empty отвечает на фильтр, под который ничего не попало.

    Audit log

    Today · 3 events

    1. Mina Ortiz signed in from Lisbon
    2. Daniel Kim refunded €48.00 on INV-2026-0411
    3. System blocked 5 sign-ins from an unknown device

    Yesterday · 2 events

    1. Grace Wu made Jonas Weber an admin
    2. Daniel Kim issued 42 invoices for October
  • Публичная дорожная карта

    orientation="horizontal" разворачивает ось слева направо, а ленту превращает в скроллер, который принимает фокус клавиатуры, — прокрутить его можно и без мыши. Выпуск в бете — variant="outlined": полая точка на сплошной дороге; то, что ещё впереди, — pending, и дорога к нему пунктирная.

    Roadmap 2026–2027
    1. 4.0 · Multi-currency
    2. 4.1 · SEPA Instant payouts
    3. 4.2 · Prorated seats
    4. 4.3 · Usage-based plans · beta
    5. 5.0 · Revenue recognition
  • История компании для страницы «О нас»

    layout="alternate" ставит события по обе стороны оси; на узком экране лента сама складывается в одну сторону — двухстороннюю в 320px не прочесть. Веха, которая пока только план, — pending.

    How Northwind Billing grew
    1. Two people, one spreadsheet
      Invoices for a Lisbon coworking, sent by hand
    2. The first 100 customers
      Recurring billing and card payments
    3. SEPA and VAT across the EU
      Offices in Lisbon and Dublin
    4. 1,000 businesses
      €40M billed through the platform in a year
    5. Revenue recognition
      Planned: the books close themselves

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

Props

PropTypeпо умолчаниюОписание
itemsT[] | undefinedundefinedДанные ленты. С ними пункты рисует слот `#item` — и только так лента знает состав набора, то есть может его группировать.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedКлюч пункта для `v-for`: имя поля или функция. Без него — индекс.
groupByGrTimelineGroupBy<T> | undefinedundefinedГруппировка: имя поля или функция от пункта. Заголовок группы — слот `#group`.
groupHeadingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedУровень заголовка группы: лента обязана вписаться в структуру заголовков страницы.
layout"stacked" | "time" | "alternate" | undefinedundefinedРаскладка: одна ось, колонка времени слева или чередование сторон.
orientation"horizontal" | "vertical" | undefinedundefinedНаправление оси. Горизонтальная лента — скроллер.
density"regular" | "compact" | undefinedundefinedПлотность вертикальных отступов пункта.
loadingboolean | undefinedfalseИдёт загрузка: вместо пунктов — заглушки, контейнер помечен `aria-busy`.
loadingRowsnumber | undefined3Сколько строк-заглушек показать при `loading`.
emptyboolean | undefinedundefinedЛента пуста. По умолчанию определяется сама — по данным или по слоту; проп нужен там, где потребитель знает лучше.
emptyTextstring | undefinedundefinedТекст пустого состояния. Слот `#empty` сильнее.

Слоты

SlotTypeОписание
defaultanyПункты ленты (`GrTimelineItem`), когда данные не переданы пропом.
item{ item: T; index: number; }Пункт в data-режиме.
group{ group: string; items: T[]; }Заголовок группы. По умолчанию — сам ключ.
emptyanyСодержимое пустого состояния вместо текста по умолчанию.
loadinganyЗаглушки загрузки вместо строк по умолчанию.

Типы

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

Импорт
import type { GrTimelineTone, GrTimelineDensity, GrTimelineLayout } from '@feugene/granularity/components/GrTimeline'

Файлdist/types/components/GrTimeline/grTimelineStyles.d.ts

GrTimelineTonetype
type GrTimelineTone = GrTone;
GrTimelineDensitytype
type GrTimelineDensity = typeof GR_TIMELINE_DENSITIES[number];
GrTimelineLayouttype
type GrTimelineLayout = typeof GR_TIMELINE_LAYOUTS[number];
GrTimelineOrientationtype
type GrTimelineOrientation = typeof GR_TIMELINE_ORIENTATIONS[number];
GrTimelineMarkerVarianttype
type GrTimelineMarkerVariant = typeof GR_TIMELINE_MARKER_VARIANTS[number];

Файлdist/types/components/GrTimeline/timelineGroups.d.ts

GrTimelineGroupBytype

Группировка ленты событий. Без Vue — чтобы проверялась без монтирования.

type GrTimelineGroupBy<T> = string | ((item: T, index: number) => string);
/** Пункт вместе с его позицией в исходном наборе: ключи `v-for` считаются от неё. */
export interface GrTimelineEntry<T> {
GrTimelineEntryinterface

Пункт вместе с его позицией в исходном наборе: ключи v-for считаются от неё.

interface GrTimelineEntry<T> {
    item: T;
    index: number;
}
GrTimelineGroupinterface
interface GrTimelineGroup<T> {
    /** Ключ группы. Пустая строка — заголовка у группы нет. */
    key: string;
    entries: GrTimelineEntry<T>[];
}

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

GrTimelinePropsinterface
interface GrTimelineProps<TItem = unknown> {
    /**
     * Данные ленты. С ними пункты рисует слот `#item` — и только так лента знает
     * состав набора, то есть может его группировать.
     */
    items?: TItem[];
    /** Ключ пункта для `v-for`: имя поля или функция. Без него — индекс. */
    itemKey?: string | ((item: TItem, index: number) => string | number);

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

GrTimelineItemPropsinterface
interface GrTimelineItemProps {
    /** Метка времени. Слот `#time` сильнее. */
    time?: string;
    /** Машиночитаемое значение метки: попадает в `datetime` тега `<time>`. */
    datetime?: string;
    /** Заголовок события. Слот `#title` сильнее. */
    title?: string;
    /** Пояснение под заголовком. Слот `#description` сильнее. */
    description?: string;
    /** Цвет маркера из палитры пакета. */
    tone?: GrTimelineTone;
    /** Заполненная точка — событие случилось, полая — запланировано. */
    variant?: GrTimelineMarkerVariant;
    /**
     * Событие ещё не произошло: точка полая, а ось пунктирна начиная с участка,
     * **ведущего** к нему, — непройденный путь. Перебивает `variant`.
     */
    pending?: boolean;
}

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

grTimelineConfigconst
const grTimelineConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTimeline">;

Файлdist/types/components/GrTimeline/grTimelineContext.d.ts

GrTimelineHeadingLeveltype
type GrTimelineHeadingLevel = typeof GR_TIMELINE_HEADING_LEVELS[number];

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

grTimelineSafelistconst
const grTimelineSafelist: string[];

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

GrTimelineConfigurablePropsinterface

Пропы GrTimeline, настраиваемые глобально через componentDefaults. Только оформление: данные, группировка и состояние загрузки принадлежат конкретному экземпляру.

interface GrTimelineConfigurableProps {
    layout: GrTimelineLayout;
    orientation: GrTimelineOrientation;
    density: GrTimelineDensity;
    groupHeadingLevel: GrTimelineHeadingLevel;
}

Доступность

Паттерн APG

—

КлавишаДействие
—своих клавиш нет: лента показывает, а не выбирает, и таб-порядок внутри неё принадлежит вложенному интерактиву. orientation="horizontal" делает её скроллером, поэтому лента встаёт в таб-порядок сама (tabindex="0") — иначе прокрутить её с клавиатуры было бы нечем

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

Два режима подачи, как у GrList

Разнородную ленту пишут пунктами: GrTimelineItem подряд. Однородный набор отдают пропом — и только тогда лента знает состав данных, то есть может их сгруппировать:

vue
<GrTimeline :items="events" item-key="id" group-by="day">
  <template #group="{ group, items }">{{ group }} · {{ items.length }}</template>
  <template #item="{ item }">
    <GrTimelineItem :time="item.at" :title="item.title" :tone="item.tone" />
  </template>
</GrTimeline>

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

Семантика

Плоская лента — <ol>: события упорядочены во времени, и диктор обязан это объявить. Группа — <section> с заголовком <h3> (уровень задаётся groupHeadingLevel) и своим <ol> внутри. Заголовок дня не делается пунктом списка: <li> с датой попал бы в набор событий и был бы объявлен одним из них.

Ось и маркеры декоративны (aria-hidden) — смысл несёт текст события. Тон маркера ничего не сообщает сверх написанного словами: цвет здесь усиливает, а не заменяет.

Метка времени — <time>; с datetime она ещё и машиночитаема.

Раскладки
layoutКогда
stackedпо умолчанию: одна ось слева, время частью содержимого
timeаудит-лог: метки времени стоят колонкой и читаются столбцом
alternateпрезентационная лента истории — стороны чередуются

orientation="horizontal" разворачивает ось слева направо, события становятся колонками, а лента — скроллером (и получает tabindex="0": скроллящийся блок обязан быть достижим с клавиатуры). Вертикальные раскладки в ней не применяются — у горизонтальной оси нет ни колонки времени, ни сторон, — и в DEV компонент об этом предупреждает.

alternate на узком экране схлопывается в одностороннюю: двухсторонняя лента в 320px нечитаема, и оставлять это потребителю значило бы оставить ему баг.

time в узкой колонке ведёт себя иначе: колонка времени остаётся на всех ширинах — ради неё раскладку и берут, — а сжимается колонка содержимого. Её ширину задаёт хук --gr-timeline-time-width (по умолчанию 5.5rem), и в совсем тесном месте её имеет смысл убавить.

Усечение длинного текста — дело потребителя: поставьте truncate (или свой text-overflow) на заголовок. Лента для этого сжиматься умеет: её гибкие треки объявлены minmax(0, 1fr), а не голым 1fr. Разница неочевидна и стоит пояснения: 1fr — это minmax(auto, 1fr), минимум такого трека равен min-content содержимого, и при white-space: nowrap от truncate это полная ширина строки. Трек раздавался бы под текст, усекать было бы нечего, а строка выносила бы себя за край — то есть truncate не срабатывал бы по построению.

Почему раскладки на CSS

Сторона пункта в alternate — это nth-child, обрыв оси на последнем событии — last-child. Пункт не знает своего индекса и знать не должен: регистрация детей в контексте на onMounted дала бы порядок, зависящий от порядка монтирования, и разъезжалась бы на v-if посреди ленты.

Отсюда же форма разметки: <li> рисует сам GrTimelineItem — в обоих режимах, в data-режиме он стоит внутри слота #item. Ось — отрезок у каждого пункта, а не одна длинная линия: соседние отрезки смыкаются сами, и мерить высоту списка не приходится.

Маркер

tone красит точку ролью тона, variant="outlined" делает её полой, слот #marker заменяет точку чем угодно — иконкой статуса, аватаром автора, номером шага.

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

Пустота определяется сама

empty не задан — лента решает сама: в data-режиме по длине items, в слот-режиме по тому, есть ли в слоте осмысленные узлы. Проверка не сводится к «слот не пуст»: v-for по пустому массиву оставляет фрагмент без узлов, а v-if — комментарий, и ни то ни другое пунктом ленты не является. Без этого лента с v-for по пустым данным рисовала бы ось из ничего вместо объяснения.

Проп перебивает автоопределение в обе стороны — он нужен там, где «пусто» решает сервер, а не разметка. Текст меняется emptyText, вид целиком — слотом #empty.

loading показывает loadingRows строк-заглушек (по умолчанию три) на GrSkeleton, а не спиннер поверх пустоты: заглушки держат высоту, и появление данных не сдвигает страницу.

Границы
  • своей клавиатуры нет — лента показывает, а не выбирает; интерактив внутрь кладёт потребитель, и таб-порядок его;
  • пункт не кликается сам — кликабельная строка собирается вложенным GrLink/GrButton, как и в GrList;
  • виртуализации нет — useVirtualList рассчитан на однородную высоту строки, а события разнородны; тысяча событий в интерфейсе — это пагинация, а не окно;
  • анимации появления нет — движение требует своей ветки prefers-reduced-motion, а ценность здесь декоративная;
  • это не GrSteps — горизонтальная лента похожа на степпер, но шагами не является: ни aria-current, ни навигации, ни валидации по шагам.