shadimage
data-table-filters
blockdata-table-example-infinitesource: ok · hit

Example: infinite table

A complete /example route to see the table running before wiring your own data: a table schema, a mock API that filters, computes facets and cursor-paginates in memory with the same semantics as the Drizzle handler, and the infinite table with a timeline chart, URL state, command palette, row sheet and a hand-written timing phases column. Delete app/example when you have your own.

npx shadcn add "https://data-table.openstatus.dev/r/data-table-example-infinite.json"
free

Where it goes

statsdata-table

Interaction

animated-countercursor-follow

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

https://data-table.openstatus.dev/r/data-table.jsonhttps://data-table.openstatus.dev/r/data-table-schema.jsonhttps://data-table.openstatus.dev/r/data-table-query.jsonhttps://data-table.openstatus.dev/r/data-table-nuqs.jsonhttps://data-table.openstatus.dev/r/data-table-sheet.jsonhttps://data-table.openstatus.dev/r/data-table-cell.jsonhttps://data-table.openstatus.dev/r/data-table-filter-command.jsonhttps://data-table.openstatus.dev/r/data-table-chart.jsonhover-carddropdown-menubutton

Files11 · source cached 10/8/2026, 1:40:34 PM

src/examples/infinite/layout.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import * as React from "react";

/**
 * The two providers the table needs, scoped to this route so nothing in the
 * root layout has to change. Move them up to `app/layout.tsx` once more than
 * one route uses them.
 */
export default function Layout({ children }: { children: React.ReactNode }) {
  const [queryClient] = React.useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <NuqsAdapter>{children}</NuqsAdapter>
    </QueryClientProvider>
  );
}
src/examples/infinite/page.tsx
import type { SearchParams as RawSearchParams } from "nuqs";
import { Client } from "./client";
import { searchParamsCache } from "./schema";

/**
 * Parses the URL on the server so the first render already has the filters,
 * the sort and the cursor — no flash of an unfiltered table.
 */
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<RawSearchParams>;
}) {
  const search = await searchParamsCache.parse(searchParams);
  return <Client initialState={search} />;
}
src/examples/infinite/client.tsx
"use client";

import {
  TimelineChart,
  type TimelineChartSeries,
} from "@/components/data-table/data-table-chart/timeline-chart";
import { DataTableFilterCommand } from "@/components/data-table/data-table-filter-command";
import { DataTableInfinite } from "@/components/data-table/data-table-infinite";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { DataTableRefreshButton } from "@/components/data-table/data-table-refresh-button";
import { MemoizedDataTableSheetContent } from "@/components/data-table/data-table-sheet/data-table-sheet-content";
import { DataTableSheetDetails } from "@/components/data-table/data-table-sheet/data-table-sheet-details";
import type { SheetField } from "@/components/data-table/types";
import {
  applyFacets,
  getFacetedMinMaxValues,
  getFacetedUniqueValues,
  getMetaPage,
  refreshDataTableQuery,
} from "@/lib/data-table";
import { isActive } from "@/lib/filters";
import { useNuqsAdapter } from "@/lib/store/adapters/nuqs";
import { useFilterState } from "@/lib/store/hooks/useFilterState";
import { DataTableStoreProvider } from "@/lib/store/provider/DataTableStoreProvider";
import {
  generateColumns,
  generateFilterFields,
  generateSheetFields,
  getDefaultColumnVisibility,
} from "@/lib/table-schema";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { Footer } from "./footer";
import { dataOptions } from "./query-options";
import { filterSchema, type FilterState, type SearchParams } from "./schema";
import {
  getLevelRowClassName,
  tableSchema,
  type ColumnSchema,
} from "./table-schema";
import { timingPhasesColumn } from "./timing-phases";

// Everything the table renders is generated from the schema, once. The one
// exception is the timing bar, which spans five columns and is appended by hand.
const columns = [
  ...generateColumns<ColumnSchema>(tableSchema.definition),
  timingPhasesColumn<ColumnSchema>(),
];
const filterFields = generateFilterFields<ColumnSchema>(tableSchema.definition);
const sheetFields = generateSheetFields<ColumnSchema>(tableSchema.definition);
const defaultColumnVisibility = getDefaultColumnVisibility(
  tableSchema.definition,
);

/** The chart's stacked series, bottom-up. Colours come from the core block's CSS variables. */
const CHART_SERIES: TimelineChartSeries[] = [
  { key: "error", label: "Error" },
  { key: "warning", label: "Warning" },
  { key: "info", label: "Info" },
];

// Module-level so its identity is stable: the memoized rows compare it.
const getRowClassName = (row: { original: ColumnSchema }) =>
  getLevelRowClassName(row.original.level);

/** URL state that is not a column filter. */
const STATE_KEYS = new Set(["sort", "uuid", "size", "direction", "cursor"]);

export function Client({ initialState }: { initialState: SearchParams }) {
  const adapter = useNuqsAdapter(filterSchema.definition, {
    id: "example",
    initialState,
  });

  return (
    <DataTableStoreProvider adapter={adapter}>
      <Table />
    </DataTableStoreProvider>
  );
}

function Table() {
  const search = useFilterState<FilterState>();
  const queryClient = useQueryClient();
  const { data, isFetching, isLoading, fetchNextPage, hasNextPage } =
    useInfiniteQuery(dataOptions(search));

  // Not the bare `refetch`: that would restart from the first cached page
  // param. The helper resets the cache to a fresh first page (now, with meta).
  const refresh = React.useCallback(
    () => refreshDataTableQuery(queryClient, dataOptions(search)),
    [queryClient, search],
  );

  const flatData = React.useMemo(
    () => data?.pages?.flatMap((page) => page.data ?? []) ?? [],
    [data?.pages],
  );

  // Facets and counts come from the page that carries meta — the first one,
  // since pagination requests skip it.
  const meta = getMetaPage(data)?.meta;
  const facets = meta?.facets;

  const defaultColumnFilters = Object.entries(search)
    .filter(([key, value]) => !STATE_KEYS.has(key) && isActive(value))
    .map(([id, value]) => ({ id, value }));

  const dynamicFilterFields = React.useMemo(
    () => applyFacets(filterFields, facets),
    [facets],
  );

  return (
    <DataTableInfinite
      columns={columns}
      data={flatData}
      totalRows={meta?.totalRowCount}
      filterRows={meta?.filterRowCount}
      totalRowsFetched={flatData.length}
      defaultColumnFilters={defaultColumnFilters}
      defaultColumnSorting={search.sort ? [search.sort] : undefined}
      defaultRowSelection={search.uuid ? { [search.uuid]: true } : undefined}
      defaultColumnVisibility={defaultColumnVisibility}
      filterFields={dynamicFilterFields}
      isFetching={isFetching}
      isLoading={isLoading}
      fetchNextPage={fetchNextPage}
      hasNextPage={hasNextPage}
      getRowId={(row) => row.uuid}
      getRowClassName={getRowClassName}
      getFacetedUniqueValues={getFacetedUniqueValues(facets)}
      getFacetedMinMaxValues={getFacetedMinMaxValues(facets)}
      chartSlot={
        <TimelineChart
          data={meta?.chartData ?? []}
          columnId="date"
          series={CHART_SERIES}
          className="-mb-2"
        />
      }
      toolbarActions={<DataTableRefreshButton onClick={refresh} />}
      footerSlot={<Footer />}
      commandSlot={
        <DataTableFilterCommand
          schema={filterSchema.definition}
          tableId="example"
        />
      }
      sheetSlot={
        <SheetSlot
          sheetFields={sheetFields}
          totalRows={meta?.totalRowCount ?? 0}
          filterRows={meta?.filterRowCount ?? 0}
          totalRowsFetched={flatData.length}
        />
      }
      tableId="example"
    />
  );
}

function SheetSlot({
  sheetFields: fields,
  totalRows,
  filterRows,
  totalRowsFetched,
}: {
  sheetFields: SheetField<ColumnSchema>[];
  totalRows: number;
  filterRows: number;
  totalRowsFetched: number;
}) {
  const { table, rowSelection, isLoading, filterFields } = useDataTable<
    ColumnSchema,
    unknown
  >();
  const selectedRowKey = Object.keys(rowSelection)?.[0];
  const selectedRow = React.useMemo(() => {
    if (isLoading && !selectedRowKey) return undefined;
    return table
      .getCoreRowModel()
      .flatRows.find((row) => row.id === selectedRowKey);
  }, [selectedRowKey, isLoading, table]);

  return (
    <DataTableSheetDetails
      title={
        selectedRow
          ? `${selectedRow.original.method} ${selectedRow.original.pathname}`
          : undefined
      }
      titleClassName="font-mono"
    >
      <MemoizedDataTableSheetContent
        table={table}
        data={selectedRow?.original}
        filterFields={filterFields}
        fields={fields}
        metadata={{ totalRows, filterRows, totalRowsFetched }}
      />
    </DataTableSheetDetails>
  );
}
src/examples/infinite/schema.ts
import {
  createNuqsSearchParams,
  type inferParserType,
} from "@/lib/store/adapters/nuqs/server";
import { field } from "@/lib/store/schema";
import { generateFilterSchema } from "@/lib/table-schema";
import { tableSchema } from "./table-schema";

const DIRECTIONS = ["prev", "next"] as const;

/**
 * Everything that lives in the URL: one filter field per filterable column,
 * generated from the table schema, plus the state that is not a filter —
 * the sort, the selected row, and the cursor pagination.
 */
export const filterSchema = generateFilterSchema(tableSchema.definition, {
  sort: field.sort(),
  uuid: field.string(),
  size: field.number().default(40),
  direction: field.stringLiteral(DIRECTIONS).default("next"),
  cursor: field.timestamp(),
});

export type FilterState = typeof filterSchema._type;

export const { searchParamsCache, searchParamsSerializer, searchParamsParser } =
  createNuqsSearchParams(filterSchema.definition);

export type SearchParams = inferParserType<typeof searchParamsParser>;
src/examples/infinite/table-schema.ts
import {
  col,
  createTableSchema,
  type InferTableType,
} from "@/lib/table-schema";
import { SheetTimingPhases } from "./timing-phases";

export const LEVELS = ["error", "warning", "info"] as const;
export const METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
export const REGIONS = ["ams", "fra", "iad", "syd"] as const;
export const STATUS_CODES = [200, 201, 400, 404, 500];

/**
 * A faint tint of the level's colour on the rows worth noticing, stepping up
 * on hover, focus, and for the row that is open in the sheet or checked — the
 * table marks that row `data-[state=selected]` without a select column, and
 * `data-detail` / `data-checked` with one. `info` is nearly every row, so it
 * stays plain.
 */
export function getLevelRowClassName(level: string): string {
  switch (level) {
    case "warning":
      return [
        "bg-warning/5 hover:bg-warning/10 focus-visible:bg-warning/10 data-[state=selected]:bg-warning/20 data-detail:bg-warning/20 data-checked:bg-warning/20",
        "dark:bg-warning/10 dark:hover:bg-warning/20 dark:focus-visible:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:data-detail:bg-warning/30 dark:data-checked:bg-warning/30",
      ].join(" ");
    case "error":
      return [
        "bg-error/5 hover:bg-error/10 focus-visible:bg-error/10 data-[state=selected]:bg-error/20 data-detail:bg-error/20 data-checked:bg-error/20",
        "dark:bg-error/10 dark:hover:bg-error/20 dark:focus-visible:bg-error/20 dark:data-[state=selected]:bg-error/30 dark:data-detail:bg-error/30 dark:data-checked:bg-error/30",
      ].join(" ");
    default:
      return "";
  }
}

/**
 * The one definition the whole example reads from. The columns, the filter
 * sidebar, the command palette, the row sheet and the mock API's filter
 * semantics are all generated from it, so a column added here shows up on
 * every surface.
 */
export const tableSchema = createTableSchema({
  level: col.presets
    .logLevel(LEVELS)
    .display("level-indicator")
    .hideHeader()
    .size(37)
    .sheet(),
  date: col.presets
    .timestamp()
    .label("Date")
    // The mock API returns rows newest-first and does not sort; the header
    // must not promise what the endpoint cannot deliver.
    .sortable(false)
    .defaultOpen()
    .commandDisabled()
    // The one unsized column: it absorbs the table's leftover width.
    .minSize(200)
    .sheet(),
  status: col.presets.httpStatus(STATUS_CODES).size(70).sheet(),
  method: col.presets.httpMethod(METHODS).size(80).sheet(),
  pathname: col.presets.pathname().size(220).sheet(),
  region: col
    .enum(REGIONS)
    .label("Region")
    .display("badge")
    .filterable("checkbox", {
      options: REGIONS.map((region) => ({ label: region, value: region })),
    })
    .size(90)
    .sheet(),
  latency: col.presets
    .latency("ms")
    .label("Latency")
    .sortable(false)
    .size(110)
    .sheet(),

  // The five phases behind `timingPhasesColumn`: hidden from the table by
  // default (toggle them in the column settings), each filterable by range,
  // and shown together as one sheet section hung off the first.
  "timing.dns": col.presets
    .duration("ms")
    .label("DNS")
    .size(110)
    .hidden()
    .sheet({
      label: "Timing Phases",
      component: SheetTimingPhases,
      className: "flex-col items-start w-full gap-1",
    }),
  "timing.connection": col.presets
    .duration("ms")
    .label("Connection")
    .size(110)
    .hidden(),
  "timing.tls": col.presets.duration("ms").label("TLS").size(110).hidden(),
  "timing.ttfb": col.presets.duration("ms").label("TTFB").size(110).hidden(),
  "timing.transfer": col.presets
    .duration("ms")
    .label("Transfer")
    .size(110)
    .hidden(),
  uuid: col.presets.traceId().label("Request ID").hidden().sheet(),
});

export type ColumnSchema = InferTableType<typeof tableSchema.definition>;
src/examples/infinite/timing.ts
/**
 * The request's timing phases, in the order they happen. Each is a flat,
 * dotted column on the row (`"timing.dns"`), the shape the table schema,
 * the filters and the mock API all read.
 */
export const TIMING_PHASES = [
  "timing.dns",
  "timing.connection",
  "timing.tls",
  "timing.ttfb",
  "timing.transfer",
] as const;

export type TimingPhase = (typeof TIMING_PHASES)[number];

export type Timing = Record<TimingPhase, number>;

/**
 * What `timingPhasesColumn` and `SheetTimingPhases` read off a row: the
 * total, and whichever phases the row carries. Any row type with these keys
 * fits, which is how the docs site's own demos share the components.
 */
export type TimingRow = { latency: number } & Partial<
  Record<TimingPhase, number>
>;

const PHASES: Record<TimingPhase, { label: string; color: string }> = {
  "timing.dns": { label: "DNS", color: "bg-emerald-500" },
  "timing.connection": { label: "Connection", color: "bg-cyan-500" },
  "timing.tls": { label: "TLS", color: "bg-blue-500" },
  "timing.ttfb": { label: "TTFB", color: "bg-violet-500" },
  "timing.transfer": { label: "Transfer", color: "bg-purple-500" },
};

export function getTimingLabel(phase: TimingPhase): string {
  return PHASES[phase].label;
}

export function getTimingColor(phase: TimingPhase): string {
  return PHASES[phase].color;
}

/**
 * One phase's share of the latency, as a percentage for a bar's `width`.
 * A row with no latency has no shares, not `NaN` ones.
 */
export function getTimingShare(
  timing: Timing,
  phase: TimingPhase,
  latency: number,
): number {
  return latency > 0 ? (timing[phase] / latency) * 100 : 0;
}

/** Each phase's share of the latency, formatted for display (`"12.5%"`, `"<1%"`). */
export function getTimingPercentage(
  timing: Timing,
  latency: number,
): Record<TimingPhase, string> {
  const out = {} as Record<TimingPhase, string>;
  for (const phase of TIMING_PHASES) {
    const share = getTimingShare(timing, phase, latency);
    out[phase] = share > 0 && share < 1 ? "<1%" : `${share.toFixed(1)}%`;
  }
  return out;
}

/** The phases of a row, read off its flat dotted keys. */
export function pickTiming(row: Partial<Record<TimingPhase, number>>): Timing {
  const out = {} as Timing;
  for (const phase of TIMING_PHASES) out[phase] = row[phase] ?? 0;
  return out;
}
src/examples/infinite/timing-phases.tsx
"use client";

import { HOVER_CARD_DELAY } from "@/components/data-table/ui-compat";
import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card";
import { formatMilliseconds } from "@/lib/format";
import type { DataTableFeatures } from "@/lib/table/features";
import { cn } from "@/lib/utils";
import type { ColumnDef } from "@tanstack/react-table";
import {
  getTimingColor,
  getTimingLabel,
  getTimingPercentage,
  getTimingShare,
  pickTiming,
  TIMING_PHASES,
  type Timing,
  type TimingRow,
} from "./timing";

/** One stacked bar, each phase as wide as its share of the latency. */
function TimingBar({
  timing,
  latency,
  className,
}: {
  timing: Timing;
  latency: number;
  className?: string;
}) {
  return (
    <div className={cn("flex", className)}>
      {TIMING_PHASES.map((phase) => (
        <div
          key={phase}
          className={cn(getTimingColor(phase), "h-4")}
          style={{ width: `${getTimingShare(timing, phase, latency)}%` }}
        />
      ))}
    </div>
  );
}

/**
 * A column that spans five schema columns, so it cannot be generated from
 * one of them: append `timingPhasesColumn<ColumnSchema>()` to
 * `generateColumns(...)` by hand. The bar reads the phases off `row.original`,
 * whose flat dotted keys are the schema's.
 *
 * A factory rather than one def so the column takes the table's own row type:
 * any row carrying `latency` and the phases fits, which is how the docs site's
 * demos share it.
 */
export function timingPhasesColumn<TData extends TimingRow>(): ColumnDef<
  DataTableFeatures,
  TData
> {
  return {
    id: "timing",
    header: () => <div className="whitespace-nowrap">Timing Phases</div>,
    cell: ({ row }) => {
      const timing = pickTiming(row.original);
      const latency = row.original.latency;
      const percentage = getTimingPercentage(timing, latency);
      return (
        // The props are spread, and the trigger has no `asChild`, on purpose:
        // this file ships as `registry:file`, which the shadcn CLI copies
        // verbatim — its Base UI codemod (`asChild` → `render`) never runs on
        // it, and a literal `openDelay` fails to typecheck on Base UI's
        // preview card. So the trigger renders its own element, with the
        // open-state class in both libraries' spelling.
        <HoverCard {...HOVER_CARD_DELAY}>
          <HoverCardTrigger className="block opacity-70 hover:opacity-100 data-open:opacity-100 data-[state=open]:opacity-100">
            <TimingBar timing={timing} latency={latency} />
          </HoverCardTrigger>
          <HoverCardContent
            side="bottom"
            align="end"
            className="z-10 w-auto p-2"
          >
            <div className="flex flex-col gap-1">
              {TIMING_PHASES.map((phase) => (
                <div key={phase} className="grid grid-cols-2 gap-4 text-xs">
                  <div className="flex items-center gap-2">
                    <div
                      className={cn(
                        getTimingColor(phase),
                        "h-2 w-2 rounded-full",
                      )}
                    />
                    <div className="text-accent-foreground font-mono uppercase">
                      {getTimingLabel(phase)}
                    </div>
                  </div>
                  <div className="flex items-center justify-between gap-4">
                    <div className="text-muted-foreground font-mono">
                      {percentage[phase]}
                    </div>
                    <div className="font-mono">
                      {formatMilliseconds(timing[phase])}
                      <span className="text-muted-foreground">ms</span>
                    </div>
                  </div>
                </div>
              ))}
            </div>
          </HoverCardContent>
        </HoverCard>
      );
    },
    enableResizing: false,
    size: 130,
    minSize: 130,
    // Pins the width: the bars have no intrinsic size, so without a fixed width
    // the column would collapse once another column claims the leftover width.
    maxSize: 130,
    meta: { label: "Timing Phases" },
  };
}

/**
 * The row sheet's "Timing Phases" section. The sheet renders a field's
 * `component` with the whole row as props, hence the loose parameter type:
 * `SheetConfig.component` is typed `(row: unknown) => …`.
 */
export function SheetTimingPhases(props: unknown) {
  const row = props as TimingRow;
  const timing = pickTiming(row);
  const percentage = getTimingPercentage(timing, row.latency);
  return (
    <div className="w-full space-y-1 text-left">
      {TIMING_PHASES.map((phase) => (
        <div
          key={phase}
          className="grid grid-cols-3 items-center justify-between gap-2 text-xs"
        >
          <div className="text-foreground truncate font-mono uppercase">
            {getTimingLabel(phase)}
          </div>
          <div className="col-span-2 flex gap-2">
            <div className="text-muted-foreground mr-8 font-mono">
              {percentage[phase]}
            </div>
            <div className="flex flex-1 items-center justify-end gap-2">
              <div className="font-mono">
                {formatMilliseconds(timing[phase])}
                <span className="text-muted-foreground">ms</span>
              </div>
            </div>
            <div
              className={cn(getTimingColor(phase), "h-4")}
              style={{
                width: `${getTimingShare(timing, phase, row.latency)}%`,
              }}
            />
          </div>
        </div>
      ))}
    </div>
  );
}
src/examples/infinite/footer.tsx
"use client";

import { buttonVariants } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Github, Keyboard } from "lucide-react";

/**
 * The chords `useHotKey` registers for the example. The hook only fires with
 * `⌘`/`Ctrl` held — that includes `Escape` — so every one of these is a chord
 * and can be replayed from the menu.
 */
export const SHORTCUTS = [
  { key: "k", label: "Toggle command input" },
  { key: "b", label: "Toggle filter controls" },
  { key: "u", label: "Reset column order and visibility" },
  { key: "Escape", label: "Reset table filters" },
] as const;

export type Shortcut = (typeof SHORTCUTS)[number];

/** Plain keys the table and the row sheet respond to. Listed, not replayed. */
export const NAVIGATION = [
  { keys: "↑ ↓", label: "Previous / next row (sheet open)" },
  { keys: "Enter", label: "Open the focused row" },
  { keys: "Esc", label: "Close the row sheet" },
] as const;

/**
 * Replays a chord as the keydown `useHotKey` listens for, so picking it from
 * the menu does the same thing as pressing it.
 */
export function triggerShortcut(key: Shortcut["key"]) {
  document.dispatchEvent(
    new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true }),
  );
}

export function formatShortcut(key: Shortcut["key"]) {
  return `⌘${key === "Escape" ? "Esc" : key.toUpperCase()}`;
}

/**
 * The sidebar footer: a GitHub link and the shortcuts list, with a
 * "Powered by openstatus" line. Delete it along with the rest of
 * `app/example` — or keep whatever is useful in your own footer slot.
 */
export function Footer() {
  return (
    <div className="flex flex-wrap items-center justify-between gap-2">
      <p className="text-muted-foreground text-xs">
        Powered by{" "}
        <a
          href="https://openstatus.dev"
          target="_blank"
          rel="noreferrer"
          className="text-foreground decoration-border hover:decoration-foreground underline underline-offset-4"
        >
          openstatus
        </a>
      </p>
      <div className="flex items-center gap-1">
        {/* No `asChild` here, on purpose: this file ships as `registry:file`,
            which the shadcn CLI copies verbatim, so its Base UI codemod
            (`asChild` → `render`) never runs on it. The link and the menu
            trigger take the button styles directly instead. */}
        <a
          href="https://github.com/openstatusHQ/data-table-filters"
          target="_blank"
          rel="noreferrer"
          className={buttonVariants({ variant: "ghost", size: "icon-xs" })}
        >
          <Github />
          <span className="sr-only">View on GitHub</span>
        </a>
        <ShortcutsMenu />
      </div>
    </div>
  );
}

function ShortcutsMenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        className={buttonVariants({ variant: "ghost", size: "icon-xs" })}
      >
        <Keyboard />
        <span className="sr-only">Keyboard shortcuts</span>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-72" align="end">
        <DropdownMenuGroup>
          <DropdownMenuLabel>Shortcuts</DropdownMenuLabel>
          {SHORTCUTS.map((shortcut) => (
            <DropdownMenuItem
              key={shortcut.key}
              onClick={() => triggerShortcut(shortcut.key)}
            >
              {shortcut.label}
              <DropdownMenuShortcut>
                {formatShortcut(shortcut.key)}
              </DropdownMenuShortcut>
            </DropdownMenuItem>
          ))}
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuGroup>
          <DropdownMenuLabel>Navigation</DropdownMenuLabel>
          {NAVIGATION.map((item) => (
            <DropdownMenuItem key={item.keys} disabled>
              {item.label}
              <DropdownMenuShortcut>{item.keys}</DropdownMenuShortcut>
            </DropdownMenuItem>
          ))}
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
src/examples/infinite/data.ts
import { LEVELS, METHODS, REGIONS, type ColumnSchema } from "./table-schema";
import { TIMING_PHASES, type Timing } from "./timing";

const ROWS = 5000;
const DAYS = 7;

const PATHNAMES = [
  "/api/v1/orders",
  "/api/v1/products",
  "/api/v1/customers",
  "/checkout",
  "/cart",
  "/search",
  "/account/settings",
  "/health",
];

/** A small seeded generator, so every server process serves the same rows. */
function mulberry32(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function pick<T>(random: () => number, values: readonly T[]): T {
  return values[Math.floor(random() * values.length)];
}

function hex(random: () => number, length: number): string {
  let out = "";
  for (let index = 0; index < length; index++) {
    out += Math.floor(random() * 16).toString(16);
  }
  return out;
}

function statusFor(random: () => number): number {
  const roll = random();
  if (roll < 0.85) return 200;
  if (roll < 0.9) return 201;
  if (roll < 0.94) return 400;
  if (roll < 0.97) return 404;
  return 500;
}

/** Rough share of a request each phase takes; jittered per row below. */
const TIMING_WEIGHTS = [0.05, 0.1, 0.15, 0.5, 0.2] as const;

/**
 * Split the latency over the phases so they add up to it exactly: every
 * phase but the last is rounded, and the last takes whatever is left.
 */
function timingFor(random: () => number, latency: number): Timing {
  const weights = TIMING_WEIGHTS.map(
    (weight) => weight * (0.6 + random() * 0.8),
  );
  const total = weights.reduce((sum, weight) => sum + weight, 0);
  const timing = {} as Timing;
  let remaining = latency;
  TIMING_PHASES.forEach((phase, index) => {
    const last = index === TIMING_PHASES.length - 1;
    const value = last
      ? remaining
      : Math.round((latency * weights[index]) / total);
    timing[phase] = Math.max(0, value);
    remaining -= value;
  });
  return timing;
}

function levelFor(status: number): (typeof LEVELS)[number] {
  if (status >= 500) return "error";
  if (status >= 400) return "warning";
  return "info";
}

/**
 * Rows spread over the last `DAYS` days, newest first — the order a timestamp
 * cursor expects. The values are seeded, so a refresh shows the same table;
 * only the timestamps move with the clock.
 */
export function createRows(now: number = Date.now()): ColumnSchema[] {
  const random = mulberry32(42);
  const span = DAYS * 24 * 60 * 60 * 1000;
  const step = span / ROWS;
  const rows: ColumnSchema[] = [];

  for (let index = 0; index < ROWS; index++) {
    const status = statusFor(random);
    const base = 40 + random() * 200;
    const slow = random() < 0.05 ? random() * 3000 : 0;
    const latency = Math.round(base + slow);
    rows.push({
      uuid: hex(random, 32),
      // Jitter stays under one step, so the order is strictly descending.
      date: new Date(now - Math.floor(index * step + random() * step * 0.4)),
      level: levelFor(status),
      status,
      method: pick(random, METHODS),
      pathname: pick(random, PATHNAMES),
      region: pick(random, REGIONS),
      latency,
      ...timingFor(random, latency),
    });
  }

  return rows;
}

export const rows = createRows();
src/examples/infinite/query-options.ts
import { createDataTableQueryOptions } from "@/lib/data-table";
import { searchParamsSerializer, type SearchParams } from "./schema";
import type { ColumnSchema } from "./table-schema";

const options = createDataTableQueryOptions<
  ColumnSchema[],
  Record<string, unknown>
>({
  queryKeyPrefix: "example",
  apiEndpoint: "/example/api",
  searchParamsSerializer: searchParamsSerializer as (
    search: Record<string, unknown>,
  ) => string,
  // The route honours `_meta=false`; the client reads meta via `getMetaPage`.
  skipMetaOnPagination: true,
});

export const dataOptions = (search: SearchParams) =>
  options(search as unknown as Record<string, unknown>);
src/examples/infinite/api/route.ts
import type {
  FacetMetadataSchema,
  InfiniteQueryResponse,
} from "@/lib/data-table";
import { bucketChartData } from "@/lib/data-table/chart-data";
import { defineFilters } from "@/lib/filters";
import SuperJSON from "superjson";
import { rows } from "../data";
import { searchParamsCache, type SearchParams } from "../schema";
import { LEVELS, tableSchema, type ColumnSchema } from "../table-schema";

export const dynamic = "force-dynamic";

/**
 * The same filter semantics the sidebar, the command palette and the Drizzle
 * handler use, applied to an array. When the rows move to Postgres, replace
 * this file with `createDrizzleHandler` from the drizzle block — the client
 * does not change.
 */
const filters = defineFilters(tableSchema.definition);

const sliderKeys = filters.specs
  .filter((spec) => spec.type === "slider")
  .map((spec) => spec.key);
const dateKeys = filters.specs
  .filter((spec) => spec.type === "timerange")
  .map((spec) => spec.key);
const facetKeys = filters.specs
  .filter((spec) => spec.type !== "timerange" && spec.type !== "slider")
  .map((spec) => spec.key);

export async function GET(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const search = searchParamsCache.parse(Object.fromEntries(url.searchParams));
  const values = search as Record<string, unknown>;

  // Three passes, matching the Drizzle handler: the date range first, then
  // every filter except the sliders, then the sliders. A slider's min and max
  // facet is computed over the second pass, so the range stays visible after
  // the user narrows it.
  const ranged = filters.apply(rows, values, { only: dateKeys });
  const withoutSliders = filters.apply(ranged, values, {
    exclude: [...sliderKeys, ...dateKeys],
  });
  const filtered = filters.apply(withoutSliders, values, { only: sliderKeys });

  const page = pageRows(sortRows(filtered, search.sort), search);

  // Pagination requests send `_meta=false`: the client keeps the meta from the
  // first page, and it cannot change while the filters are fixed.
  const skipMeta = url.searchParams.get("_meta") === "false";
  const facets = skipMeta
    ? {}
    : {
        ...facetsOf(withoutSliders, sliderKeys),
        ...facetsOf(filtered, facetKeys),
      };
  // One point per time bucket with a count per level, over the date filter
  // when one is set and the rows' own span otherwise.
  const chartData = skipMeta
    ? []
    : bucketChartData(filtered, {
        timestamp: (row) => row.date,
        series: (row) => row.level,
        keys: LEVELS,
        range: search.date,
      });

  const response: InfiniteQueryResponse<ColumnSchema[]> = {
    data: page,
    meta: {
      totalRowCount: rows.length,
      filterRowCount: filtered.length,
      chartData,
      facets,
    },
    prevCursor: page.length > 0 ? page[0].date.getTime() : null,
    nextCursor: page.length > 0 ? page[page.length - 1].date.getTime() : null,
  };

  return Response.json(SuperJSON.stringify(response));
}

function compare(left: unknown, right: unknown): number {
  const a = left instanceof Date ? left.getTime() : left;
  const b = right instanceof Date ? right.getTime() : right;
  if (typeof a === "number" && typeof b === "number") return a - b;
  return String(a).localeCompare(String(b));
}

/**
 * Newest first, the order a timestamp cursor addresses. The caller's sort
 * only decides between rows sharing a timestamp, and `uuid` descending
 * settles the rest — the same composition and directions as the Drizzle
 * handler (cursor desc, sort, tiebreak desc), so swapping it in does not
 * reshuffle tied rows.
 * Sorting by the requested column alone would make every page after the
 * first re-read or skip rows, because the cursor is still a timestamp.
 */
function sortRows(
  data: ColumnSchema[],
  sort: SearchParams["sort"],
): ColumnSchema[] {
  const key = sort?.id as keyof ColumnSchema | undefined;
  const direction = sort?.desc ? -1 : 1;
  return [...data].sort(
    (a, b) =>
      compare(b.date, a.date) ||
      (key ? compare(a[key], b[key]) * direction : 0) ||
      compare(b.uuid, a.uuid),
  );
}

/**
 * One page, addressed by a timestamp cursor. `next` reads rows older than the
 * cursor; `prev` reads everything newer, which is what a live refresh asks
 * for. Rows sharing the last timestamp travel together so none are skipped.
 */
function pageRows(
  data: ColumnSchema[],
  search: Pick<SearchParams, "cursor" | "size" | "direction">,
): ColumnSchema[] {
  const cursor = search.cursor?.getTime() ?? Date.now();
  // A `size` of zero or less would send back an empty first page forever.
  const size = search.size && search.size > 0 ? search.size : 40;

  if (search.direction === "prev") {
    return data.filter((row) => row.date.getTime() > cursor);
  }

  const page: ColumnSchema[] = [];
  for (const row of data) {
    const time = row.date.getTime();
    if (time >= cursor) continue;
    const last = page[page.length - 1];
    if (page.length < size || (last && last.date.getTime() === time)) {
      page.push(row);
    } else {
      break;
    }
  }
  return page;
}

/** Value counts per key, plus min and max for the numeric ones. */
function facetsOf(
  data: ColumnSchema[],
  keys: string[],
): Record<string, FacetMetadataSchema> {
  const facets: Record<string, FacetMetadataSchema> = {};

  for (const key of keys) {
    const counts = new Map<unknown, number>();
    let min: number | undefined;
    let max: number | undefined;

    for (const row of data) {
      const value = row[key as keyof ColumnSchema];
      if (value === null || value === undefined) continue;
      counts.set(value, (counts.get(value) ?? 0) + 1);
      if (typeof value === "number") {
        min = min === undefined ? value : Math.min(min, value);
        max = max === undefined ? value : Math.max(max, value);
      }
    }

    facets[key] = {
      rows: [...counts].map(([value, total]) => ({ value, total })),
      total: data.length,
      min,
      max,
    };
  }

  return facets;
}

More from data-table-filters

Browse all components in this library →