GrTimeline
Берут, когда события идут во времени.
Когда брать
- события идут во времени — история заказа, аудит-лог, статус доставки: порядок и есть смысл;
- события группируются по дням —
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
items | T[] | undefined | undefined | Данные ленты. С ними пункты рисует слот `#item` — и только так лента знает состав набора, то есть может его группировать. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | Ключ пункта для `v-for`: имя поля или функция. Без него — индекс. |
groupBy | GrTimelineGroupBy<T> | undefined | undefined | Группировка: имя поля или функция от пункта. Заголовок группы — слот `#group`. |
groupHeadingLevel | 2 | 3 | 4 | 5 | 6 | undefined | undefined | Уровень заголовка группы: лента обязана вписаться в структуру заголовков страницы. |
layout | "stacked" | "time" | "alternate" | undefined | undefined | Раскладка: одна ось, колонка времени слева или чередование сторон. |
orientation | "horizontal" | "vertical" | undefined | undefined | Направление оси. Горизонтальная лента — скроллер. |
density | "regular" | "compact" | undefined | undefined | Плотность вертикальных отступов пункта. |
loading | boolean | undefined | false | Идёт загрузка: вместо пунктов — заглушки, контейнер помечен `aria-busy`. |
loadingRows | number | undefined | 3 | Сколько строк-заглушек показать при `loading`. |
empty | boolean | undefined | undefined | Лента пуста. По умолчанию определяется сама — по данным или по слоту; проп нужен там, где потребитель знает лучше. |
emptyText | string | undefined | undefined | Текст пустого состояния. Слот `#empty` сильнее. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Пункты ленты (`GrTimelineItem`), когда данные не переданы пропом. |
item | { item: T; index: number; } | Пункт в data-режиме. |
group | { group: string; items: T[]; } | Заголовок группы. По умолчанию — сам ключ. |
empty | any | Содержимое пустого состояния вместо текста по умолчанию. |
loading | any | Заглушки загрузки вместо строк по умолчанию. |
Примеры 4
История заказа
Событие — это GrTimelineItem с меткой времени, заголовком и тоном. Последний пункт pending: полая точка и пунктир вместо сплошной оси.
- Заказ созданИванов И., через корзину
- ОплаченКартой •• 4242
- Передан в доставкуТрек-номер RU8412093
- Вручение получателю
<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>Четыре раскладки
Колонка времени слева, чередование сторон и горизонтальная ось — одним пропом. В горизонтальной ленте вертикальные раскладки не применяются.
- Сборка запущена
- Тесты пройдены
- Задеплоено на stage
- Ждём approve
<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
- Изменила права роли «Менеджер»Петрова А.
- Пригласил пользователяИванов И.
11 августа · 2
- Ротация ключей APIСистема
- Удалил проект «Архив 2024»Сидоров П.
Порядок задаёт приложение: groupBy только режет набор на группы и не сортирует его.
<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.
- Списание за подписку «Расширенный доступ» на месяц
- Пополнение с карты •• 4417 через платёжный шлюз
- Возврат по отменённой операции от 11 августа
<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") — иначе прокрутить её с клавиатуры было бы нечем