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>
)
}