Display

Async Table

A controlled, server-driven data table for async DB-based sorting and filtering. Each filterable column header opens a popover that loads its distinct values from the source (searchable, paginated on scroll) with explicit Apply/Clear, three-state sort, and active-filter chips above the table. Built on the quebi Table, Popover, and List Box.

tabledatagridasyncserverfiltersortpaginationsearchinteractive

Server-driven orders

Sort columns (three-state: ascending → descending → off) and filter Status / Country / Customer. Each filter popover loads its distinct values from the source with search and scroll-to-load-more; Apply runs one query. Active filters show as chips above the table.

Loading…
No orders match these filters.

Source

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { ArrowDown, ArrowUp, ArrowUpDown, Filter, Loader2, X } from "lucide-react"
import { useMemo, useRef, useState } from "react"
import type { ColumnProps, Selection } from "react-aria-components"
import { Autocomplete, Button as ButtonPrimitive } from "react-aria-components"
import { useAsyncList } from "react-stately"
import { Button } from "@/components/button"
import { ListBox, ListBoxItem } from "@/components/list-box"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover"
import { SearchField, SearchInput } from "@/components/search-field"
import {
  Table,
  TableBody,
  TableCell,
  TableColumn,
  TableHeader,
  TableRow,
} from "@/components/table"
import { cn } from "@/lib/utils"

/**
 * Async Table — quebi design system
 *
 * A controlled, server-driven data table: sorting and per-column filtering
 * report their intent through callbacks so the consumer can re-query the
 * database, while the component owns the interaction. Each filterable column
 * header opens a popover that loads its distinct values from the source
 * (searchable, paginated on scroll), with an explicit Apply/Clear so a single
 * round-trip runs per change. Active filters surface as removable chips above
 * the table. Built on the quebi Table, Popover, Autocomplete + List Box.
 */

export type AsyncTableSortDirection = "asc" | "desc"

export interface AsyncTableSort {
  column: string
  direction: AsyncTableSortDirection
}

/** A distinct value a column can be filtered by. `label` defaults to `value`. */
export interface AsyncTableFilterOption {
  value: string
  label?: string
}

export interface AsyncTableFilterPage {
  items: AsyncTableFilterOption[]
  /** Return a cursor to enable "load more" on scroll; omit when exhausted. */
  cursor?: string
}

export interface AsyncTableLoadFilterParams {
  /** The column whose distinct values are being requested. */
  column: string
  search: string
  cursor?: string
  signal: AbortSignal
}

export type AsyncTableLoadFilterValues = (
  params: AsyncTableLoadFilterParams,
) => Promise<AsyncTableFilterPage>

export interface AsyncTableColumn<T> {
  /** Stable id; also the sort/filter key sent back through callbacks. */
  id: string
  label: string
  cell: (row: T) => React.ReactNode
  isRowHeader?: boolean
  sortable?: boolean
  filterable?: boolean
  /** Data-cell text alignment (headers stay start-aligned). */
  align?: "start" | "center" | "end"
  width?: number | string
}

export interface AsyncTableProps<T> {
  "aria-label": string
  columns: AsyncTableColumn<T>[]
  rows: T[]
  getRowId: (row: T) => string | number
  /** Controlled sort; `null` when unsorted. */
  sort?: AsyncTableSort | null
  onSortChange?: (sort: AsyncTableSort | null) => void
  /** Controlled filters: column id → selected values. */
  filters?: Record<string, string[]>
  onFiltersChange?: (filters: Record<string, string[]>) => void
  /** Loads a page of distinct values for a column's filter popover. */
  loadFilterValues?: AsyncTableLoadFilterValues
  /** Show a busy indicator while the consumer re-queries rows. */
  isLoading?: boolean
  renderEmptyState?: () => React.ReactNode
  className?: string
}

const alignClass = (align?: "start" | "center" | "end") =>
  align === "center" ? "text-center" : align === "end" ? "text-end" : "text-start"

const sameValues = (a: string[], b: string[]) =>
  a.length === b.length && [...a].sort().join("") === [...b].sort().join("")

/* ------------------------------ filter popover ----------------------------- */

interface ColumnFilterPanelProps {
  column: string
  label: string
  appliedValues: string[]
  loadFilterValues?: AsyncTableLoadFilterValues
  onCommit: (values: string[]) => void
}

interface FilterItem {
  id: string
  label: string
}

/**
 * Rendered inside the popover, so it mounts (and reloads) each time the filter
 * opens. Pending selection is a transaction: Apply/Clear commit it, dismissing
 * the popover discards it.
 */
function ColumnFilterPanel({
  column,
  label,
  appliedValues,
  loadFilterValues,
  onCommit,
}: ColumnFilterPanelProps) {
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  // Pending selection as value → label so selected rows stay labelled even when
  // filtered out of the current page. Pre-applied values fall back to label=value.
  const [pending, setPending] = useState<Map<string, string>>(
    () => new Map(appliedValues.map((v) => [v, v])),
  )

  const list = useAsyncList<FilterItem>({
    async load({ signal, cursor, filterText }) {
      if (!loadFilterValues) return { items: [] }
      const page = await loadFilterValues({
        column,
        search: filterText ?? "",
        cursor,
        signal,
      })
      return {
        items: page.items.map((o) => ({ id: o.value, label: o.label ?? o.value })),
        cursor: page.cursor,
      }
    },
  })

  // Selected values not in the loaded page are shown first, so every selection
  // stays visible and un-checkable regardless of the search/scroll position.
  const items = useMemo<FilterItem[]>(() => {
    const loaded = new Set(list.items.map((i) => i.id))
    const selectedOnly: FilterItem[] = []
    for (const [id, lbl] of pending) if (!loaded.has(id)) selectedOnly.push({ id, label: lbl })
    return [...selectedOnly, ...list.items]
  }, [list.items, pending])

  const onSearch = (text: string) => {
    if (debounceRef.current) clearTimeout(debounceRef.current)
    debounceRef.current = setTimeout(() => list.setFilterText(text), 250)
  }

  const onSelectionChange = (keys: Selection) => {
    const allKeys = keys === "all" ? new Set(items.map((i) => i.id)) : keys
    const next = new Map<string, string>()
    for (const k of allKeys) {
      const id = String(k)
      const item = items.find((i) => i.id === id)
      next.set(id, item?.label ?? pending.get(id) ?? id)
    }
    setPending(next)
  }

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    const el = e.currentTarget
    if (
      el.scrollHeight - el.scrollTop - el.clientHeight < 60 &&
      list.items.length > 0 &&
      list.loadingState === "idle"
    ) {
      list.loadMore()
    }
  }

  const pendingValues = Array.from(pending.keys())
  const isLoading = list.loadingState === "loading" || list.loadingState === "filtering"
  const isDirty = !sameValues(pendingValues, appliedValues)
  const canClear = pending.size > 0 || appliedValues.length > 0

  return (
    <div className="flex w-full flex-col">
      <Autocomplete onInputChange={onSearch}>
        <SearchField
          autoFocus
          aria-label={`Filter ${label}`}
          className="border-cyan-500/10 border-b"
        >
          <SearchInput
            placeholder="Search values…"
            className="border-none bg-transparent outline-hidden focus:ring-0"
          />
        </SearchField>
        <ListBox
          aria-label={`${label} values`}
          selectionMode="multiple"
          selectedKeys={new Set(pending.keys())}
          onSelectionChange={onSelectionChange}
          onScroll={handleScroll}
          items={items}
          className="max-h-64 min-w-0 rounded-none border-0 bg-transparent shadow-none"
          renderEmptyState={() => (
            <div className="flex items-center justify-center gap-2 py-6 text-quebi-fg-subtle text-sm">
              {isLoading ? (
                <>
                  <Loader2 className="size-4 animate-spin" aria-hidden="true" />
                  Loading…
                </>
              ) : (
                "No results"
              )}
            </div>
          )}
        >
          {(item) => (
            <ListBoxItem id={item.id} textValue={item.label}>
              {item.label}
            </ListBoxItem>
          )}
        </ListBox>
      </Autocomplete>

      {list.loadingState === "loadingMore" && (
        <div className="flex items-center justify-center border-cyan-500/10 border-t py-2">
          <Loader2 className="size-4 animate-spin text-quebi-fg-subtle" aria-hidden="true" />
        </div>
      )}

      <div className="flex items-center gap-2 border-cyan-500/10 border-t p-2">
        <Button
          intent="ghost"
          size="xs"
          className="flex-1"
          isDisabled={!canClear}
          onPress={() => onCommit([])}
        >
          Clear
        </Button>
        <Button
          intent="primary"
          size="xs"
          className="flex-1"
          isDisabled={!isDirty}
          onPress={() => onCommit(pendingValues)}
        >
          Apply
        </Button>
      </div>
    </div>
  )
}

/* --------------------------- filterable header ----------------------------- */

interface FilterableColumnHeaderProps {
  label: string
  column: string
  sortable?: boolean
  sortDirection?: AsyncTableSortDirection | null
  onToggleSort?: () => void
  filterable?: boolean
  appliedValues?: string[]
  onApplyFilter?: (values: string[]) => void
  loadFilterValues?: AsyncTableLoadFilterValues
}

export function FilterableColumnHeader({
  label,
  column,
  sortable,
  sortDirection = null,
  onToggleSort,
  filterable,
  appliedValues = [],
  onApplyFilter,
  loadFilterValues,
}: FilterableColumnHeaderProps) {
  const [open, setOpen] = useState(false)
  const activeCount = appliedValues.length
  const isFiltered = activeCount > 0

  const SortIcon =
    sortDirection === "asc" ? ArrowUp : sortDirection === "desc" ? ArrowDown : ArrowUpDown

  return (
    <div className="inline-flex items-center gap-1">
      {sortable ? (
        <ButtonPrimitive
          onPress={onToggleSort}
          className={cn(
            "group -mx-1 inline-flex items-center gap-1 rounded-quebi-sm px-1 py-0.5 outline-none transition-colors",
            "hover:text-white focus-visible:ring-2 focus-visible:ring-quebi-brand/50",
            isFiltered ? "text-white" : "text-quebi-fg-muted",
          )}
          aria-label={`Sort by ${label}${
            sortDirection ? `, currently ${sortDirection === "asc" ? "ascending" : "descending"}` : ""
          }`}
        >
          <span>{label}</span>
          <SortIcon
            data-slot="icon"
            className={cn(
              "size-3.5 shrink-0 transition-opacity",
              sortDirection
                ? "text-quebi-brand opacity-100"
                : "text-quebi-fg-subtle opacity-0 group-hover:opacity-100",
            )}
            aria-hidden="true"
          />
        </ButtonPrimitive>
      ) : (
        <span className={cn(isFiltered && "text-white")}>{label}</span>
      )}

      {filterable && (
        <Popover isOpen={open} onOpenChange={setOpen}>
          <PopoverTrigger
            intent="ghost"
            size="sq-xs"
            isCircle
            aria-label={
              isFiltered ? `Filter ${label} (${activeCount} active)` : `Filter ${label}`
            }
            className={cn("relative", isFiltered && "text-quebi-brand")}
          >
            <Filter data-slot="icon" aria-hidden="true" />
            {isFiltered && (
              <span className="-top-0.5 -right-0.5 absolute grid min-w-3.5 place-content-center rounded-full bg-quebi-brand px-1 font-semibold text-[9px] text-quebi-bg leading-[0.9rem]">
                {activeCount}
              </span>
            )}
          </PopoverTrigger>
          <PopoverContent className="w-64 p-0">
            <ColumnFilterPanel
              column={column}
              label={label}
              appliedValues={appliedValues}
              loadFilterValues={loadFilterValues}
              onCommit={(values) => {
                onApplyFilter?.(values)
                setOpen(false)
              }}
            />
          </PopoverContent>
        </Popover>
      )}
    </div>
  )
}

/* -------------------------------- the table -------------------------------- */

export function AsyncTable<T>({
  "aria-label": ariaLabel,
  columns,
  rows,
  getRowId,
  sort = null,
  onSortChange,
  filters = {},
  onFiltersChange,
  loadFilterValues,
  isLoading,
  renderEmptyState,
  className,
}: AsyncTableProps<T>) {
  const hasExplicitRowHeader = columns.some((c) => c.isRowHeader)

  const toggleSort = (columnId: string) => {
    const current = sort && sort.column === columnId ? sort.direction : null
    const next: AsyncTableSort | null =
      current === null
        ? { column: columnId, direction: "asc" }
        : current === "asc"
          ? { column: columnId, direction: "desc" }
          : null
    onSortChange?.(next)
  }

  const setColumnFilter = (columnId: string, values: string[]) => {
    const next = { ...filters }
    if (values.length === 0) delete next[columnId]
    else next[columnId] = values
    onFiltersChange?.(next)
  }

  const activeFilters = columns
    .map((c) => ({ column: c, values: filters[c.id] ?? [] }))
    .filter((f) => f.values.length > 0)

  return (
    <div className={cn("flex flex-col gap-2", className)} aria-busy={isLoading || undefined}>
      {(activeFilters.length > 0 || isLoading) && (
        <div className="flex min-h-7 flex-wrap items-center gap-1.5">
          {activeFilters.map(({ column, values }) => (
            <span
              key={column.id}
              className="inline-flex items-center gap-x-1 rounded-full border border-quebi-brand/30 bg-quebi-brand/10 py-0.5 pe-1 ps-2.5 font-medium text-quebi-brand text-xs"
            >
              <span>
                {column.label}
                <span className="text-quebi-brand/70"> · {values.length}</span>
              </span>
              <Button
                aria-label={`Clear ${column.label} filter`}
                onPress={() => setColumnFilter(column.id, [])}
                className="flex size-4 shrink-0 items-center justify-center rounded-full text-quebi-brand/80 outline-none transition-colors hover:bg-quebi-brand/20 hover:text-quebi-brand focus-visible:ring-2 focus-visible:ring-quebi-brand/50"
              >
                <X className="size-3" strokeWidth={2.5} aria-hidden="true" />
              </Button>
            </span>
          ))}
          {activeFilters.length > 1 && (
            <Button intent="ghost" size="xs" onPress={() => onFiltersChange?.({})}>
              Clear all
            </Button>
          )}
          {isLoading && (
            <span className="ms-auto inline-flex items-center gap-1.5 text-quebi-fg-subtle text-xs">
              <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
              Loading…
            </span>
          )}
        </div>
      )}

      <Table
        aria-label={ariaLabel}
        className={cn(isLoading && "opacity-60 transition-opacity")}
      >
        <TableHeader>
          {columns.map((col, i) => (
            <TableColumn
              key={col.id}
              id={col.id}
              isRowHeader={col.isRowHeader ?? (!hasExplicitRowHeader && i === 0)}
              width={col.width as ColumnProps["width"]}
            >
              <FilterableColumnHeader
                label={col.label}
                column={col.id}
                sortable={col.sortable}
                sortDirection={sort && sort.column === col.id ? sort.direction : null}
                onToggleSort={() => toggleSort(col.id)}
                filterable={col.filterable}
                appliedValues={filters[col.id] ?? []}
                onApplyFilter={(values) => setColumnFilter(col.id, values)}
                loadFilterValues={loadFilterValues}
              />
            </TableColumn>
          ))}
        </TableHeader>
        <TableBody
          items={rows.map((row) => ({ id: getRowId(row), row }))}
          renderEmptyState={renderEmptyState}
        >
          {(entry) => (
            <TableRow id={entry.id}>
              {columns.map((col) => (
                <TableCell key={col.id} className={alignClass(col.align)}>
                  {col.cell(entry.row)}
                </TableCell>
              ))}
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}