GrList
Берут, когда строки однородные.
Обзор
Когда брать
- строки однородные — уведомления, участники, файлы: заголовок, описание, префикс и действие уже разложены;
- разметку строки писать не хочется —
GrListItemзакрывает типовой случай целиком; - строк тысячи —
virtualоставляет в DOM только окно вокруг вьюпорта; - данные ещё грузятся —
loadingсloadingRowsпоказывает скелетоны вместо прыжка раскладки.
Когда взять другое
| Нужно | Берите |
|---|---|
| Данные табличные, со столбцами | GrTable |
| Нужны сортировка, выбор строк, слоты ячеек | GrDataTable |
| Строки вложены друг в друга | GrTree |
| Порядок строк меняет пользователь | GrSortableList |
| Строки — события во времени | GrTimeline |
| Строка — пара «характеристика → значение» | GrDescriptionList |
| Строк нет, и надо объяснить почему | GrEmptyState |
Варианты все тона и виды — настоящим компонентом
variant
elevatedoutlinedghost
Состояния
обычнаяloading
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrList emptyText="No members yet">
<GrListItem title="Ada Lin" description="Owner · [email protected]" />
<GrListItem title="Grace Wu" description="Admin · [email protected]" />
<GrListItem title="Linus Ray" description="Editor · [email protected]" />
</GrList>Примеры 4 у каждого — живое демо и его код
Строки-ссылки, строки-действия и спокойные строки
GrListItemраскладывает префикс, заголовок и описание. Сhrefвся строка — одна ссылка,clickableделает её кнопкой с событиемclick, аdisabledгасит её фоном и глушит. Слотtitleпринимает разметку, когда пропа мало.Load older notificationsNothing older than 30 daysСписок внутри карточки
variant="ghost"убирает собственную поверхность списка, и список внутри карточки не рисует вторую рамку.density="compact"уплотняет строки, слотprefixдержит значок слева, а слот по умолчанию стоит справа — здесь шеврон, в другом месте счётчик или действие.Account settings
Everything about you and your workspace
Данные, ожидание и пустота
С
itemsстроки рисует слот#item, а пустоту список видит по длине данных — безv-ifвокруг.loadingпоказывает строки-скелетоны той же плотности и сильнее пустоты, а слот#emptyдержит выход вместо тупика.Admin · Sent 2 days agoEditor · Sent 5 days agoViewer · 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 · mainPassed in 2 min 12 s#4999 · chore/depsPassed in 6 min 11 s#4998 · feat/badge-dotPassed in 5 min 10 s#4997 · fix/tree-current-rowPassed in 4 min 58 s#4996 · release/1.0Passed in 3 min 57 s#4995 · mainPassed in 2 min 56 s#4994 · chore/depsFailed in 6 min 55 s#4993 · feat/badge-dotPassed in 5 min 54 s#4992 · fix/tree-current-rowPassed in 4 min 53 s#4991 · release/1.0Passed in 3 min 52 s
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | GrCardVariant | undefined | undefined | Поверхность списка — вариант карточки под ним. `ghost` убирает рамку и тень: список внутри уже существующей карточки не должен давать вторую. |
divided | boolean | undefined | true | Показывать ли горизонтальные разделители между элементами (по умолчанию — да). |
loading | boolean | undefined | false | Идёт загрузка: вместо пунктов — строки-скелетоны, контейнер помечен `aria-busy`. |
loadingRows | number | undefined | 3 | Сколько строк-заглушек показать при `loading`. |
empty | boolean | undefined | undefined | Список пуст. По умолчанию определяется сам — по содержимому слота; проп нужен там, где потребитель знает лучше (например, слот заполнен заголовками групп, а данных в них нет). |
emptyText | string | undefined | undefined | Текст пустого состояния. Слот `#empty` сильнее. |
items | T[] | undefined | undefined | Данные списка. С ними пункты рисует слот `#item`, а не слот по умолчанию, — и только так список знает размер набора, то есть может его виртуализировать. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | Ключ пункта для `v-for`: имя поля или функция. Без него — индекс. |
maxHeight | string | number | undefined | undefined | Высота видимой части: контейнер становится скроллером. Число — пиксели. |
virtual | boolean | undefined | false | Держать в DOM только окно вокруг вьюпорта. Требует `items` и `maxHeight`. |
estimatedItemSize | number | undefined | 56 | Оценка высоты пункта до замера. Уточняется по факту при первом рендере. |
Слоты
| Slot | Type | Описание |
|---|---|---|
default | any | Пункты списка, когда разметка пишется руками вместо `items`. |
item | { item: T; index: number; aria: Record<string, number>; } | Пункт в data-режиме. `aria` проставляется только в виртуальном режиме — когда набор в DOM неполный и размер списка браузеру взять неоткуда. |
loading | any | Содержимое, пока едут данные. |
empty | any | Пустое состояние вместо текста по умолчанию. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrListProps, GrListItemDensity, GrListItemProps } from '@feugene/granularity/components/GrList'Файлdist/types/components/GrList/GrList.vue.d.ts
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
type GrListItemDensity = 'compact' | 'regular';Файлdist/types/components/GrList/GrListItem.vue.d.ts
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;
}interface GrListItemEmits {
(e: 'click', event: MouseEvent): void;
}Файлdist/types/components/GrList/config.d.ts
const grListConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrList">;Файлdist/types/components/GrList/safelist.d.ts
Пуст: составных классов у списка и его пункта нет. Плотность берётся из мапы
densityPadding целым литералом, как и остальные классы grListStyles.ts,
и granum извлекает их из чанков компонента сам, включая общие.
const grListSafelist: string[];Файлdist/types/components/GrList/index.d.ts
type GrListInstance = ComponentExposed<typeof GrListComponent>;Доступность
Паттерн APG
list
| Клавиша | Действие |
|---|---|
| — | кликабельная строка — обычная кнопка или ссылка: своя остановка Tab |
| — | Enter/Space активируют. При maxHeight список становится скроллером и встаёт в таб-порядок сам (tabindex="0") |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Поверхность
Список рисует под собой GrCard, и variant доходит до неё:
<GrCard>
<h3>Настройки</h3>
<GrList variant="ghost">…</GrList>
</GrCard>ghost убирает рамку и тень — список внутри уже существующей карточки не даёт
вторую. Без пропа вариант берётся из GrConfigProvider
(componentDefaults.GrCard.variant), иначе — elevated.
Список обрезается по радиусу этой карточки. Своего радиуса у строки нет и не
нужно — строки идут вплотную, — но поля у карточки тоже нет, и без клипа фон
первой и последней строки заливал бы её углы: постоянно у disabled и по
наведению у кликабельной.
Пустое состояние
Список сам видит, что пунктов нет: v-for по пустому массиву оставляет фрагмент
без узлов, v-if — комментарий, и ни то ни другое пунктом не является. Условие
v-if/v-else вокруг списка писать больше не нужно.
<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: список уже внутри карточки, и
получилась бы карточка в карточке. Богатое пустое состояние кладётся в слот
осознанно.
Загрузка
<GrList :loading="pending" :loading-rows="5" />Вместо пунктов — строки GrSkeleton той же плотности, контейнер помечен
aria-busy="true". Слот #loading заменяет заглушки целиком. Загрузка сильнее
пустоты: иначе список мигал бы текстом «пока пусто» на каждом запросе.
Кликабельная строка
<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 называет тег, а не поведение. Строка, которой тег нужен ради разметки,
остаётся ровно тем же, чем была:
<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 его не заменяет, а добавляет
второй способ.
<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-сборке будет предупреждение.
<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.
Пропы этого примера