GrList

Пакет: @feugene/granularityядроГруппа: Данные

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

Когда брать

  • строки однородные — уведомления, участники, файлы: заголовок, описание, префикс и действие уже разложены;
  • разметку строки писать не хочется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

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Оценка высоты пункта до замера. Уточняется по факту при первом рендере.

Slots

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

Примеры 5

Кликабельные строки

Пункт сам становится ссылкой или кнопкой (href / as / clickable), не разрывая связку role=\"list\" с role=\"listitem\".

Журнал доступа
Архивный раздел
Последнее действие: . Строки достижимы `Tab`, отключённая — нет.

Navigation
<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 справа.

Realtime alerts
Push incidents to the operations inbox.
Weekly summaries
Send a digest to workspace owners every Monday.
Approval reminders
Remind approvers about stale payout requests.

Settings
<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.

Publish release notes
Ready for review by marketing
Ready
Re-sync bank accounts
Waiting for background worker
Queued
Archive invoices
Needs manual confirmation
Review

Queue Actions
<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 держит богатую заглушку.

Retention policy
Archive old reports after 90 days.
Export history
Keep downloadable exports for 30 days.

Empty State
<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 только окно вокруг вьюпорта — прокрутка не тяжелеет от длины.

Сборка #1
Ветка feature/1000 · 2 мин 14 с
Упала
Сборка #2
Ветка feature/1001 · 2 мин 14 с
Успешно
Сборка #3
Ветка feature/1002 · 2 мин 14 с
Успешно
Сборка #4
Ветка feature/1003 · 2 мин 14 с
Успешно
Сборка #5
Ветка feature/1004 · 2 мин 14 с
Успешно
Сборка #6
Ветка feature/1005 · 2 мин 14 с
Успешно
Сборка #7
Ветка feature/1006 · 2 мин 14 с
Успешно
Сборка #8
Ветка feature/1007 · 2 мин 14 с
Упала
Сборка #9
Ветка feature/1008 · 2 мин 14 с
Успешно
Сборка #10
Ветка feature/1009 · 2 мин 14 с
Успешно

Virtual
<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>

Документация компонентаВсе компоненты