GrList

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

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

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

Обзор

Когда брать

  • строки однородные — уведомления, участники, файлы: заголовок, описание, префикс и действие уже разложены;
  • разметку строки писать не хочется — GrListItem закрывает типовой случай целиком;
  • строк тысячи — virtual оставляет в DOM только окно вокруг вьюпорта;
  • данные ещё грузятся — loading с loadingRows показывает скелетоны вместо прыжка раскладки.

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

НужноБерите
Данные табличные, со столбцамиGrTable
Нужны сортировка, выбор строк, слоты ячеекGrDataTable
Строки вложены друг в другаGrTree
Порядок строк меняет пользовательGrSortableList
Строки — события во времениGrTimeline
Строка — пара «характеристика → значение»GrDescriptionList
Строк нет, и надо объяснить почемуGrEmptyState

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

variant

  • elevated
  • outlined
  • ghost

Состояния

  • обычная
  • loading

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

Сцена

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

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

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

  • Строки-ссылки, строки-действия и спокойные строки

    GrListItem раскладывает префикс, заголовок и описание. С href вся строка — одна ссылка, clickable делает её кнопкой с событием click, а disabled гасит её фоном и глушит. Слот title принимает разметку, когда пропа мало.

  • Список внутри карточки

    variant="ghost" убирает собственную поверхность списка, и список внутри карточки не рисует вторую рамку. density="compact" уплотняет строки, слот prefix держит значок слева, а слот по умолчанию стоит справа — здесь шеврон, в другом месте счётчик или действие.

  • Данные, ожидание и пустота

    С items строки рисует слот #item, а пустоту список видит по длине данных — без v-if вокруг. loading показывает строки-скелетоны той же плотности и сильнее пустоты, а слот #empty держит выход вместо тупика.

    Admin · Sent 2 days ago
    Editor · Sent 5 days ago
    Viewer · Expires tomorrow
  • Пять тысяч строк, пятнадцать в DOM

    virtual вместе с max-height держит в DOM только окно вокруг вьюпорта. v-bind="aria" отдаёт каждой строке aria-setsize и aria-posinset, а scrollToIndex доходит до строки, которой в DOM нет вовсе.

    5 000 builds, about 15 in the DOM at a time
    #5000 · main
    Passed in 2 min 12 s
    #4999 · chore/deps
    Passed in 6 min 11 s
    #4998 · feat/badge-dot
    Passed in 5 min 10 s
    #4997 · fix/tree-current-row
    Passed in 4 min 58 s
    #4996 · release/1.0
    Passed in 3 min 57 s
    #4995 · main
    Passed in 2 min 56 s
    #4994 · chore/deps
    Failed in 6 min 55 s
    #4993 · feat/badge-dot
    Passed in 5 min 54 s
    #4992 · fix/tree-current-row
    Passed in 4 min 53 s
    #4991 · release/1.0
    Passed in 3 min 52 s

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

Props

PropTypeпо умолчаниюОписание
variantGrCardVariant | undefinedundefinedПоверхность списка — вариант карточки под ним. `ghost` убирает рамку и тень: список внутри уже существующей карточки не должен давать вторую.
dividedboolean | undefinedtrueПоказывать ли горизонтальные разделители между элементами (по умолчанию — да).
loadingboolean | undefinedfalseИдёт загрузка: вместо пунктов — строки-скелетоны, контейнер помечен `aria-busy`.
loadingRowsnumber | undefined3Сколько строк-заглушек показать при `loading`.
emptyboolean | undefinedundefinedСписок пуст. По умолчанию определяется сам — по содержимому слота; проп нужен там, где потребитель знает лучше (например, слот заполнен заголовками групп, а данных в них нет).
emptyTextstring | undefinedundefinedТекст пустого состояния. Слот `#empty` сильнее.
itemsT[] | undefinedundefinedДанные списка. С ними пункты рисует слот `#item`, а не слот по умолчанию, — и только так список знает размер набора, то есть может его виртуализировать.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedКлюч пункта для `v-for`: имя поля или функция. Без него — индекс.
maxHeightstring | number | undefinedundefinedВысота видимой части: контейнер становится скроллером. Число — пиксели.
virtualboolean | undefinedfalseДержать в DOM только окно вокруг вьюпорта. Требует `items` и `maxHeight`.
estimatedItemSizenumber | undefined56Оценка высоты пункта до замера. Уточняется по факту при первом рендере.

Слоты

SlotTypeОписание
defaultanyПункты списка, когда разметка пишется руками вместо `items`.
item{ item: T; index: number; aria: Record<string, number>; }Пункт в data-режиме. `aria` проставляется только в виртуальном режиме — когда набор в DOM неполный и размер списка браузеру взять неоткуда.
loadinganyСодержимое, пока едут данные.
emptyanyПустое состояние вместо текста по умолчанию.

Типы

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

Импорт
import type { GrListProps, GrListItemDensity, GrListItemProps } from '@feugene/granularity/components/GrList'

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

GrListPropsinterface
interface GrListProps<TItem = unknown> {
    /**
     * Поверхность списка — вариант карточки под ним. `ghost` убирает рамку и
     * тень: список внутри уже существующей карточки не должен давать вторую.
     */
    variant?: GrCardVariant;
    /** Показывать ли горизонтальные разделители между элементами (по умолчанию — да). */
    divided?: boolean;
    /** Идёт загрузка: вместо пунктов — строки-скелетоны, контейнер помечен `aria-busy`. */
    loading?: boolean;
    /** Сколько строк-заглушек показать при `loading`. */
    loadingRows?: number;
    /**
     * Список пуст. По умолчанию определяется сам — по содержимому слота; проп
     * нужен там, где потребитель знает лучше (например, слот заполнен
     * заголовками групп, а данных в них нет).
     */
    empty?: boolean;
    /** Текст пустого состояния. Слот `#empty` сильнее. */
    emptyText?: string;
    /**
     * Данные списка. С ними пункты рисует слот `#item`, а не слот по умолчанию, —
     * и только так список знает размер набора, то есть может его виртуализировать.
     */
    items?: TItem[];
    /** Ключ пункта для `v-for`: имя поля или функция. Без него — индекс. */
    itemKey?: string | ((item: TItem, index: number) => string | number);

Файлdist/types/components/GrList/grListStyles.d.ts

GrListItemDensitytype
type GrListItemDensity = 'compact' | 'regular';

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

GrListItemPropsinterface
interface GrListItemProps {
    /** Заголовок строки. Если передан слот `#title`, проп игнорируется. */
    title?: string;
    /** Описание под заголовком. Если передан слот `#description`, проп игнорируется. */
    description?: string;
    /** Плотность вертикальных отступов: `regular` — 12px, `compact` — 8px. */
    density?: GrListItemDensity;
    /** Ссылка: строка становится `<a>`. */
    href?: string;
    /**
     * Свой корневой тег строки: компонент-ссылка (`RouterLink`, `Link` от Inertia)
     * или тег ради разметки. Сильнее `href`. Неинтерактивный тег — только замена
     * `div`: ни подсветки, ни кольца фокуса.
     */
    as?: string | Component;
    /**
     * Цель роутерной ссылки: уезжает в компонент из `as`. Пропом, а не атрибутом:
     * атрибуты достаются обёртке с ролью `listitem`, до строки они не доходят.
     */
    to?: unknown;
    /** Кликабельная строка без ссылки — `<button>` с событием `click`. */
    clickable?: boolean;
    /** Подсветка при наведении без интерактивности. */
    hoverable?: boolean;
    disabled?: boolean;
}
GrListItemEmitsinterface
interface GrListItemEmits {
    (e: 'click', event: MouseEvent): void;
}

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

grListConfigconst
const grListConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrList">;

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

grListSafelistconst

Пуст: составных классов у списка и его пункта нет. Плотность берётся из мапы densityPadding целым литералом, как и остальные классы grListStyles.ts, и granum извлекает их из чанков компонента сам, включая общие.

const grListSafelist: string[];

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

GrListInstancetype
type GrListInstance = ComponentExposed<typeof GrListComponent>;

Доступность

Паттерн APG

list

КлавишаДействие
—кликабельная строка — обычная кнопка или ссылка: своя остановка Tab
—Enter/Space активируют. При maxHeight список становится скроллером и встаёт в таб-порядок сам (tabindex="0")

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

Поверхность

Список рисует под собой GrCard, и variant доходит до неё:

vue
<GrCard>
  <h3>Настройки</h3>
  <GrList variant="ghost">…</GrList>
</GrCard>

ghost убирает рамку и тень — список внутри уже существующей карточки не даёт вторую. Без пропа вариант берётся из GrConfigProvider (componentDefaults.GrCard.variant), иначе — elevated.

Список обрезается по радиусу этой карточки. Своего радиуса у строки нет и не нужно — строки идут вплотную, — но поля у карточки тоже нет, и без клипа фон первой и последней строки заливал бы её углы: постоянно у disabled и по наведению у кликабельной.

Пустое состояние

Список сам видит, что пунктов нет: v-for по пустому массиву оставляет фрагмент без узлов, v-if — комментарий, и ни то ни другое пунктом не является. Условие v-if/v-else вокруг списка писать больше не нужно.

vue
<GrList>
  <GrListItem v-for="item in items" :key="item.id" :title="item.title" />

  <template #empty>
    Ни одной заявки. <GrButton size="sm">Создать</GrButton>
  </template>
</GrList>

Без слота показывается приглушённая строка emptyText (по умолчанию — ключ gr.list.empty). Проп empty остаётся escape-hatch’ем в обе стороны: там, где слот заполнен, скажем, заголовками групп, потребитель знает про пустоту лучше.

Разделители в пустой ветке не рисуются — иначе висела бы линия ни между чем.

Заглушка не оборачивается в GrEmptyState: список уже внутри карточки, и получилась бы карточка в карточке. Богатое пустое состояние кладётся в слот осознанно.

Загрузка
vue
<GrList :loading="pending" :loading-rows="5" />

Вместо пунктов — строки GrSkeleton той же плотности, контейнер помечен aria-busy="true". Слот #loading заменяет заглушки целиком. Загрузка сильнее пустоты: иначе список мигал бы текстом «пока пусто» на каждом запросе.

Кликабельная строка
vue
<GrListItem title="Профиль" href="/settings/profile" />

<GrListItem title="Выйти" clickable @click="signOut" />

<GrListItem title="Раздел" :as="RouterLink" :to="{ name: 'section' }" />

Порядок выбора тега — as → <a href> → <button clickable> → <div>, как у GrSidebarItem.

Ключевая деталь разметки: role="listitem" остаётся на обёртке, а строка целиком — вложенный элемент. <a role="listitem"> потерял бы роль ссылки, а интерактив снаружи GrListItem разорвал бы связку role="list" → role="listitem" и список перестал бы быть списком.

Структура одна на любой пункт: обёртка держит роль, вложенный элемент — вид и поведение. У обычной строки это <div>, у кликабельной — <a>, <button> или тег из as; отличает их атрибут data-gr-list-item-action, который есть только у кликабельной.

Отсюда же to — проп, а не атрибут: атрибуты компонента достаются обёртке с ролью, и до компонента-ссылки из as цель перехода иначе не доехала бы.

as называет тег, а не поведение. Строка, которой тег нужен ради разметки, остаётся ровно тем же, чем была:

vue
<GrListItem title="Профиль" as="article" />

Меняется только тег: ни подсветки под курсором, ни кольца фокуса, ни атрибута data-gr-list-item-action. Контролом строку делают href, clickable и компонент-ссылка — то есть поведение, которое кто-то действительно обещал.

as="li" тут не подходит, и это не придирка: контейнер списка — div с role="list", а <li> обязан лежать в <ul> или <ol> (axe: listitem). Роль пункта уже несёт обёртка — тег из as нужен содержимому, а не списку.

Действие на теге вне таб-порядка — ошибка вёрстки. <span> с обработчиком клика работает для мыши и только для неё: он не попадает в таб-порядок и не отвечает на Enter (WCAG 2.1.1). Клик такая строка эмитит — молча выключить его значило бы сломать работающий код, — но кольца фокуса не получает и ловит предупреждение в dev-сборке. Компоненты роутера проходят молча: они рендерят <a>, и до рендера этого не узнать.

hoverable подсвечивает строку при наведении, не делая её кнопкой. disabled возвращает строку к неинтерактивному виду, гасит её фоном --gr-muted (не opacity — прозрачность разбавляет выверенные на AA токены) и глушит событие click.

Плотность и разделители

density у пункта: regular (12px) или compact (8px). divided у списка включает разделители между пунктами — по умолчанию включены.

Заголовок и описание строки идут от --gr-text-sm — размер меняется вместе со шкалой темы, а иерархию внутри строки держат вес и цвет.

Классы обеих карт живут в grListStyles.ts и целиком объявлены в safelist.

Данные вместо слота

С пропом items пункты рисует слот #item, а не слот по умолчанию. Прежний режим при этом цел и остаётся дефолтом: items его не заменяет, а добавляет второй способ.

vue
<GrList :items="rows" item-key="id">
  <template #item="{ item }">
    <GrListItem :title="item.title" :description="item.subtitle" />
  </template>
</GrList>

item-key — имя поля или функция (item, index); без него ключом идёт индекс. Пустоту список в этом режиме видит по длине items, а не по содержимому слота.

max-height (число — пиксели) делает контейнер скроллером и добавляет ему tabindex="0": скроллящийся блок обязан быть достижим тем, кто без мыши. Проп работает и сам по себе — включение виртуализации ниже вид не меняет.

Виртуализация

virtual оставляет в DOM только окно вокруг вьюпорта. Требует items (иначе размер набора списку неоткуда взять) и max-height (иначе нет вьюпорта); без любого из двух в dev-сборке будет предупреждение.

vue
<GrList :items="builds" item-key="id" virtual :max-height="320">
  <template #item="{ item, aria }">
    <GrListItem v-bind="aria" :title="item.title" />
  </template>
</GrList>

v-bind="aria" обязателен. В DOM живёт неполный набор, и без aria-setsize/aria-posinset диктор посчитает пункты по окну — объявит «3 из 12» на списке в пять тысяч. Поставить атрибуты за потребителя список не может: разметка пункта принадлежит слоту. Поэтому набор приходит готовым объектом, а забытый проброс ловится после монтирования и печатает предупреждение — тишины не будет. Вне виртуального режима aria пуст: полный набор браузер считает сам.

Высота пункта уточняется замером, но до первого рендера её неоткуда взять — оценка задаётся estimated-item-size (по умолчанию 56, строка обычной плотности с описанием). Грубая оценка не ломает прокрутку, но делает бегунок неровным на первых кадрах.

scrollToIndex(index, align?) доступен через ref на списке — единственный способ добраться до пункта вне окна: в DOM его нет, и scrollIntoView не по чему звать.

Общие правила и ограничения приёма — docs/virtual-list.md.