ComponentsData
Data view
Shows a collection of items as rows or as cards in a grid, with sorting, pages, loading and an empty state.
Import
import { DataView, DataViewLayoutToggle, DataViewSort } from "@booleanpress/ui/data-view"Usage
Give DataView the items and a renderItem that draws one of them; it wraps each in a list item.
import { DataView } from "@booleanpress/ui/data-view"
export function Mailers({ mailers }: { mailers: Mailer[] }) {
return (
<DataView
aria-label="Mailers"
items={mailers}
getItemKey={(mailer) => mailer.id}
renderItem={(mailer) => <MailerRow mailer={mailer} />}
/>
)
}renderItem(item, layout, index) receives the layout in use, "list" or "grid", so one function can draw a row or a card. layout and onLayoutChange control the layout, defaultLayout sets where it starts, and layoutToggle shows a switch between the two at the end of the header. In a grid the cards take as many columns of at least itemMinWidth (15rem) as fit.
sortOptions adds a select to the header; each option has a value, a label and, to sort in the browser, a compare function. pageSize pages the items and shows the pages in the footer; page and onPageChange control them. When the server sorts and pages, leave out compare, pass the current page as items and the whole count as total, and fetch on onSortChange and onPageChange.
loading with no items draws placeholders in the layout's shape (skeletonCount of them, or renderSkeleton for your own); with items, it dims them while new ones arrive. With no items and nothing loading it shows empty, the provider's noItems text by default: pass an Empty with an action, or an error with a retry button. header and footer add your own content, such as a title or a search field. For a header of your own, render DataViewSort and DataViewLayoutToggle yourself and control sort and layout.
Examples
Basic
A list of mailers, one per row, divided by the content edge.
import { MailIcon, PencilIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { Button } from "@booleanpress/ui/button"
import { DataView } from "@booleanpress/ui/data-view"
import { useUiLocale } from "@booleanpress/ui/provider"
const MAILERS = [
{ id: "ses", name: "Transactional", provider: "Amazon SES", status: "Active", sent: 18432 },
{ id: "postmark", name: "Password resets", provider: "Postmark", status: "Active", sent: 2210 },
{ id: "mailgun", name: "Newsletter", provider: "Mailgun", status: "Paused", sent: 40125 },
{ id: "smtp", name: "Office relay", provider: "Custom SMTP", status: "Failing", sent: 312 },
{ id: "brevo", name: "Receipts", provider: "Brevo", status: "Active", sent: 9870 },
]
const TONE: Record<string, "success" | "warning" | "destructive"> = { Active: "success", Paused: "warning", Failing: "destructive" }
export default function DataViewBasic() {
const { locale } = useUiLocale()
const count = new Intl.NumberFormat(locale)
return (
<DataView
aria-label="Mailers"
items={MAILERS}
getItemKey={(mailer) => mailer.id}
className="w-full"
renderItem={(mailer) => (
<div className="flex items-center gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
<MailIcon className="size-5" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-sm text-muted-foreground">{mailer.provider}</span>
<span className="text-lg/normal font-medium">{mailer.name}</span>
</div>
<Badge variant={TONE[mailer.status]}>{mailer.status}</Badge>
<span className="w-20 text-end text-lg font-semibold tabular-nums">{count.format(mailer.sent)}</span>
<Button variant="outline" size="icon" aria-label={`Edit ${mailer.name}`}>
<PencilIcon />
</Button>
</div>
)}
/>
)
}Grid
defaultLayout="grid" draws each mailer as a card, in as many columns as fit.
import { MailIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { Button } from "@booleanpress/ui/button"
import { DataView } from "@booleanpress/ui/data-view"
import { useUiLocale } from "@booleanpress/ui/provider"
const MAILERS = [
{ id: "ses", name: "Transactional", provider: "Amazon SES", status: "Active", sent: 18432 },
{ id: "postmark", name: "Password resets", provider: "Postmark", status: "Active", sent: 2210 },
{ id: "mailgun", name: "Newsletter", provider: "Mailgun", status: "Paused", sent: 40125 },
{ id: "smtp", name: "Office relay", provider: "Custom SMTP", status: "Failing", sent: 312 },
]
const TONE: Record<string, "success" | "warning" | "destructive"> = { Active: "success", Paused: "warning", Failing: "destructive" }
export default function DataViewGrid() {
const { locale } = useUiLocale()
const count = new Intl.NumberFormat(locale)
return (
<DataView
aria-label="Mailers"
items={MAILERS}
getItemKey={(mailer) => mailer.id}
defaultLayout="grid"
className="w-full"
renderItem={(mailer) => (
<div className="flex h-full flex-col gap-4 rounded-sm border p-6">
<div className="relative flex h-28 items-center justify-center rounded-sm bg-subtle text-muted-foreground">
<MailIcon className="size-8" aria-hidden="true" />
<Badge variant={TONE[mailer.status]} className="absolute start-1 top-1">
{mailer.status}
</Badge>
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">{mailer.provider}</span>
<span className="text-lg/normal font-medium">{mailer.name}</span>
</div>
<span className="text-2xl font-semibold tabular-nums">{count.format(mailer.sent)}</span>
<Button className="mt-auto">Send a test</Button>
</div>
)}
/>
)
}Layout
layoutToggle adds the list and grid switch; renderItem draws a row or a card for the layout in use.
import { MailIcon } from "lucide-react"
import { Badge } from "@booleanpress/ui/badge"
import { Button } from "@booleanpress/ui/button"
import { DataView } from "@booleanpress/ui/data-view"
const MAILERS = [
{ id: "ses", name: "Transactional", provider: "Amazon SES", status: "Active" },
{ id: "postmark", name: "Password resets", provider: "Postmark", status: "Active" },
{ id: "mailgun", name: "Newsletter", provider: "Mailgun", status: "Paused" },
{ id: "smtp", name: "Office relay", provider: "Custom SMTP", status: "Failing" },
]
const TONE: Record<string, "success" | "warning" | "destructive"> = { Active: "success", Paused: "warning", Failing: "destructive" }
export default function DataViewLayoutToggleExample() {
return (
<DataView
aria-label="Mailers"
items={MAILERS}
getItemKey={(mailer) => mailer.id}
layoutToggle
defaultLayout="grid"
className="w-full"
renderItem={(mailer, layout) =>
layout === "list" ? (
<div className="flex items-center gap-4">
<MailIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{mailer.name}</span>
<span className="text-sm text-muted-foreground">{mailer.provider}</span>
</div>
<Badge variant={TONE[mailer.status]}>{mailer.status}</Badge>
<Button variant="outline" size="sm">
Send a test
</Button>
</div>
) : (
<div className="flex h-full flex-col gap-3 rounded-sm border p-6">
<div className="flex items-start justify-between gap-2">
<MailIcon className="size-6 text-muted-foreground" aria-hidden="true" />
<Badge variant={TONE[mailer.status]}>{mailer.status}</Badge>
</div>
<span className="text-lg/normal font-medium">{mailer.name}</span>
<span className="text-sm text-muted-foreground">{mailer.provider}</span>
<Button variant="outline" size="sm" className="mt-auto">
Send a test
</Button>
</div>
)
}
/>
)
}Sorting
sortOptions adds a select of sort orders to the header, each with its own compare function.
import { DataView, type DataViewSortOption } from "@booleanpress/ui/data-view"
import { useUiLocale } from "@booleanpress/ui/provider"
type Mailer = { id: string; name: string; provider: string; sent: number }
const MAILERS: Mailer[] = [
{ id: "ses", name: "Transactional", provider: "Amazon SES", sent: 18432 },
{ id: "postmark", name: "Password resets", provider: "Postmark", sent: 2210 },
{ id: "mailgun", name: "Newsletter", provider: "Mailgun", sent: 40125 },
{ id: "smtp", name: "Office relay", provider: "Custom SMTP", sent: 312 },
{ id: "brevo", name: "Receipts", provider: "Brevo", sent: 9870 },
]
const SORTS: DataViewSortOption<Mailer>[] = [
{ value: "most-sent", label: "Most sent first", compare: (a, b) => b.sent - a.sent },
{ value: "least-sent", label: "Least sent first", compare: (a, b) => a.sent - b.sent },
{ value: "name", label: "Name, A to Z", compare: (a, b) => a.name.localeCompare(b.name) },
]
export default function DataViewSorting() {
const { locale } = useUiLocale()
const count = new Intl.NumberFormat(locale)
return (
<DataView
aria-label="Mailers"
items={MAILERS}
getItemKey={(mailer) => mailer.id}
sortOptions={SORTS}
defaultSort="most-sent"
className="w-full"
renderItem={(mailer) => (
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-col">
<span className="font-medium">{mailer.name}</span>
<span className="text-sm text-muted-foreground">{mailer.provider}</span>
</div>
<span className="text-lg font-semibold tabular-nums">{count.format(mailer.sent)}</span>
</div>
)}
/>
)
}Pagination
pageSize={5} pages 23 tickets, with the pages in the footer.
import { Badge } from "@booleanpress/ui/badge"
import { DataView } from "@booleanpress/ui/data-view"
const SUBJECTS = ["SMTP login fails", "Bounce notices missing", "Rotate the API key", "Emails land in spam", "Webhook times out"]
const STATUSES = ["Open", "Pending", "Closed"] as const
const TONE = { Open: "info", Pending: "warning", Closed: "secondary" } as const
// 23 tickets, numbered from #2040, so the last page is a short one.
const TICKETS = Array.from({ length: 23 }, (_, index) => ({
id: 2040 + index,
subject: SUBJECTS[index % SUBJECTS.length],
status: STATUSES[index % STATUSES.length],
}))
export default function DataViewPagination() {
return (
<DataView
aria-label="Tickets"
items={TICKETS}
getItemKey={(ticket) => ticket.id}
pageSize={5}
className="w-full"
renderItem={(ticket) => (
<div className="flex items-center gap-4">
<span className="w-14 text-sm text-muted-foreground tabular-nums">#{ticket.id}</span>
<span className="min-w-0 flex-1 truncate font-medium">{ticket.subject}</span>
<Badge variant={TONE[ticket.status]}>{ticket.status}</Badge>
</div>
)}
/>
)
}Loading
loading with no items yet draws placeholder cards in the grid's shape.
Refreshing
loading with items keeps them in place, dimmed and marked busy, until the new ones arrive.
import { useState } from "react"
import { RefreshCwIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { DataView } from "@booleanpress/ui/data-view"
const LOGS = [
{ id: "l1", to: "ana@example.com", subject: "Your receipt", result: "Delivered" },
{ id: "l2", to: "ben@example.com", subject: "Reset your password", result: "Delivered" },
{ id: "l3", to: "chen@example.org", subject: "Welcome aboard", result: "Bounced" },
]
export default function DataViewRefreshing() {
const [loading, setLoading] = useState(false)
const refresh = () => {
setLoading(true)
// A stand-in for a request: new rows arrive after a second.
setTimeout(() => setLoading(false), 1000)
}
return (
<DataView
aria-label="Delivery log"
items={LOGS}
getItemKey={(log) => log.id}
loading={loading}
className="w-full"
header={
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
<RefreshCwIcon />
Refresh
</Button>
}
renderItem={(log) => (
<div className="flex items-center justify-between gap-4 text-sm">
<span className="min-w-0 flex-1 truncate">{log.to}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{log.subject}</span>
<span>{log.result}</span>
</div>
)}
/>
)
}Empty
empty replaces the provider's "No items" with an Empty that offers the next step.
import { MailPlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { DataView } from "@booleanpress/ui/data-view"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"
export default function DataViewEmpty() {
return (
<DataView
aria-label="Mailers"
items={[]}
layoutToggle
className="w-full"
renderItem={() => null}
empty={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<MailPlusIcon />
</EmptyMedia>
<EmptyTitle>No mailers yet</EmptyTitle>
<EmptyDescription>Add a mailer to start sending email from your site.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Add a mailer</Button>
</EmptyContent>
</Empty>
}
/>
)
}Error
An error is an empty state too: say what failed and offer to try again.
import { useState } from "react"
import { CircleAlertIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { DataView } from "@booleanpress/ui/data-view"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@booleanpress/ui/empty"
export default function DataViewError() {
const [loading, setLoading] = useState(false)
const retry = () => {
setLoading(true)
// A stand-in for a request that fails again after a second.
setTimeout(() => setLoading(false), 1000)
}
return (
<DataView
aria-label="Mailers"
items={[]}
loading={loading}
className="w-full"
renderItem={() => null}
empty={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon" className="bg-destructive-subtle text-destructive-strong">
<CircleAlertIcon />
</EmptyMedia>
<EmptyTitle>The mailers could not be loaded</EmptyTitle>
<EmptyDescription>The server did not answer. Check your connection, then try again.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" onClick={retry}>
Try again
</Button>
</EmptyContent>
</Empty>
}
/>
)
}Accessibility
- Semantics
- The items are a list (
ul), one list item each, in both layouts. The layout switch is a radio group of two radios; the sort select is a combobox with a listbox. The pages are anavlandmark. While loading, the list isaria-busy; the first load announces the provider'sloadingtext in a status region. - Labels
- Name the list with
aria-labeloraria-labelledbyonDataView. The switch is named by the provider'slayoutstring and its radios bylayoutListandlayoutGrid; the select bysortBy, which it also shows until an option is chosen. Name every control inside an item after its item ("Edit Transactional"). - Focus
- The data view takes no focus of its own. Tab moves through the sort select, the layout switch, the controls inside the items in order, and the pages. Choosing a page keeps focus on its link.
- Known limits
- Changing the layout or the page does not move focus or announce anything beyond the controls' own state. Announce a new page with your own status text if it matters, such as
PaginationRangefrom Pagination. - The grid is a visual arrangement: arrow keys do not move between cards. Each card's controls are reached with Tab.
- The placeholders are hidden from assistive technology; only the status text is read.
- Safari's VoiceOver does not announce a list drawn without bullets as a list, so it reads the items one after another without a count.
- Changing the layout or the page does not move focus or announce anything beyond the controls' own state. Announce a new page with your own status text if it matters, such as
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves to the sort select, the layout switch, each item's controls and the pages, in that order. |
| โ | In the layout switch, chooses the next layout (the previous one in a right-to-left page). |
| โ | In the layout switch, chooses the previous layout (the next one in a right-to-left page). |
| Enter | On the sort select, opens the options; on an option, chooses it and sorts; on a page, shows that page. |
| โ | On the sort select, opens the options; in the options, moves to the next one. |
| Escape | Closes the sort options without changing the order. |
API
DataView
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | readonly T[] | The items to show: all of them, or the current page when the server pages (with total). | |
renderItemrequired | (item: T, layout: DataViewLayout, index: number) => ReactNode | Draws one item for the layout in use. The item is wrapped in a list item for you. | |
defaultLayout | "grid" | "list" | list | The layout it starts in, when it controls itself. |
defaultPage | number | 1 | The page it starts on, when it controls itself. |
defaultSort | string | The sort option it starts with, when it controls itself; none keeps the items' own order. | |
empty | ReactNode | What to show when there are no items and nothing is loading. Defaults to the provider's noItems text. | |
footer | ReactNode | Content in the footer, beside the pages. | |
getItemKey | ((item: T, index: number) => Key) | A stable key for an item, such as its id. Defaults to its position. | |
header | ReactNode | Content at the start of the header, such as a title or a search field. | |
itemMinWidth | string | 15rem | The narrowest a grid card may be before the grid drops a column, as a CSS length. |
layout | "grid" | "list" | The layout, when you control it. Pair it with onLayoutChange. | |
layoutToggle | boolean | false | Shows the built-in switch between list and grid, at the end of the header. |
loading | boolean | false | Shows that items are loading: placeholders in the layout's shape while there are none yet, or the items dimmed while new ones arrive. Either way the list is marked busy. |
onLayoutChange | ((layout: DataViewLayout) => void) | Called with the layout chosen in the built-in switch. | |
onPageChange | ((page: number) => void) | Called with the page asked for. Sorting goes back to page 1. | |
onSortChange | ((sort: string) => void) | Called with the sort option chosen. | |
page | number | The current page, from 1, when you control it. Pair it with onPageChange. | |
pageSize | number | Items per page: given, the items are paged and the footer shows the pages. | |
renderSkeleton | ((layout: DataViewLayout, index: number) => ReactNode) | Draws one placeholder for the layout in use, in place of the built-in skeleton row or card. | |
skeletonCount | number | The number of placeholders while loading. Defaults to pageSize, or 3. | |
sort | string | The chosen sort option's value, when you control it. | |
sortOptions | DataViewSortOption<T>[] | The ways to sort: given, the header shows a select of them. | |
total | number | The number of items on the server, when items holds the current page only. |
DataViewLayoutToggle
| Prop | Type | Default | Description |
|---|---|---|---|
onValueChangerequired | (layout: DataViewLayout) => void | Called with the layout chosen. | |
valuerequired | "grid" | "list" | The layout in use. | |
asChild | boolean | ||
dir | "ltr" | "rtl" | The reading direction, for the arrow keys. Defaults to the provider's dir. | |
disabled | boolean | Turns the switch off. | |
fluid | boolean | Fills the width of its container; the segments share it equally. | |
form | string | ||
loop | boolean | ||
name | string | ||
required | boolean | ||
size | "default" | "sm" | "lg" | sm | The text size: 12, 14 or 16 px, 32, 35 or 38 px tall. Defaults to the provider's controlSize.
The switch's size: sm (28 px with icons, the default), default or lg. |
DataViewSort
| Prop | Type | Default | Description |
|---|---|---|---|
onValueChangerequired | (value: string) => void | Called with the option chosen. | |
optionsrequired | Pick<DataViewSortOption<unknown>, "label" | "value">[] | The options, each with a value and a label. | |
asChild | boolean | ||
clearable | boolean | Shows a clear button inside the field while a value is chosen; it resets the value to the placeholder. | |
fluid | boolean | Fills the width of its container. | |
size | "default" | "sm" | "lg" | 28, 35 or 42 px tall (lg since 0.2.0). Defaults to the provider's controlSize.
The select's size. Defaults to the provider's controlSize. | |
value | string | The chosen option's value; an empty string or none shows the placeholder. | |
variant | "default" | "filled" | filled fills the field grey. Defaults to the provider's fieldVariant. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="data-view-list" (DataView), data-slot="data-view-layout-toggle" (DataViewLayoutToggle), data-slot="data-view-sort" (DataViewSort), and data-layout.
Provider strings: layout, layoutList, layoutGrid, sortBy, noItems, loading (BooleanUIProvider's strings).
Theming
Rows are divided by 1 px --border lines; the header and footer are set off by the same line. The placeholders are Skeleton blocks; the empty text is --muted-foreground. The switch, select and pages take the look of SegmentedControl, Select and Pagination.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--border | divider |
--muted-foreground | text |