# List page

A page that lists records in a table, with a search field, a status filter and row actions.

```tsx
import * as React from 'react'
import { FolderPlus, MoreHorizontal, Pause, Play, Plus, SearchX, SquareArrowOutUpRight, Trash2 } from 'lucide-react'

import {
  Avatar,
  AvatarFallback,
  Badge,
  Button,
  ConfirmDialog,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  EmptyState,
  FilterMenu,
  ListToolbar,
  PageContainer,
  PageHeader,
  SearchInput,
  StatusBadge,
  StatusDot,
  Table,
  TableBody,
  TableCell,
  TableErrorRow,
  TableHead,
  TableHeader,
  TableRow,
  TableSkeletonRows,
  rowLinkProps,
  toast,
  type FilterOption,
} from '../index'

import {
  EXAMPLE_PROJECTS,
  ExampleApp,
  ExampleLink,
  initialsOf,
  PROJECT_STATUS,
  type ExampleProject,
  type ProjectStatus,
} from './example-app'

const STATUSES: ProjectStatus[] = ['active', 'paused', 'archived']
const COLUMNS = 6

/* ---------------------------------------------------------------------------------------------- */
/* Page                                                                                            */
/* ---------------------------------------------------------------------------------------------- */

/** Props of the List Page example. */
export interface ListPageExampleProps {
  /** The records to list. Search, status filter, pause / resume and delete are applied on top of them. */
  projects?: ExampleProject[]
  /** First load in flight: the table keeps its header and shows skeleton rows; the toolbar is disabled. */
  loading?: boolean
  /**
   * What the first load threw or rejected with. The table keeps its header and shows a
   * `TableErrorRow` with the message; the toolbar is disabled. Ignored while `loading`.
   */
  error?: unknown
  /** Called by the Retry button of the error row (a real app refetches). */
  onRetry?: () => void
  /** Called with the target `href` when a link or a row is opened (a real app navigates). */
  onNavigate?: (href: string) => void
  /** Called by the page's primary action ("New project"). */
  onCreateProject?: () => void
  /** Called with the project once its deletion is confirmed. */
  onDeleteProject?: (project: ExampleProject) => void
}

/**
 * A filterable collection page: `PageHeader` with the primary action, a `ListToolbar` (search +
 * status `FilterMenu`) and a `Table` whose rows open the record, with a row menu and a confirmed
 * delete.
 */
export function ListPageExample({
  projects = EXAMPLE_PROJECTS,
  loading = false,
  error,
  onRetry,
  onNavigate,
  onCreateProject,
  onDeleteProject,
}: ListPageExampleProps) {
  const [query, setQuery] = React.useState('')
  const [statusFilter, setStatusFilter] = React.useState<string[]>([])
  // Local edits layered over `projects` (a real app would mutate on the server and refetch).
  const [statusOverrides, setStatusOverrides] = React.useState<Record<string, ProjectStatus>>({})
  const [deletedIds, setDeletedIds] = React.useState<string[]>([])
  // The target outlives `deleteOpen` so the title stays put while the dialog animates out.
  const [deleteTarget, setDeleteTarget] = React.useState<ExampleProject | null>(null)
  const [deleteOpen, setDeleteOpen] = React.useState(false)

  const rows = projects
    .filter((project) => !deletedIds.includes(project.id))
    .map((project) => ({ ...project, status: statusOverrides[project.id] ?? project.status }))

  const needle = query.trim().toLowerCase()
  const visible = rows.filter(
    (project) =>
      (statusFilter.length === 0 || statusFilter.includes(project.status)) &&
      (needle === '' || project.name.toLowerCase().includes(needle) || project.owner.toLowerCase().includes(needle)),
  )
  const filtered = needle !== '' || statusFilter.length > 0
  // The first load failed: there is nothing to list, search or filter until a retry succeeds.
  const failed = error !== undefined && !loading
  const unavailable = loading || failed

  const statusOptions: FilterOption[] = STATUSES.map((status) => ({
    value: status,
    label: PROJECT_STATUS[status].label,
    icon: <StatusDot tone={PROJECT_STATUS[status].tone} />,
    count: rows.filter((project) => project.status === status).length,
  }))

  const clearFilters = () => {
    setQuery('')
    setStatusFilter([])
  }

  const open = (project: ExampleProject) => onNavigate?.(`/projects/${project.id}`)

  const togglePaused = (project: ExampleProject) => {
    const next: ProjectStatus = project.status === 'paused' ? 'active' : 'paused'
    setStatusOverrides((overrides) => ({ ...overrides, [project.id]: next }))
    toast.success(next === 'paused' ? `${project.name} paused` : `${project.name} resumed`)
  }

  return (
    <ExampleApp section="projects" onNavigate={onNavigate}>
      <PageContainer>
        <PageHeader
          title="Projects"
          description="Everything the Acme workspace is working on."
          actions={
            <Button variant="primary" icon={<Plus />} onClick={onCreateProject}>
              New project
            </Button>
          }
        />

        {!unavailable && rows.length === 0 ? (
          // Nothing exists yet: explain what goes here and offer the action that creates it.
          <EmptyState
            size="lg"
            icon={<FolderPlus />}
            title="No projects yet"
            description="Projects group the work, members and API keys of one product. Create the first one to get started."
            actions={
              <Button variant="primary" icon={<Plus />} onClick={onCreateProject}>
                New project
              </Button>
            }
          />
        ) : (
          <>
            <ListToolbar
              className="mb-4"
              actions={
                !unavailable && (
                  <span className="text-[13px] text-foreground-lighter tabular" aria-live="polite">
                    {filtered ? `${visible.length} of ${rows.length} projects` : `${rows.length} projects`}
                  </span>
                )
              }
            >
              <SearchInput placeholder="Search projects" value={query} onValueChange={setQuery} disabled={unavailable} />
              <FilterMenu
                label="Status"
                options={statusOptions}
                value={statusFilter}
                onValueChange={setStatusFilter}
                disabled={unavailable}
              />
            </ListToolbar>

            {!unavailable && visible.length === 0 ? (
              // The filters match nothing: say so and offer the way back.
              <EmptyState
                icon={<SearchX />}
                title="No projects match your filters"
                description="Try another name or owner, or clear the status filter."
                actions={<Button onClick={clearFilters}>Clear filters</Button>}
              />
            ) : (
              <Table aria-label="Projects">
                <TableHeader>
                  <TableRow className="hover:bg-transparent">
                    <TableHead>Project</TableHead>
                    <TableHead>Status</TableHead>
                    <TableHead>Owner</TableHead>
                    <TableHead className="text-right">Members</TableHead>
                    <TableHead>Updated</TableHead>
                    <TableHead className="w-[1%]">
                      <span className="sr-only">Actions</span>
                    </TableHead>
                  </TableRow>
                </TableHeader>
                <TableBody aria-busy={loading || undefined}>
                  {loading ? (
                    <TableSkeletonRows columns={COLUMNS} rows={6} />
                  ) : failed ? (
                    // The load failed: keep the header, show the message and offer Retry.
                    <TableErrorRow colSpan={COLUMNS} error={error} onRetry={onRetry} />
                  ) : (
                    visible.map((project) => {
                      const status = PROJECT_STATUS[project.status]
                      return (
                        // The whole row opens the record; the link in the first cell is what
                        // keyboard and screen-reader users (and cmd/ctrl-click) rely on.
                        <TableRow key={project.id} {...rowLinkProps(() => open(project))}>
                          <TableCell>
                            <span className="flex items-center gap-2">
                              <ExampleLink
                                href={`/projects/${project.id}`}
                                className="rounded-sm font-medium text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
                              >
                                {project.name}
                              </ExampleLink>
                              <Badge font="mono" shape="square" case="normal">
                                {project.team}
                              </Badge>
                            </span>
                          </TableCell>
                          <TableCell>
                            <StatusBadge tone={status.tone} label={status.label} />
                          </TableCell>
                          <TableCell>
                            <span className="flex items-center gap-2 text-foreground-light">
                              <Avatar size="sm">
                                <AvatarFallback>{initialsOf(project.owner)}</AvatarFallback>
                              </Avatar>
                              {project.owner}
                            </span>
                          </TableCell>
                          <TableCell className="text-right tabular">{project.members}</TableCell>
                          <TableCell className="text-foreground-lighter">{project.updated}</TableCell>
                          <TableCell>
                            <DropdownMenu>
                              <DropdownMenuTrigger asChild>
                                <Button
                                  variant="ghost"
                                  size="icon-tiny"
                                  icon={<MoreHorizontal />}
                                  aria-label={`Actions for ${project.name}`}
                                />
                              </DropdownMenuTrigger>
                              <DropdownMenuContent align="end">
                                <DropdownMenuItem onSelect={() => open(project)}>
                                  <SquareArrowOutUpRight /> Open
                                </DropdownMenuItem>
                                {project.status !== 'archived' && (
                                  <DropdownMenuItem onSelect={() => togglePaused(project)}>
                                    {project.status === 'paused' ? <Play /> : <Pause />}
                                    {project.status === 'paused' ? 'Resume' : 'Pause'}
                                  </DropdownMenuItem>
                                )}
                                <DropdownMenuSeparator />
                                {/* Opens the controlled ConfirmDialog rendered once, below the table. */}
                                <DropdownMenuItem
                                  variant="destructive"
                                  onSelect={() => {
                                    setDeleteTarget(project)
                                    setDeleteOpen(true)
                                  }}
                                >
                                  <Trash2 /> Delete…
                                </DropdownMenuItem>
                              </DropdownMenuContent>
                            </DropdownMenu>
                          </TableCell>
                        </TableRow>
                      )
                    })
                  )}
                </TableBody>
              </Table>
            )}
          </>
        )}
      </PageContainer>

      {/* One controlled dialog for every row, opened from the row menu. It closes itself on success. */}
      <ConfirmDialog
        open={deleteOpen}
        onOpenChange={setDeleteOpen}
        title={`Delete project “${deleteTarget?.name ?? ''}”?`}
        description="Its tasks, files and API keys are permanently deleted. This cannot be undone."
        confirmLabel="Delete project"
        onConfirm={() => {
          if (!deleteTarget) return
          setDeletedIds((ids) => [...ids, deleteTarget.id])
          onDeleteProject?.(deleteTarget)
          toast.success(`${deleteTarget.name} deleted`)
        }}
      />
    </ExampleApp>
  )
}
```

Type in the search field, or open the Status filter, to change the rows. Open the menu of a row to see its actions.

The example has no router. A click on a link shows a message with the target of the link.

## Components

The code under the preview is the page. The frame around it is `ExampleApp`. The [Dashboard](/docs/examples/dashboard#the-frame) example tells how the frame works.

| Region | Components |
| --- | --- |
| Header | [Page](/docs/components/page), [Button](/docs/components/button) |
| Toolbar | [List Toolbar](/docs/components/list-toolbar): `ListToolbar`, `SearchInput`, `FilterMenu` |
| Table | [Table](/docs/components/table), [Status](/docs/components/status), [Badge](/docs/components/badge), [Avatar](/docs/components/avatar) |
| Row actions | [Dropdown Menu](/docs/components/dropdown-menu), [Confirm Dialog](/docs/components/confirm-dialog), [Toast](/docs/components/toast) |
| Load and error states | [Table States](/docs/components/table-states): `TableSkeletonRows`, `TableErrorRow` |
| Empty states | [Empty State](/docs/components/empty-state) |

## Design rules

- **The full row opens the record.** `rowLinkProps()` makes the row open the record on a click. The link in the first cell stays the target for the keyboard and for screen readers.
- **One map for the status.** `PROJECT_STATUS` gives a tone and a label to each status. Each `StatusBadge` and each `StatusDot` reads this map.
- **One dialog for all the rows.** The "Delete" item of each row opens the same controlled `ConfirmDialog`.
- **A toast for a quick action.** "Pause" and "Resume" apply immediately. A toast tells the result.
- **The toolbar shows the result count.** The `actions` slot of `ListToolbar` holds the count.

## States

The example has a prop for each state of the list.

| Prop | The page shows |
| --- | --- |
| `loading` | Skeleton rows below the table header. The toolbar is disabled. |
| `error` | An error row with the message of the error. The toolbar is disabled. |
| `onRetry` | A "Retry" button in the error row. |
| `projects={[]}` | An empty state with the "New project" action. |

When the filters find no row, an empty state replaces the table. Its "Clear filters" button brings the rows back.
