GrList
Берут, когда строки однородные.
Когда брать
- строки однородные — уведомления, участники, файлы: заголовок, описание, префикс и действие уже разложены;
- разметку строки писать не хочется —
GrListItemзакрывает типовой случай целиком; - строк тысячи —
virtualоставляет в DOM только окно вокруг вьюпорта; - данные ещё грузятся —
loadingсloadingRowsпоказывает скелетоны вместо прыжка раскладки.
Когда взять другое
| Нужно | Берите |
|---|---|
| Данные табличные, со столбцами | GrTable |
| Нужны сортировка, выбор строк, слоты ячеек | GrDataTable |
| Строки вложены друг в друга | GrTree |
| Порядок строк меняет пользователь | GrSortableList |
| Строки — события во времени | GrTimeline |
| Строка — пара «характеристика → значение» | GrDescriptionList |
| Строк нет, и надо объяснить почему | GrEmptyState |
Поверхность
Список рисует под собой 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>, как у
GrSidebarItem.
Ключевая деталь разметки: role="listitem" остаётся на обёртке, а строка
целиком — вложенный элемент. <a role="listitem"> потерял бы роль ссылки, а
интерактив снаружи GrListItem разорвал бы связку role="list" →
role="listitem" и список перестал бы быть списком.
Структура одна на любой пункт: обёртка держит роль, вложенный элемент — вид и
поведение. У обычной строки это <div>, у кликабельной — <a>, <button> или
тег из as; отличает их атрибут data-gr-list-item-action, который есть только
у кликабельной.
as обязан рендерить фокусируемый элемент. <span> с обработчиком клика —
контрол для мыши и только для неё: он не попадает в таб-порядок и не отвечает на
Enter (WCAG 2.1.1). Тег, названный строкой и не умеющий фокус (всё, кроме
button и a со ссылкой), ловит предупреждение в 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.
Playground 7
Загружается…
<GrList />Установка
npm i @feugene/granularityИмпорт
import { GrList } from '@feugene/granularity/components/GrList'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 | Оценка высоты пункта до замера. Уточняется по факту при первом рендере. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Пункты списка, когда разметка пишется руками вместо `items`. |
item | { item: T; index: number; aria: Record<string, number>; } | Пункт в data-режиме. `aria` проставляется только в виртуальном режиме — когда набор в DOM неполный и размер списка браузеру взять неоткуда. |
loading | any | Содержимое, пока едут данные. |
empty | any | Пустое состояние вместо текста по умолчанию. |
Примеры 5
Кликабельные строки
Пункт сам становится ссылкой или кнопкой (href / as / clickable), не разрывая связку role=\"list\" с role=\"listitem\".
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrList, GrListItem } from '@feugene/granularity'
const lastAction = ref('—')
const sections = [
{ id: 'profile', title: 'Профиль', description: 'Имя, аватар, контакты', badge: 'Готово' },
{ id: 'billing', title: 'Оплата', description: 'Карта и счета', badge: '2 счёта' },
{ id: 'audit', title: 'Журнал доступа', description: 'Архивный раздел', disabled: true },
]
</script>
<template>
<div class="grid gap-3">
<GrList>
<!-- Кликабельная строка — сам пункт: обёртка вокруг него рвала бы связку
role="list" с role="listitem". -->
<GrListItem
v-for="section in sections"
:key="section.id"
:title="section.title"
:description="section.description"
:clickable="!section.disabled"
:disabled="section.disabled"
@click="lastAction = section.title"
>
<GrBadge v-if="section.badge" size="sm" tone="neutral">
{{ section.badge }}
</GrBadge>
</GrListItem>
</GrList>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Последнее действие: <span class="font-semibold text-[var(--gr-fg)]">{{ lastAction }}</span>.
Строки достижимы `Tab`, отключённая — нет.
</div>
</div>
</template>Строки настроек с действиями
Базовый data-display сценарий: GrList + GrListItem собирают preference rows со secondary controls справа.
<script setup lang="ts">
import { reactive } from 'vue'
import { GrList, GrListItem, GrSwitch } from '@feugene/granularity'
const settings = reactive({
alerts: true,
summaries: false,
approvals: true,
})
</script>
<template>
<!-- Заголовок пункта переключателю не принадлежит: он живёт рядом, а не в его
разметке. Без aria-label скринридер объявит просто «переключатель». -->
<GrList>
<GrListItem title="Realtime alerts" description="Push incidents to the operations inbox.">
<GrSwitch v-model="settings.alerts" aria-label="Realtime alerts" />
</GrListItem>
<GrListItem title="Weekly summaries" description="Send a digest to workspace owners every Monday.">
<GrSwitch v-model="settings.summaries" aria-label="Weekly summaries" />
</GrListItem>
<GrListItem title="Approval reminders" description="Remind approvers about stale payout requests.">
<GrSwitch v-model="settings.approvals" aria-label="Approval reminders" />
</GrListItem>
</GrList>
</template>Очередь: бейджи и кнопки в строке
Показываем GrList как lightweight alternative для job queues и task summaries, где справа нужны badges и compact buttons.
<script setup lang="ts">
import { GrBadge, GrButton, GrList, GrListItem } from '@feugene/granularity'
const jobs = [
{ title: 'Publish release notes', description: 'Ready for review by marketing', status: 'Ready' },
{ title: 'Re-sync bank accounts', description: 'Waiting for background worker', status: 'Queued' },
{ title: 'Archive invoices', description: 'Needs manual confirmation', status: 'Review' },
]
</script>
<template>
<GrList>
<GrListItem
v-for="job in jobs"
:key="job.title"
:title="job.title"
:description="job.description"
>
<div class="flex items-center gap-2">
<GrBadge size="sm" tone="success">
{{ job.status }}
</GrBadge>
<GrButton size="sm" variant="outline">
Open
</GrButton>
</div>
</GrListItem>
</GrList>
</template>Пустое состояние и загрузка
Пустоту список определяет сам — без v-if вокруг него; при loading вместо пунктов идут скелетоны, а слот #empty держит богатую заглушку.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrList, GrListItem, GrSegmented } from '@feugene/granularity'
type Mode = 'items' | 'empty' | 'loading'
const mode = ref<Mode>('items')
const presets = computed(() => (mode.value === 'items'
? [
{ id: 'retention', title: 'Retention policy', description: 'Archive old reports after 90 days.' },
{ id: 'export', title: 'Export history', description: 'Keep downloadable exports for 30 days.' },
]
: []))
</script>
<template>
<div class="grid gap-3">
<GrSegmented
v-model="mode"
size="sm"
:options="[
{ value: 'items', label: 'Пункты' },
{ value: 'empty', label: 'Пусто' },
{ value: 'loading', label: 'Загрузка' },
]"
/>
<!-- Ни `v-if` вокруг списка, ни ручного переключения `divided`: пустоту
список видит по слоту сам. -->
<GrList :loading="mode === 'loading'">
<GrListItem
v-for="preset in presets"
:key="preset.id"
:title="preset.title"
:description="preset.description"
/>
<template #empty>
<div class="grid justify-items-center gap-2">
<span>Ни одного архивного пресета</span>
<GrButton size="sm" @click="mode = 'items'">
Показать примеры
</GrButton>
</div>
</template>
</GrList>
</div>
</template>Пять тысяч пунктов
С items список знает размер набора, а virtual оставляет в DOM только окно вокруг вьюпорта — прокрутка не тяжелеет от длины.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrButton, GrList, GrListItem } from '@feugene/granularity'
type Build = { id: number, title: string, description: string, status: string }
const builds: Build[] = Array.from({ length: 5000 }, (_, index) => ({
id: index + 1,
title: `Сборка #${index + 1}`,
description: `Ветка feature/${1000 + index} · 2 мин 14 с`,
status: index % 7 === 0 ? 'Упала' : 'Успешно',
}))
const list = ref<{ scrollToIndex: (index: number, align?: 'auto' | 'start' | 'center' | 'end') => void } | null>(null)
</script>
<template>
<div class="grid gap-3">
<GrButton size="sm" variant="outline" @click="list?.scrollToIndex(2499, 'start')">
Показать сборку #2500
</GrButton>
<!-- `aria` из слота обязателен: в DOM живёт окно, и без setsize/posinset
диктор объявил бы «12 из 12» вместо «1 из 5000». -->
<GrList
ref="list"
:items="builds"
item-key="id"
virtual
:max-height="320"
>
<template #item="{ item, aria }">
<GrListItem v-bind="aria" :title="item.title" :description="item.description">
<GrBadge size="sm" :tone="item.status === 'Успешно' ? 'success' : 'danger'">
{{ item.status }}
</GrBadge>
</GrListItem>
</template>
</GrList>
</div>
</template>