GrList

  • core
  • data
  • API · 11 props
  • ⌨ keys

Displays a vertical list of uniform items or actions.

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

Overview

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 row — GrListItem covers the typical case as a whole;
  • there are thousands of rows — virtual keeps in the DOM only the window around the viewport;
  • the data is still loading — loading 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

Variants every tone and view, rendered by the real component

variant

  • elevated
  • outlined
  • ghost

States

  • normal
  • loading

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • Rows that link, act and rest

    GrListItem lays out a prefix, a title and a description. With href the whole row is one link, clickable makes it a button that emits click, and disabled mutes it by background and silences it. The title slot takes markup when a prop is not enough.

  • A list inside a card

    variant="ghost" removes the list’s own surface, so a list inside a card does not draw a second frame. density="compact" tightens the rows, the prefix slot holds an icon on the left, and the default slot stands on the right — a chevron here, a count or an action elsewhere.

  • Data, waiting and nothing

    With items the rows come from the #item slot and the list sees emptiness by the length of the data — no v-if around it. loading shows skeleton rows of the same density and beats emptiness, and the #empty slot holds a way out instead of a dead end.

    Admin · Sent 2 days ago
    Editor · Sent 5 days ago
    Viewer · Expires tomorrow
  • Five thousand rows, fifteen in the DOM

    virtual with max-height keeps only a window around the viewport in the DOM. v-bind="aria" hands each row aria-setsize and aria-posinset, and scrollToIndex reaches a row that is not rendered at all.

    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 generated from the package · cannot drift

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.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrListProps, GrListItemDensity, GrListItemProps } from '@feugene/granularity/components/GrList'

Filedist/types/components/GrList/GrList.vue.d.ts

GrListPropsinterface
interface GrListProps<TItem = unknown> {
    variant?: GrCardVariant;
    divided?: boolean;
    loading?: boolean;
    loadingRows?: number;
    empty?: boolean;
    emptyText?: string;
    items?: TItem[];
    itemKey?: string | ((item: TItem, index: number) => string | number);

Filedist/types/components/GrList/grListStyles.d.ts

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

Filedist/types/components/GrList/GrListItem.vue.d.ts

GrListItemPropsinterface
interface GrListItemProps {
    title?: string;
    description?: string;
    density?: GrListItemDensity;
    href?: string;
    as?: string | Component;
    to?: unknown;
    clickable?: boolean;
    hoverable?: boolean;
    disabled?: boolean;
}
GrListItemEmitsinterface
interface GrListItemEmits {
    (e: 'click', event: MouseEvent): void;
}

Filedist/types/components/GrList/config.d.ts

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

Filedist/types/components/GrList/safelist.d.ts

grListSafelistconst
const grListSafelist: string[];

Filedist/types/components/GrList/index.d.ts

GrListInstancetype
type GrListInstance = ComponentExposed<typeof GrListComponent>;

Accessibility

APG pattern

list

Usage from the library prose

The surface

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

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

vue
<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
vue
<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
vue
<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> → <div>, 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.

This is also why to is a prop rather than an attribute: the component’s attributes go to the wrapper with the role, and the navigation target would otherwise never reach the link component from as.

as names a tag, not a behaviour. A row that needs the tag for the sake of the markup stays exactly what it was:

vue
<GrListItem title="Profile" as="article" />

Only the tag changes: no highlight under the cursor, no focus ring, no data-gr-list-item-action attribute. What makes a row a control is href, clickable and a link component — that is, a behaviour someone has actually promised.

as="li" does not fit here, and that is no nitpick: the container of the list is a div with role="list", and an <li> is obliged to lie inside a <ul> or an <ol> (axe: listitem). The role of the item is already carried by the wrapper — the tag from as is for the content, not for the list.

An action on a tag outside the tab order is a markup error. A <span> with a click handler works for the mouse and for it alone: it does not enter the tab order and does not answer Enter (WCAG 2.1.1). Such a row does emit the click — silently switching it off would mean breaking working code — but it gets no focus ring and 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.

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

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