Skip to the content

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.

import { DataView } from "@booleanpress/ui/data-view"

export default function DataViewLoading() {
  return (
    <DataView
      aria-label="Mailers"
      items={[]}
      loading
      defaultLayout="grid"
      skeletonCount={6}
      itemMinWidth="11rem"
      className="w-full"
      renderItem={() => null}
    />
  )
}

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 a nav landmark. While loading, the list is aria-busy; the first load announces the provider's loading text in a status region.
Labels
Name the list with aria-label or aria-labelledby on DataView. The switch is named by the provider's layout string and its radios by layoutList and layoutGrid; the select by sortBy, 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 PaginationRange from 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.

Keyboard

Keyboard
KeyBehaviour
TabMoves 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).
EnterOn 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.
EscapeCloses the sort options without changing the order.

API

DataView

DataView props
PropTypeDefaultDescription
itemsrequiredreadonly 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) => ReactNodeDraws one item for the layout in use. The item is wrapped in a list item for you.
defaultLayout"grid" | "list"listThe layout it starts in, when it controls itself.
defaultPagenumber1The page it starts on, when it controls itself.
defaultSortstringThe sort option it starts with, when it controls itself; none keeps the items' own order.
emptyReactNodeWhat to show when there are no items and nothing is loading. Defaults to the provider's noItems text.
footerReactNodeContent 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.
headerReactNodeContent at the start of the header, such as a title or a search field.
itemMinWidthstring15remThe 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.
layoutTogglebooleanfalseShows the built-in switch between list and grid, at the end of the header.
loadingbooleanfalseShows 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.
pagenumberThe current page, from 1, when you control it. Pair it with onPageChange.
pageSizenumberItems 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.
skeletonCountnumberThe number of placeholders while loading. Defaults to pageSize, or 3.
sortstringThe chosen sort option's value, when you control it.
sortOptionsDataViewSortOption<T>[]The ways to sort: given, the header shows a select of them.
totalnumberThe number of items on the server, when items holds the current page only.

DataViewLayoutToggle

DataViewLayoutToggle props
PropTypeDefaultDescription
onValueChangerequired(layout: DataViewLayout) => voidCalled with the layout chosen.
valuerequired"grid" | "list"The layout in use.
asChildboolean
dir"ltr" | "rtl"The reading direction, for the arrow keys. Defaults to the provider's dir.
disabledbooleanTurns the switch off.
fluidbooleanFills the width of its container; the segments share it equally.
formstring
loopboolean
namestring
requiredboolean
size"default" | "sm" | "lg"smThe 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

DataViewSort props
PropTypeDefaultDescription
onValueChangerequired(value: string) => voidCalled with the option chosen.
optionsrequiredPick<DataViewSortOption<unknown>, "label" | "value">[]The options, each with a value and a label.
asChildboolean
clearablebooleanShows a clear button inside the field while a value is chosen; it resets the value to the placeholder.
fluidbooleanFills 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.
valuestringThe 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.

Theme tokens
TokenUsed for
--borderdivider
--muted-foregroundtext