GrList

Package: @feugene/granularitycoreGroup: data

Displays a vertical list of uniform items or actions.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the rows are uniform — notifications, participants, files: the title, the description, the prefix and the action are already laid out;
  • you would rather not write the markup of a rowGrListItem covers the typical case as a whole;
  • there are thousands of rowsvirtual keeps in the DOM only the window around the viewport;
  • the data is still loadingloading with loadingRows shows skeletons instead of a jumping layout.

When to take something else

NeedTake
The data is tabular, with columnsGrTable
Sorting, row selection and cell slots are neededGrDataTable
The rows are nested in one anotherGrTree
The order of the rows is changed by the userGrSortableList
The rows are events in timeGrTimeline
A row is a “property → value” pairGrDescriptionList
There are no rows, and that needs explainingGrEmptyState

The surface

The list draws a GrCard under itself, and variant reaches it:

<GrCard>
  <h3>Settings</h3>
  <GrList variant="ghost">…</GrList>
</GrCard>

ghost removes the border and the shadow — a list inside an already existing card does not give a second one. Without the prop the variant is taken from GrConfigProvider (componentDefaults.GrCard.variant), and otherwise it is elevated.

The list is clipped by the radius of that card. A row has no radius of its own and needs none — the rows run flush against each other — but the card has no padding either, and without the clipping the background of the first and the last row would fill its corners: permanently for a disabled one and on hover for a clickable one.

The empty state

The list sees for itself that there are no items: a v-for over an empty array leaves a fragment with no nodes, a v-if leaves a comment, and neither of those is an item. There is no longer any need to write a v-if/v-else around the list.

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

  <template #empty>
    Not a single request. <GrButton size="sm">Create</GrButton>
  </template>
</GrList>

Without the slot a dimmed emptyText row is shown (the gr.list.empty key by default). The empty prop remains an escape hatch in both directions: where the slot is filled with, say, group headings, the consumer knows better about the emptiness.

Separators are not drawn in the empty branch — a line would otherwise hang between nothing.

The placeholder is not wrapped into GrEmptyState: the list is already inside a card, and a card inside a card would come out. A rich empty state is put into the slot deliberately.

Loading

<GrList :loading="pending" :loading-rows="5" />

Instead of items there are GrSkeleton rows of the same density, and the container is marked aria-busy="true". The #loading slot replaces the placeholders as a whole. Loading is stronger than emptiness: otherwise the list would blink the “nothing yet” text on every request.

A clickable row

<GrListItem title="Profile" href="/settings/profile" />

<GrListItem title="Sign out" clickable @click="signOut" />

<GrListItem title="Section" :as="RouterLink" :to="{ name: 'section' }" />

The order in which the tag is chosen is as<a href><button clickable>, as in GrSidebarItem.

The key detail of the markup: role="listitem" stays on the wrapper, and the row as a whole is a nested element. An <a role="listitem"> would lose the role of a link, and an interactive element outside GrListItem would break the role="list"role="listitem" pairing and the list would stop being a list.

The structure is one for any item: the wrapper holds the role, and the nested element the look and the behaviour. In an ordinary row that is a <div>, in a clickable one an <a>, a <button> or the tag from as; they are told apart by the data-gr-list-item-action attribute, which only a clickable one has.

as is obliged to render a focusable element. A <span> with a click handler is a control for the mouse and for it alone: it does not enter the tab order and does not answer Enter (WCAG 2.1.1). A tag named as a string and unable to take focus (anything but button and a with a link) catches a warning in a dev build. Router components pass silently — they render an <a>, and there is no knowing that before the render.

hoverable highlights a row on hover without making it a button. disabled returns the row to a non-interactive look, dims it with the --gr-muted background (not with opacity — transparency dilutes tokens tuned to AA) and silences the click event.

Density and separators

The density of an item: regular (12px) or compact (8px). The divided of the list switches the separators between items on — they are on by default.

The title and the description of a row start from --gr-text-sm — the size changes together with the scale of the theme, and the hierarchy inside the row is held by weight and colour.

The classes of both maps live in grListStyles.ts and are declared in the safelist as a whole.

Data instead of a slot

With the items prop the items are drawn by the #item slot rather than by the default one. The previous mode stays intact in the process and remains the default: items does not replace it, it adds a second way.

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

item-key is the name of a field or a (item, index) function; without it the index serves as the key. In this mode the list sees the emptiness by the length of items rather than by the content of the slot.

max-height (a number means pixels) turns the container into a scroller and adds tabindex="0" to it: a scrolling block has to be reachable by someone without a mouse. The prop works on its own too — switching the virtualisation on below does not change the look.

Virtualisation

virtual keeps in the DOM only the window around the viewport. It requires items (otherwise the list has nowhere to take the size of the set from) and max-height (otherwise there is no viewport); without either of the two there will be a warning in a dev build.

<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" is mandatory. An incomplete set lives in the DOM, and without aria-setsize/aria-posinset a screen reader will count the items by the window — it will announce “3 of 12” for a list of five thousand. The list cannot set the attributes on the consumer’s behalf: the markup of an item belongs to the slot. The set therefore arrives as a ready object, and a forgotten binding is caught after mounting and prints a warning — there will be no silence. Outside the virtual mode aria is empty: the browser counts a complete set itself.

The height of an item is refined by measurement, but before the first render there is nowhere to take it from — the estimate is set by estimated-item-size (56 by default, a row of ordinary density with a description). A rough estimate does not break the scrolling, but it makes the scrollbar uneven in the first frames.

scrollToIndex(index, align?) is available through a ref on the list — the only way to reach an item outside the window: it is not in the DOM, and there is nothing to call scrollIntoView on.

The general rules and the limits of the technique — docs/virtual-list.md.

Playground 7

Loading…

Code
<GrList />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
variantGrCardVariant | undefinedundefinedThe surface of the list — the variant of the card under it. `ghost` removes the border and the shadow: a list inside an already existing card must not give a second one.
dividedboolean | undefinedtrueWhether to show horizontal separators between the items (yes by default).
loadingboolean | undefinedfalseLoading is in progress: instead of items there are skeleton rows, and the container is marked `aria-busy`.
loadingRowsnumber | undefined3How many placeholder rows to show with `loading`.
emptyboolean | undefinedundefinedThe list is empty. By default it is determined by itself — from the content of the slot; the prop is needed where the consumer knows better (the slot is filled with group headings, for instance, and there is no data in them).
emptyTextstring | undefinedundefinedThe text of the empty state. The `#empty` slot is stronger.
itemsT[] | undefinedundefinedThe data of the list. With it the items are drawn by the `#item` slot rather than by the default one — and only that way does the list know the size of the set, that is, can virtualise it.
itemKeystring | ((item: T, index: number) => string | number) | undefinedundefinedThe key of an item for the `v-for`: the name of a field or a function. Without it — the index.
maxHeightstring | number | undefinedundefinedThe height of the visible part: the container becomes a scroller. A number is pixels.
virtualboolean | undefinedfalseKeep in the DOM only the window around the viewport. It requires `items` and `maxHeight`.
estimatedItemSizenumber | undefined56An estimate of the height of an item before the measurement. It is refined by the fact at the first render.

Slots

SlotTypeDescription
defaultanyThe items of the list, when the markup is written by hand instead of `items`.
item{ item: T; index: number; aria: Record<string, number>; }An item in the data mode. `aria` is set only in the virtual mode — when the set in the DOM is incomplete and the browser has nowhere to take the size of the list from.
loadinganyThe content while the data is on its way.
emptyanyThe empty state instead of the default text.

Examples 5

Navigation

Журнал доступа
Архивный раздел
Последнее действие: . Строки достижимы `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>

Settings rows with actions

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>

Queue rows with badges and 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>

Empty State

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>

Virtual

Сборка #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>

Component documentationAll components