GrTimeline

Пакет: @feugene/granularityядроГруппа: Данные

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

Когда брать

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

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

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

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

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

<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, ни навигации, ни валидации по шагам.

Playground 7

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

Код
<GrTimeline />

Установка

npm i @feugene/granularity

Импорт

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

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` сильнее.

Slots

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

Примеры 4

История заказа

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

  1. Заказ создан
    Иванов И., через корзину
  2. Оплачен
    Картой •• 4242
  3. Передан в доставку
    Трек-номер RU8412093
  4. Вручение получателю

Basic
<script setup lang="ts">
import { GrTimeline, GrTimelineItem } from '@feugene/granularity'
</script>

<template>
  <div class="max-w-md">
    <GrTimeline>
      <GrTimelineItem
        time="10:24"
        datetime="2026-08-12T10:24"
        title="Заказ создан"
        description="Иванов И., через корзину"
        tone="primary"
      />
      <GrTimelineItem
        time="11:03"
        datetime="2026-08-12T11:03"
        title="Оплачен"
        description="Картой •• 4242"
        tone="success"
      />
      <GrTimelineItem
        time="14:47"
        datetime="2026-08-12T14:47"
        title="Передан в доставку"
        description="Трек-номер RU8412093"
        tone="info"
      />
      <GrTimelineItem
        time="ожидается"
        title="Вручение получателю"
        tone="neutral"
        pending
      />
    </GrTimeline>
  </div>
</template>

Четыре раскладки

Колонка времени слева, чередование сторон и горизонтальная ось — одним пропом. В горизонтальной ленте вертикальные раскладки не применяются.

  1. Сборка запущена
  2. Тесты пройдены
  3. Задеплоено на stage
  4. Ждём approve

Layouts
<script setup lang="ts">
import { ref } from 'vue'
import { GrSegmented, GrTimeline, GrTimelineItem } from '@feugene/granularity'

type Layout = 'stacked' | 'time' | 'alternate' | 'horizontal'

const layout = ref<Layout>('time')

const options = [
  { value: 'stacked', label: 'Одна ось' },
  { value: 'time', label: 'Колонка времени' },
  { value: 'alternate', label: 'Чередование' },
  { value: 'horizontal', label: 'Горизонталь' },
]

const events = [
  { at: '09:15', title: 'Сборка запущена', tone: 'primary' as const },
  { at: '09:22', title: 'Тесты пройдены', tone: 'success' as const },
  { at: '09:24', title: 'Задеплоено на stage', tone: 'info' as const },
  { at: '09:40', title: 'Ждём approve', tone: 'warning' as const, pending: true },
]
</script>

<template>
  <div class="grid gap-5">
    <GrSegmented v-model="layout" :options="options" size="sm" />

    <GrTimeline
      :items="events"
      item-key="at"
      :layout="layout === 'horizontal' ? 'stacked' : layout"
      :orientation="layout === 'horizontal' ? 'horizontal' : 'vertical'"
    >
      <template #item="{ item }">
        <GrTimelineItem
          :time="item.at"
          :title="item.title"
          :tone="item.tone"
          :pending="item.pending"
        />
      </template>
    </GrTimeline>
  </div>
</template>

Аудит-лог по дням

С items лента знает состав набора: groupBy режет его на группы, каждая — секция со своим заголовком, а ось между группами не рвётся.

12 августа · 2

  1. Изменила права роли «Менеджер»
    Петрова А.
  2. Пригласил пользователя
    Иванов И.

11 августа · 2

  1. Ротация ключей API
    Система
  2. Удалил проект «Архив 2024»
    Сидоров П.

Порядок задаёт приложение: groupBy только режет набор на группы и не сортирует его.

Grouped
<script setup lang="ts">
import { GrBadge, GrTimeline, GrTimelineItem } from '@feugene/granularity'

interface AuditEvent {
  id: number
  day: string
  at: string
  actor: string
  action: string
  tone: 'neutral' | 'success' | 'warning' | 'danger'
}

const events: AuditEvent[] = [
  { id: 1, day: '12 августа', at: '18:02', actor: 'Петрова А.', action: 'Изменила права роли «Менеджер»', tone: 'warning' },
  { id: 2, day: '12 августа', at: '11:41', actor: 'Иванов И.', action: 'Пригласил пользователя', tone: 'success' },
  { id: 3, day: '11 августа', at: '20:15', actor: 'Система', action: 'Ротация ключей API', tone: 'neutral' },
  { id: 4, day: '11 августа', at: '09:03', actor: 'Сидоров П.', action: 'Удалил проект «Архив 2024»', tone: 'danger' },
]
</script>

<template>
  <div class="max-w-lg">
    <GrTimeline :items="events" item-key="id" group-by="day" density="compact">
      <template #group="{ group, items }">
        {{ group }} · {{ items.length }}
      </template>

      <template #item="{ item }">
        <GrTimelineItem :time="item.at" :tone="item.tone">
          <template #title>
            {{ item.action }}
          </template>
          <template #description>
            {{ item.actor }}
          </template>
        </GrTimelineItem>
      </template>
    </GrTimeline>

    <p class="mt-4 text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">
      Порядок задаёт приложение: <GrBadge size="xs">groupBy</GrBadge> только режет набор на группы и не сортирует его.
    </p>
  </div>
</template>

Узкая колонка

Лента сжимается по доступному месту, а не выносит строку за край: гибкие треки объявлены minmax(0, 1fr), поэтому truncate на заголовке наконец срабатывает. Ширину колонки времени задаёт хук --gr-timeline-time-width.

  1. Списание за подписку «Расширенный доступ» на месяц
  2. Пополнение с карты •• 4417 через платёжный шлюз
  3. Возврат по отменённой операции от 11 августа

Narrow
<script setup lang="ts">
import { ref } from 'vue'

import { GrSegmented, GrTimeline, GrTimelineItem } from '@feugene/granularity'

/**
 * Ширина контейнера, а не окна: сжатие считается по доступному месту, поэтому
 * увидеть его можно не трогая размер браузера.
 *
 * Заголовки намеренно длинные и с `truncate`: пока трек ленты держал минимум по
 * содержимому, усечение не срабатывало — усекать было нечего, колонка раздавалась
 * под текст и выносила строку за край.
 */
const width = ref('260')
const widths = [
  { value: '260', label: '260px' },
  { value: '320', label: '320px' },
  { value: '480', label: '480px' },
]

const events = [
  { id: 1, at: '10:24', title: 'Списание за подписку «Расширенный доступ» на месяц', tone: 'neutral' as const },
  { id: 2, at: '11:02', title: 'Пополнение с карты •• 4417 через платёжный шлюз', tone: 'success' as const },
  { id: 3, at: '14:47', title: 'Возврат по отменённой операции от 11 августа', tone: 'warning' as const },
]
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented v-model="width" :options="widths" size="sm" class="justify-self-start" />

    <div
      data-demo-narrow-box
      class="rounded-[var(--gr-radius-md)] border border-dashed border-[var(--gr-brd)] p-3"
      :style="{ width: `${width}px`, maxWidth: '100%' }"
    >
      <GrTimeline layout="time" density="compact">
        <GrTimelineItem
          v-for="event in events"
          :key="event.id"
          :time="event.at"
          :tone="event.tone"
        >
          <template #title>
            <span class="block truncate">{{ event.title }}</span>
          </template>
        </GrTimelineItem>
      </GrTimeline>
    </div>
  </div>
</template>

Доступность

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

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты