# 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"`
- **APG Radio Group:** <https://www.w3.org/WAI/ARIA/apg/patterns/radio/>
- **Page:** <https://ui.booleanpress.com/components/data-view> · @booleanpress/ui 0.2.0

## Usage

Give `DataView` the items and a `renderItem` that draws one of them; it wraps each in a list item.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| 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 |
| --- | --- | --- | --- |
| `items` (required) | `readonly T[]` |  | The items to show: all of them, or the current page when the server pages (with `total`). |
| `renderItem` (required) | `(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 |
| --- | --- | --- | --- |
| `onValueChange` (required) | `(layout: DataViewLayout) => void` |  | Called with the layout chosen. |
| `value` (required) | `"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 |
| --- | --- | --- | --- |
| `onValueChange` (required) | `(value: string) => void` |  | Called with the option chosen. |
| `options` (required) | `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`.

| Token | Used for |
| --- | --- |
| `--border` | divider |
| `--muted-foreground` | text |
