GrList
Displays a vertical list of uniform items or actions.
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 —
GrListItemcovers the typical case as a whole; - there are thousands of rows —
virtualkeeps in the DOM only the window around the viewport; - the data is still loading —
loadingwithloadingRowsshows skeletons instead of a jumping layout.
When to take something else
| Need | Take |
|---|---|
| The data is tabular, with columns | GrTable |
| Sorting, row selection and cell slots are needed | GrDataTable |
| The rows are nested in one another | GrTree |
| The order of the rows is changed by the user | GrSortableList |
| The rows are events in time | GrTimeline |
| A row is a “property → value” pair | GrDescriptionList |
| There are no rows, and that needs explaining | GrEmptyState |
Variants every tone and view, rendered by the real component
variant
elevatedoutlinedghost
States
normalloading
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<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>Examples 4 each with a live demo and its code
Rows that link, act and rest
GrListItemlays out a prefix, a title and a description. Withhrefthe whole row is one link,clickablemakes it a button that emitsclick, anddisabledmutes it by background and silences it. Thetitleslot takes markup when a prop is not enough.Load older notificationsNothing older than 30 daysA 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, theprefixslot holds an icon on the left, and the default slot stands on the right — a chevron here, a count or an action elsewhere.Account settings
Everything about you and your workspace
Data, waiting and nothing
With
itemsthe rows come from the#itemslot and the list sees emptiness by the length of the data — nov-ifaround it.loadingshows skeleton rows of the same density and beats emptiness, and the#emptyslot holds a way out instead of a dead end.Admin · Sent 2 days agoEditor · Sent 5 days agoViewer · Expires tomorrowFive thousand rows, fifteen in the DOM
virtualwithmax-heightkeeps only a window around the viewport in the DOM.v-bind="aria"hands each rowaria-setsizeandaria-posinset, andscrollToIndexreaches a row that is not rendered at all.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 generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | GrCardVariant | undefined | undefined | The 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. |
divided | boolean | undefined | true | Whether to show horizontal separators between the items (yes by default). |
loading | boolean | undefined | false | Loading is in progress: instead of items there are skeleton rows, and the container is marked `aria-busy`. |
loadingRows | number | undefined | 3 | How many placeholder rows to show with `loading`. |
empty | boolean | undefined | undefined | The 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). |
emptyText | string | undefined | undefined | The text of the empty state. The `#empty` slot is stronger. |
items | T[] | undefined | undefined | The 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. |
itemKey | string | ((item: T, index: number) => string | number) | undefined | undefined | The key of an item for the `v-for`: the name of a field or a function. Without it — the index. |
maxHeight | string | number | undefined | undefined | The height of the visible part: the container becomes a scroller. A number is pixels. |
virtual | boolean | undefined | false | Keep in the DOM only the window around the viewport. It requires `items` and `maxHeight`. |
estimatedItemSize | number | undefined | 56 | An estimate of the height of an item before the measurement. It is refined by the fact at the first render. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The 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. |
loading | any | The content while the data is on its way. |
empty | any | The 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 type { GrListProps, GrListItemDensity, GrListItemProps } from '@feugene/granularity/components/GrList'Filedist/types/components/GrList/GrList.vue.d.ts
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
type GrListItemDensity = 'compact' | 'regular';Filedist/types/components/GrList/GrListItem.vue.d.ts
interface GrListItemProps {
title?: string;
description?: string;
density?: GrListItemDensity;
href?: string;
as?: string | Component;
to?: unknown;
clickable?: boolean;
hoverable?: boolean;
disabled?: boolean;
}interface GrListItemEmits {
(e: 'click', event: MouseEvent): void;
}Filedist/types/components/GrList/config.d.ts
const grListConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrList">;Filedist/types/components/GrList/safelist.d.ts
const grListSafelist: string[];Filedist/types/components/GrList/index.d.ts
type GrListInstance = ComponentExposed<typeof GrListComponent>;Accessibility
APG pattern
list
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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> → <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:
<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.
<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.
Props in this example