GrTimeline
Берут, когда события идут во времени.
Обзор
Когда брать
- события идут во времени — история заказа, аудит-лог, статус доставки: порядок и есть смысл;
- события группируются по дням —
groupByрасставляет заголовки, не требуя готовить данные; - процесс не закончен — незавершённый шаг рисуется пунктиром, а не выглядит как выполненный;
- лента строится горизонтально —
orientationдля шкалы прогресса вместо колонки.
Когда взять другое
| Нужно | Берите |
|---|---|
| Порядок не важен, строки однородные | GrList |
| Данные табличные | GrDataTable |
| Шаги мастера с переходами вперёд и назад | GrTabs |
| Момент относительно сейчас | GrRelativeTime |
| Как менялась величина | GrChartLine |
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrTimeline emptyText="No events yet" />Примеры 4 у каждого — живое демо и его код
Где сейчас заказ
Шаги разные, поэтому лента пишется пунктами
GrTimelineItem. Каждая метка —<time datetime>, машиночитаемая. То, что ещё не случилось, —pending: полая точка и пунктир на участке к ней, непройденный путь. Слотmarkerставит иконку там, где статус важнее точки, а слот по умолчанию несёт ссылку на отслеживание.Order BV-20418 · Oak writing deskArrives Thursday, 9 October - Order placedPaid by card ending 6411
- Packed at the Lisbon warehouse
- Handed to the courierTracking number CTT RH4471 9902 PT
- Out for deliveryBetween 09:00 and 13:00
- Delivered
Журнал аудита по дням
Однородный набор отдаётся
items— только так лента может его сгруппировать.group-byрежет события по дням в том порядке, в каком они пришли, без сортировки и разбора дат, а слотgroupформулирует заголовок уровняgroup-heading-level3.layout="time"ставит время колонкой,density="compact"держит событие в одну строку,loadingкладёт заглушки на место строк, а слотemptyотвечает на фильтр, под который ничего не попало.Audit log Today · 3 events
- Mina Ortiz signed in from Lisbon
- Daniel Kim refunded €48.00 on INV-2026-0411
- System blocked 5 sign-ins from an unknown device
Yesterday · 2 events
- Grace Wu made Jonas Weber an admin
- Daniel Kim issued 42 invoices for October
Публичная дорожная карта
orientation="horizontal"разворачивает ось слева направо, а ленту превращает в скроллер, который принимает фокус клавиатуры, — прокрутить его можно и без мыши. Выпуск в бете —variant="outlined": полая точка на сплошной дороге; то, что ещё впереди, —pending, и дорога к нему пунктирная.Roadmap 2026–2027 - 4.0 · Multi-currency
- 4.1 · SEPA Instant payouts
- 4.2 · Prorated seats
- 4.3 · Usage-based plans · beta
- 5.0 · Revenue recognition
История компании для страницы «О нас»
layout="alternate"ставит события по обе стороны оси; на узком экране лента сама складывается в одну сторону — двухстороннюю в 320px не прочесть. Веха, которая пока только план, —pending.How Northwind Billing grew - Two people, one spreadsheetInvoices for a Lisbon coworking, sent by hand
- The first 100 customersRecurring billing and card payments
- SEPA and VAT across the EUOffices in Lisbon and Dublin
- 1,000 businesses€40M billed through the platform in a year
- Revenue recognitionPlanned: the books close themselves
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` сильнее. |
Слоты
| Slot | Type | Описание |
|---|---|---|
default | any | Пункты ленты (`GrTimelineItem`), когда данные не переданы пропом. |
item | { item: T; index: number; } | Пункт в data-режиме. |
group | { group: string; items: T[]; } | Заголовок группы. По умолчанию — сам ключ. |
empty | any | Содержимое пустого состояния вместо текста по умолчанию. |
loading | any | Заглушки загрузки вместо строк по умолчанию. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrTimelineTone, GrTimelineDensity, GrTimelineLayout } from '@feugene/granularity/components/GrTimeline'Файлdist/types/components/GrTimeline/grTimelineStyles.d.ts
type GrTimelineTone = GrTone;type GrTimelineDensity = typeof GR_TIMELINE_DENSITIES[number];type GrTimelineLayout = typeof GR_TIMELINE_LAYOUTS[number];type GrTimelineOrientation = typeof GR_TIMELINE_ORIENTATIONS[number];type GrTimelineMarkerVariant = typeof GR_TIMELINE_MARKER_VARIANTS[number];Файлdist/types/components/GrTimeline/timelineGroups.d.ts
Группировка ленты событий. Без Vue — чтобы проверялась без монтирования.
type GrTimelineGroupBy<T> = string | ((item: T, index: number) => string);
/** Пункт вместе с его позицией в исходном наборе: ключи `v-for` считаются от неё. */
export interface GrTimelineEntry<T> {Пункт вместе с его позицией в исходном наборе: ключи v-for считаются от неё.
interface GrTimelineEntry<T> {
item: T;
index: number;
}interface GrTimelineGroup<T> {
/** Ключ группы. Пустая строка — заголовка у группы нет. */
key: string;
entries: GrTimelineEntry<T>[];
}Файлdist/types/components/GrTimeline/GrTimeline.vue.d.ts
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
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
const grTimelineConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTimeline">;Файлdist/types/components/GrTimeline/grTimelineContext.d.ts
type GrTimelineHeadingLevel = typeof GR_TIMELINE_HEADING_LEVELS[number];Файлdist/types/components/GrTimeline/safelist.d.ts
const grTimelineSafelist: string[];Файлdist/types/components/GrTimeline/defaults.d.ts
Пропы GrTimeline, настраиваемые глобально через componentDefaults.
Только оформление: данные, группировка и состояние загрузки принадлежат
конкретному экземпляру.
interface GrTimelineConfigurableProps {
layout: GrTimelineLayout;
orientation: GrTimelineOrientation;
density: GrTimelineDensity;
groupHeadingLevel: GrTimelineHeadingLevel;
}Доступность
Паттерн APG
—
| Клавиша | Действие |
|---|---|
| — | своих клавиш нет: лента показывает, а не выбирает, и таб-порядок внутри неё принадлежит вложенному интерактиву. orientation="horizontal" делает её скроллером, поэтому лента встаёт в таб-порядок сама (tabindex="0") — иначе прокрутить её с клавиатуры было бы нечем |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Два режима подачи, как у 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, ни навигации, ни валидации по шагам.
Пропы этого примера