Patterns
Table States
The rows that show the loading, empty and error states of a table.
Patterns
The rows that show the loading, empty and error states of a table.
| Invoice | Amount |
|---|---|
import * as React from 'react'
import {
Table,
TableBody,
TableCell,
TableErrorRow,
TableHead,
TableHeader,
TableMessageRow,
TableRow,
TableSkeletonRows,
ToggleGroup,
ToggleGroupItem,
} from 'ferry-ui'
const INVOICES = [
{ number: 'INV-2041', amount: '$1,250.00' },
{ number: 'INV-2042', amount: '$3,480.00' },
]
const ERROR = new Error('The request failed with status 502.')
export default function TableStatesHero() {
const [state, setState] = React.useState('loading') // In an app, the request that loads the rows gives the state.
return (
<div className="flex flex-col gap-3">
<ToggleGroup
type="single"
variant="outline"
aria-label="State of the table"
value={state}
onValueChange={(next) => next && setState(next)}
>
<ToggleGroupItem value="loading">Loading</ToggleGroupItem>
<ToggleGroupItem value="error">Error</ToggleGroupItem>
<ToggleGroupItem value="empty">Empty</ToggleGroupItem>
<ToggleGroupItem value="data">Data</ToggleGroupItem>
</ToggleGroup>
<Table aria-label="Invoices">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Invoice</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody aria-busy={state === 'loading'}>
{state === 'loading' && <TableSkeletonRows columns={2} rows={2} />}
{state === 'error' && <TableErrorRow colSpan={2} error={ERROR} onRetry={() => setState('loading')} />}
{state === 'empty' && <TableMessageRow colSpan={2}>No invoices yet.</TableMessageRow>}
{state === 'data' &&
INVOICES.map((invoice) => (
<TableRow key={invoice.number}>
<TableCell className="font-mono text-[13px]">{invoice.number}</TableCell>
<TableCell className="text-right tabular">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}TableBody of the real Table. The user then sees the columns of the table.StaleDataCallout above the table. See Callout.Import the rows. Show one of them in the body of the table for each state.
import {
Table,
TableBody,
TableErrorRow,
TableHeader,
TableMessageRow,
TableRow,
TableSkeletonRows,
} from 'ferry-ui'
<Table>
<TableHeader />
<TableBody aria-busy={loading}>
{loading ? (
<TableSkeletonRows columns={4} />
) : error ? (
<TableErrorRow colSpan={4} error={error} onRetry={retry} />
) : rows.length === 0 ? (
<TableMessageRow colSpan={4}>No invoices yet.</TableMessageRow>
) : (
rows.map((row) => <TableRow key={row.id} />)
)}
</TableBody>
</Table>| Part | State | Role |
|---|---|---|
TableSkeletonRows | The first load | Placeholder rows, with one bar in each cell. |
TableMessageRow | No rows | One row with a centered message. |
TableErrorRow | The load failed | One row with the error message. It can show a Retry button. |
Give the number of columns of the table to columns and to colSpan.
TableSkeletonRows shows placeholder rows. rows sets how many. The default is 4.
| Name | Role | Projects | |
|---|---|---|---|
import { Table, TableBody, TableHead, TableHeader, TableRow, TableSkeletonRows } from 'ferry-ui'
export default function TableStatesLoading() {
return (
<Table aria-label="Members">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Projects</TableHead>
</TableRow>
</TableHeader>
<TableBody aria-busy="true">
<TableSkeletonRows columns={4} rows={3} />
</TableBody>
</Table>
)
}TableMessageRow shows one message across the table. Use it when the table has no rows.
| Name | Key | Created |
|---|---|---|
| No API keys yet. Create a key to call the API from your code. | ||
import { Table, TableBody, TableHead, TableHeader, TableMessageRow, TableRow } from 'ferry-ui'
export default function TableStatesEmpty() {
return (
<Table aria-label="API keys">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Key</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableMessageRow colSpan={3}>No API keys yet. Create a key to call the API from your code.</TableMessageRow>
</TableBody>
</Table>
)
}When no row matches the search or the filters, tell the user. Put a small button in the message row to clear them.
| Customer | Plan |
|---|---|
No customers match “orders”. | |
import * as React from 'react'
import {
Button,
SearchInput,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableMessageRow,
TableRow,
} from 'ferry-ui'
import { X } from 'lucide-react'
const CUSTOMERS = [
{ name: 'Acme', plan: 'Pro' },
{ name: 'Maya Chen', plan: 'Free' },
{ name: 'Jonas Weber', plan: 'Enterprise' },
]
export default function TableStatesNoMatch() {
const [query, setQuery] = React.useState('orders')
const rows = CUSTOMERS.filter((customer) => customer.name.toLowerCase().includes(query.trim().toLowerCase()))
return (
<div className="flex flex-col gap-4">
<SearchInput placeholder="Search customers" value={query} onValueChange={setQuery} />
<Table aria-label="Customers">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Customer</TableHead>
<TableHead>Plan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableMessageRow colSpan={2}>
<div className="flex flex-col items-center gap-2">
<span>No customers match “{query}”.</span>
<Button size="tiny" icon={<X />} onClick={() => setQuery('')}>
Clear search
</Button>
</div>
</TableMessageRow>
) : (
rows.map((customer) => (
<TableRow key={customer.name}>
<TableCell>{customer.name}</TableCell>
<TableCell className="text-foreground-light">{customer.plan}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}TableErrorRow shows the message of error. With onRetry, it also shows a Retry button. Set retrying while the new load is in progress.
| Order | Customer | Total |
|---|---|---|
Could not reach the orders service. | ||
import * as React from 'react'
import { Table, TableBody, TableErrorRow, TableHead, TableHeader, TableRow } from 'ferry-ui'
const ERROR = new Error('Could not reach the orders service.')
export default function TableStatesError() {
const [retrying, setRetrying] = React.useState(false)
// In an app, `onRetry` starts the request again.
function retry() {
setRetrying(true)
window.setTimeout(() => setRetrying(false), 1500)
}
return (
<Table aria-label="Orders">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Order</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableErrorRow colSpan={3} error={ERROR} onRetry={retry} retrying={retrying} />
</TableBody>
</Table>
)
}rowLinkProps(onOpen) makes a full row clickable. Spread its result on a TableRow. A click on a link, a button or a field in the row does not call onOpen.
| Name | Role |
|---|---|
| Maya Chen | Owner |
| Jonas Weber | Admin |
| Priya Patel | Member |
Click a row to open a member.
import * as React from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, rowLinkProps } from 'ferry-ui'
const MEMBERS = [
{ id: 'maya', name: 'Maya Chen', role: 'Owner' },
{ id: 'jonas', name: 'Jonas Weber', role: 'Admin' },
{ id: 'priya', name: 'Priya Patel', role: 'Member' },
]
export default function TableStatesRowLink() {
// In an app, the row and the link go to the page of the member.
const [opened, setOpened] = React.useState<string>()
return (
<div className="flex flex-col gap-3">
<Table aria-label="Members">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{MEMBERS.map((member) => (
<TableRow key={member.id} {...rowLinkProps(() => setOpened(member.name))}>
<TableCell>
<a
href={`#${member.id}`}
onClick={(event) => {
event.preventDefault()
setOpened(member.name)
}}
className="rounded-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{member.name}
</a>
</TableCell>
<TableCell className="text-foreground-light">{member.role}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-[13px] text-foreground-light" aria-live="polite">
{opened ? `Opened: ${opened}` : 'Click a row to open a member.'}
</p>
</div>
)
}aria-busy on the TableBody while they show.TableErrorRow has the alert role. Screen readers read it when it shows.Each component also accepts the attributes of the <tr> element.
Each placeholder row gets the other attributes. Thus the component does not accept ref and id.
| Prop | Type | Default |
|---|---|---|
columnsRequired | number | - |
Number of cells per row: the number of columns of the table ( | ||
rows | number | 4 |
Number of placeholder rows. Match the expected page size when known, capped to a few rows. Defaults to 4. | ||
className | string | - |
Classes merged onto every placeholder | ||
| Prop | Type | Default |
|---|---|---|
colSpanRequired | number | - |
Number of columns the single cell spans: the number of columns of the table. | ||
childrenRequired | ReactNode | - |
The message (and, if useful, a small action such as a "Clear filters" link button). Wraps when long. | ||
tone | "destructive" | "neutral" | "muted" | neutral |
| ||
className | string | - |
Classes merged onto the full-width | ||
| Prop | Type | Default |
|---|---|---|
colSpanRequired | number | - |
Number of columns the single cell spans: the number of columns of the table. | ||
errorRequired | unknown | - |
What was thrown or rejected; its message is shown through | ||
onRetry | (() => void) | - |
Shows a small Retry button under the message that calls it. | ||
retrying | boolean | - |
Spinner on the Retry button while the new attempt runs. | ||
retryLabel | ReactNode | Retry |
Label of the Retry button. Defaults to "Retry". | ||
className | string | - |
Classes merged onto the full-width | ||
rowLinkProps(onOpen) returns className and onClick for a TableRow. A className after the spread replaces the className of the result. Merge the two with cn.
| Name | Type | Role |
|---|---|---|
onOpen | (event: React.MouseEvent) => void | The argument. It runs when the user clicks the row. |
className | string | In the result. It sets the pointer cursor. |
onClick | (event: React.MouseEvent) => void | In the result. It calls onOpen for a click on the row. |