Skip to content

Table

Rows and columns of data with sorting, selection, nested rows and drag and drop. Describe the columns up front, then render a TableRow for every item.

Image
Title
Status
Table.tsx
import {
  Badge,
  Table,
  TableCell,
  TableRow,
  TableThumbnail,
  TableTitle
} from 'alinea/components'

const products = [
  {id: 'oak-dining-chair', title: 'Oak dining chair', status: 'published'},
  {id: 'walnut-stools', title: 'Walnut stools', status: 'draft'},
  {id: 'linen-lounge-chair', title: 'Linen lounge chair', status: 'archived'}
] as const

export function TableExample() {
  return (
    <div style={{width: 520, height: 180}}>
      <Table
        aria-label="Products"
        items={products}
        columns={[
          {id: 'image', header: 'Image', width: 72},
          {id: 'title', header: 'Title', width: '1fr'},
          {id: 'status', header: 'Status', width: 120}
        ]}
      >
        {product => (
          <TableRow id={product.id} textValue={product.title}>
            <TableThumbnail src={`/catalog/${product.id}.jpg`} alt="" />
            <TableTitle title={product.title} label="Products" />
            <TableCell>
              <Badge size="sm" status={product.status}>
                {product.status}
              </Badge>
            </TableCell>
          </TableRow>
        )}
      </Table>
    </div>
  )
}

Selection and sorting

Set a selectionMode to let editors select rows, showSelectionControls adds a checkbox to every row. Mark columns as sortable and sort the items in onSortChange.

TableSelection.tsx
import {
  type SortDescriptor,
  Table,
  TableCell,
  TableRow,
  TableTitle
} from 'alinea/components'
import {useState} from 'react'

const products = [
  {id: 'shirt', title: 'Linen shirt', price: 89},
  {id: 'chair', title: 'Oak dining chair', price: 245},
  {id: 'stool', title: 'Walnut stool', price: 160},
  {id: 'lamp', title: 'Ceramic table lamp', price: 120}
]

export function TableSelectionExample() {
  const [sort, setSort] = useState<SortDescriptor>({
    column: 'title',
    direction: 'asc'
  })
  const sorted = [...products].sort((a, b) =>
    sort.column === 'price' ? a.price - b.price : a.title.localeCompare(b.title)
  )
  if (sort.direction === 'desc') sorted.reverse()
  return (
    <div style={{width: 520, height: 230}}>
      <Table
        aria-label="Products"
        items={sorted}
        columns={[
          {id: 'title', header: 'Title', width: '1fr', sortable: true},
          {
            id: 'price',
            header: 'Price',
            width: 120,
            align: 'end',
            sortable: true
          }
        ]}
        selectionMode="multiple"
        showSelectionControls
        defaultSelectedKeys={new Set(['chair'])}
        sortDescriptor={sort}
        onSortChange={setSort}
      >
        {product => (
          <TableRow id={product.id} textValue={product.title}>
            <TableTitle title={product.title} />
            <TableCell align="end">€{product.price}</TableCell>
          </TableRow>
        )}
      </Table>
    </div>
  )
}

Load more rows

onLoadMore is called when the rows are scrolled close to the end, to fetch the next page. While loading is set, a spinner shows below the rows, or in place of them while there are none. useAsyncList keeps the loaded pages: its load function receives the cursor the previous page returned, and loadMore does nothing once a page returns no cursor. Its items, loading, loadMore, sortDescriptor and sort, and selectedKeys and setSelectedKeys plug into the props of the table. update(key, item), remove(...keys), prepend(...items) and append(...items) change the loaded items without loading them again, for example after a dialog saves a row.

Title
Price
TableAsync.tsx
import {
  Table,
  TableCell,
  TableRow,
  TableTitle,
  useAsyncList
} from 'alinea/components'

interface Product {
  id: string
  title: string
  price: number
}

const materials = ['Linen', 'Oak', 'Wool', 'Walnut', 'Ceramic']
const kinds = ['shirt', 'chair', 'throw', 'stool', 'lamp', 'table']

// Stands in for a paged API: 20 products per page, 100 in total
async function fetchProducts(page: number) {
  await new Promise(resolve => setTimeout(resolve, 500))
  const items: Array<Product> = Array.from({length: 20}, (_, i) => {
    const n = page * 20 + i
    const title = `${materials[n % 5]} ${kinds[n % 6]} ${n + 1}`
    return {id: `product-${n}`, title, price: 40 + ((n * 37) % 300)}
  })
  return {items, cursor: page < 4 ? page + 1 : undefined}
}

export function TableAsyncExample() {
  const list = useAsyncList<Product, number>({
    load: ({cursor = 0}) => fetchProducts(cursor)
  })
  return (
    <div style={{width: 420, height: 260}}>
      <Table
        aria-label="Products"
        items={list.items}
        columns={[
          {id: 'title', header: 'Title', width: '1fr'},
          {id: 'price', header: 'Price', width: 100, align: 'end'}
        ]}
        loading={list.loading}
        onLoadMore={list.loadMore}
      >
        {product => (
          <TableRow id={product.id} textValue={product.title}>
            <TableTitle title={product.title} />
            <TableCell align="end">€{product.price}</TableCell>
          </TableRow>
        )}
      </Table>
    </div>
  )
}
import {useAsyncList} from 'alinea/components'

const list = useAsyncList({
  async load({cursor, filterText, sort, signal}) {
    const res = await fetch(`/api/products?page=${cursor ?? 1}`, {signal})
    const {items, next} = await res.json()
    // Leave out the cursor, or return null, on the last page
    return {items, cursor: next}
  }
})
// list.items, list.loading, list.hasMore, list.error,
// list.loadMore(), list.reload(), list.setFilterText(text), list.sort(sort),
// list.update(id, item), list.remove(id), list.prepend(item)

Props

<Table>

PropTypeDefaultDescription
items*Iterable<T>Default –
columns*ReadonlyArray<TableColumn>Default –
showHeaderbooleanDefault trueShow the column headers, defaults to true
expandablebooleanDefault falseReserve space for expand toggles so titles line up, set when rows nest
rowHeightnumberDefault 44
selectionBehavior'toggle' | 'replace'Default 'toggle'How pointer clicks select rows. With toggle (the default) a click runs the row action, or toggles its selection while rows are selected. With replace a click selects only that row and a double click or Enter runs the row action.
showSelectionControlsbooleanDefault –Show a selection checkbox per row, defaults to multiple selection
variant'surface' | 'plain'Default 'surface'plain drops the rounded surface, eg. when the table fills a panel
expandedKeysIterable<Key>Default –
defaultExpandedKeysIterable<Key>Default –
onExpandedChange(keys: Set<Key>) => voidDefault –
sortDescriptorSortDescriptorDefault –
onSortChange(descriptor: SortDescriptor) => voidDefault –
onRowAction(key: Key) => voidDefault –Called when a row is activated (see selectionBehavior), rows can override it with their own onAction
renderEmptyState() => ReactNodeDefault –
loadingbooleanDefault –Shows a spinner in place of the rows while there are none, below the rows otherwise
onLoadMore() => voidDefault –Called when the rows are scrolled close to the end, to load the next page (see useAsyncList)
dependenciesReadonlyArray<unknown>Default –Rows are cached per item, list the values children reads besides the item to render them again when those change
children*(item: T) => ReactElementDefault –

<TableRow>

PropTypeDefaultDescription
id*KeyDefault –
textValue*stringDefault –Text used for typeahead and as the accessible row name
hasChildrenbooleanDefault –Shows the expand toggle, also before the nested rows are loaded
rowsReactNodeDefault –Nested TableRows, rendered when the row is expanded. Pass them only once expanded to load them lazily, eg. from a component that renders the rows of already loaded data.
selectablebooleanDefault trueSet to false to disable selecting the row, its action still runs
highlightedbooleanDefault –Highlights the row, eg. to mark items that are already in use
onAction() => voidDefault –Called when the row is activated, overrides onRowAction
onClick() => voidDefault –Called on every click or tap of the row, next to its selection. Use it instead of onAction to act on a single click with the replace selection behavior, where onAction needs a double click.
onDoubleClick() => voidDefault –

Also accepts data-* attributes.

<TableCell>

PropTypeDefaultDescription
labelReactNodeDefault –A small caption above the value, like the dashboard explorer shows its columns when the header is hidden
align'start' | 'end' | 'center'Default –
titlestringDefault –Tooltip text

Also accepts className and style.

<TableThumbnail>

An image cell, sized to the row height

PropTypeDefaultDescription
srcstringDefault –
altstringDefault ''

Also accepts className and style.

<TableTitle>

PropTypeDefaultDescription
iconIconTypeDefault –Doubles as the drag handle of the row when rows can be dragged
title*ReactNodeDefault –
labelReactNodeDefault –A small caption above the title, eg. the parent path
suffixReactNodeDefault –Shown right after the title, eg. a status icon

Also accepts className and style.

useAsyncList()

Loads a list of items, with sorting, filtering and paging

Accessibility

Built on the React Aria grid: the arrow keys move between rows, Space selects a row and Enter runs its action. Sortable column headers announce their sort direction.