listkit@5.0.1

Playground

Live demonstrations of the published package. Every example imports the exact version available on npm.

Overview

The complete list: search, filters, sorting, pagination, table and cards from one configuration.

HelloCase.tsx
import { defineListConfig, ES_LABELS } from "listkit";
import { NextListView } from "listkit/next";
import { useMemo } from "react";

import type { Locale } from "@/lib/i18n/config";

import { formatMoney, type Invoice, invoices } from "./data";

const COPY = {
  en: {
    title: "Invoices",
    subtitle:
      "84 rows, entirely in memory. Every control below is config, not code.",
    search: "Search by number or client…",
    number: "Number",
    client: "Client",
    status: "Status",
    total: "Total",
    issued: "Issued",
    recurring: "Recurring",
    filters: "Filters",
    statuses: {
      paid: "Paid",
      pending: "Pending",
      overdue: "Overdue",
      draft: "Draft",
    },
    yes: "Yes",
    no: "No",
  },
  es: {
    title: "Facturas",
    subtitle:
      "84 filas, todo en memoria. Cada control de abajo es configuración, no código.",
    search: "Buscar por número o cliente…",
    number: "Número",
    client: "Cliente",
    status: "Estado",
    total: "Total",
    issued: "Emitida",
    recurring: "Recurrente",
    filters: "Filtros",
    statuses: {
      paid: "Pagada",
      pending: "Pendiente",
      overdue: "Vencida",
      draft: "Borrador",
    },
    yes: "Sí",
    no: "No",
  },
} as const;

const STATUS_TONE: Record<Invoice["status"], string> = {
  paid: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
  pending: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
  overdue: "bg-red-500/10 text-red-600 dark:text-red-400",
  draft: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400",
};

/**
 * The whole list from one config object — the case a reader should see first.
 *
 * A client island by necessity — the list owns URL state and interaction — but
 * the page around it stays server-rendered, so the prose and metadata that
 * actually rank are in the initial HTML.
 *
 * It renders the published package from npm, not a workspace link: a demo that
 * shows unreleased behaviour is worse than no demo.
 */
export const HelloCase = ({ locale }: { locale: Locale }) => {
  const t = COPY[locale];

  const config = useMemo(
    () =>
      defineListConfig<Invoice>({
        id: "gallery-hello",
        title: t.title,
        subtitle: t.subtitle,
        pageSize: 12,
        colorTheme: "blue",
        tones: "slate",
        searchPlaceholder: t.search,
        labels: locale === "es" ? ES_LABELS : undefined,
        search: { fields: ["number", "client"] },
        filters: [
          {
            id: "main",
            title: t.filters,
            filters: [
              {
                id: "status",
                field: "status",
                label: t.status,
                type: "multi-select",
                options: (["paid", "pending", "overdue", "draft"] as const).map(
                  (value) => ({ value, label: t.statuses[value] }),
                ),
              },
              {
                id: "client",
                field: "client",
                label: t.client,
                type: "select",
                searchable: true,
                options: [...new Set(invoices.map((i) => i.client))].map(
                  (value) => ({ value, label: value }),
                ),
              },
              {
                id: "recurring",
                field: "recurring",
                label: t.recurring,
                type: "boolean",
                trueLabel: t.yes,
                falseLabel: t.no,
              },
            ],
          },
        ],
        table: {
          stickyHeader: true,
          columnControl: true,
          density: true,
          resizable: true,
          columns: [
            { key: "number", header: t.number, sortable: true, sticky: "left" },
            { key: "client", header: t.client, sortable: true, grow: true },
            {
              key: "status",
              header: t.status,
              sortable: true,
              render: (row) => (
                <span
                  className={`rounded px-2 py-0.5 text-xs font-medium ${STATUS_TONE[row.status]}`}
                >
                  {t.statuses[row.status]}
                </span>
              ),
            },
            {
              key: "total",
              header: t.total,
              align: "right",
              sortable: true,
              render: (row) => formatMoney(row.total, row.currency),
            },
            { key: "issuedAt", header: t.issued, sortable: true },
          ],
        },
      }),
    [locale, t],
  );

  return <NextListView config={config} data={invoices} />;
};

Every filter type

Text, select, multi-select, boolean, number range and date range, each as a quick pill and in the sidebar.

FiltersCase.tsx
import { defineListConfig, ES_LABELS } from "listkit";
import { NextListView } from "listkit/next";
import { useMemo } from "react";

import type { Locale } from "@/lib/i18n/config";

import {
  AREAS,
  CHANNELS,
  TAGS,
  type Ticket,
  TICKET_STATUSES,
  tickets,
} from "./fixtures";

const opt = (value: string) => ({ value, label: value });

/**
 * Every filter type listkit ships, shown twice over: as quick pills in the
 * toolbar and as a fully expanded advanced sidebar.
 *
 * `filtersAutoCollapse: false` keeps every section open, so the advanced
 * sidebar reads as a preset of all six input shapes rather than a panel whose
 * sections start closed.
 */
const COPY = {
  en: {
    subject: "Subject",
    status: "Status",
    escalated: "Escalated",
    ref: "Ref",
    requester: "Requester",
    hoursShort: "Hours",
    area: "Area",
    tags: "Tags",
    channel: "Reply channel",
    hours: "Hours open",
    satisfaction: "Satisfaction",
    created: "Created",
  },
  es: {
    subject: "Asunto",
    status: "Estado",
    escalated: "Escalado",
    ref: "Ref",
    requester: "Solicitante",
    hoursShort: "Horas",
    area: "Área",
    tags: "Etiquetas",
    channel: "Canal de respuesta",
    hours: "Horas abierto",
    satisfaction: "Satisfacción",
    created: "Creado",
  },
} as const;

export const FiltersCase = ({ locale }: { locale: Locale }) => {
  const copy = COPY[locale];
  const config = useMemo(
    () =>
      defineListConfig<Ticket>({
        id: "gallery-filters",
        title:
          locale === "es" ? "Todos los tipos de filtro" : "Every filter type",
        pageSize: 12,
        colorTheme: "blue",
        tones: "slate",
        labels: locale === "es" ? ES_LABELS : undefined,
        search: { fields: ["ref", "subject", "requester.name"] },
        defaultSort: { field: "createdAt", dir: "desc" },
        getItemKey: (t) => t.id,
        filtersTitle: locale === "es" ? "Filtrar tickets" : "Filter tickets",
        filtersAutoCollapse: false,
        filtersActiveFirst: true,
        filters: [
          {
            id: "basics",
            collapsible: false,
            title: locale === "es" ? "Básicos" : "Basics",
            filters: [
              {
                id: "subject",
                field: "subject",
                label: copy.subject,
                type: "text",
                quick: true,
              },
              {
                id: "status",
                field: "status",
                label: copy.status,
                type: "select",
                options: TICKET_STATUSES.map(opt),
                quick: true,
              },
              {
                id: "escalated",
                field: "escalated",
                label: copy.escalated,
                type: "boolean",
                quick: true,
              },
            ],
          },
          {
            id: "people",
            collapsible: false,
            title: locale === "es" ? "Personas" : "People",
            filters: [
              {
                id: "area",
                field: "requester.area",
                label: copy.area,
                type: "select",
                options: AREAS.map(opt),
                quick: true,
              },
              {
                id: "tags",
                field: "tags",
                label: copy.tags,
                type: "multi-select",
                options: TAGS.map(opt),
                quick: true,
              },
              // An array-crossing path: matches when ANY reply used the channel.
              {
                id: "channel",
                field: "replies.channel",
                label: copy.channel,
                type: "multi-select",
                options: CHANNELS.map(opt),
              },
            ],
          },
          {
            id: "ranges",
            collapsible: false,
            title: locale === "es" ? "Rangos" : "Ranges",
            filters: [
              {
                id: "hours",
                field: "hoursOpen",
                label: copy.hours,
                type: "number-range",
                quick: true,
              },
              {
                id: "satisfaction",
                field: "satisfaction",
                label: copy.satisfaction,
                type: "number-range",
                display: "slider",
                min: 0,
                max: 5,
                step: 1,
              },
              {
                id: "created",
                field: "createdAt",
                label: copy.created,
                type: "date-range",
                quick: true,
              },
            ],
          },
        ],
        table: {
          stickyHeader: true,
          columns: [
            { key: "ref", header: copy.ref, sortable: true, sticky: "left" },
            {
              key: "subject",
              header: copy.subject,
              sortable: true,
              grow: true,
            },
            { key: "status", header: copy.status },
            { key: "requester.name", header: copy.requester },
            {
              key: "hoursOpen",
              header: copy.hoursShort,
              align: "right",
              sortable: true,
            },
            { key: "createdAt", header: copy.created, sortable: true },
          ],
        },
      }),
    [locale, copy],
  );

  return <NextListView config={config} data={tickets} />;
};

Custom cards and theme

A brand theme outside the built-in palettes and a card renderer that receives the list context.

CardsCase.tsx
import { defineListConfig, ES_LABELS, ListImage } from "listkit";
import { NextListView } from "listkit/next";
import { useMemo } from "react";

import type { Locale } from "@/lib/i18n/config";

import { CATEGORIES, money, type Product, products } from "./fixtures";
import { cyanTheme } from "./theme";

const COPY = {
  en: {
    title: "Catalog",
    category: "Category",
    price: "Price",
    name: "Name",
    stock: "Stock",
  },
  es: {
    title: "Catálogo",
    category: "Categoría",
    price: "Precio",
    name: "Nombre",
    stock: "Existencias",
  },
} as const;

/** A card renderer receives the item plus the list context — theme, actions,
 * selection helpers — so nothing is prop-drilled. */
const ProductCard = (item: Product) => (
  <>
    <div className="flex items-center gap-3">
      <ListImage src={item.image} alt={item.name} width={44} height={44} />
      <div className="min-w-0">
        <h3 className="truncate font-semibold text-gray-900">{item.name}</h3>
        <p className="text-xs text-gray-500">{item.sku}</p>
      </div>
    </div>
    <div className="mt-auto flex items-center justify-between pt-3 text-sm">
      <span className="rounded-full bg-[#ecfeff] px-2.5 py-0.5 text-xs font-medium text-[#155e75]">
        {item.category}
      </span>
      <span className="font-semibold text-gray-900">{money(item.price)}</span>
    </div>
  </>
);

/** A brand theme outside the built-ins and a custom card, opening in cards view. */
export const CardsCase = ({ locale }: { locale: Locale }) => {
  const copy = COPY[locale];
  const config = useMemo(
    () =>
      defineListConfig<Product>({
        id: "gallery-cards",
        title: copy.title,
        pageSize: 12,
        colorTheme: cyanTheme,
        defaultView: "cards",
        labels: locale === "es" ? ES_LABELS : undefined,
        search: { fields: ["name", "sku", "category"] },
        getItemKey: (p) => p.id,
        card: ProductCard,
        filters: [
          {
            id: "attrs",
            filters: [
              {
                id: "category",
                field: "category",
                label: copy.category,
                type: "select",
                options: CATEGORIES.map((c) => ({ value: c, label: c })),
                quick: true,
              },
              {
                id: "price",
                field: "price",
                label: copy.price,
                type: "number-range",
                display: "slider",
                min: 0,
                max: 1000,
                step: 10,
                formatValue: money,
              },
            ],
          },
        ],
        table: {
          columns: [
            { key: "name", header: copy.name, sortable: true, grow: true },
            { key: "category", header: copy.category },
            {
              key: "price",
              header: copy.price,
              align: "right",
              sortable: true,
              render: (p) => money(p.price),
            },
            {
              key: "stock",
              header: copy.stock,
              align: "right",
              sortable: true,
            },
          ],
        },
      }),
    [locale, copy],
  );

  return <NextListView config={config} data={products} />;
};

Row selection

Checkboxes, bulk actions, and selecting every matching result — including what a server would receive.

SelectionCase.tsx
import { defineListConfig, ES_LABELS, type SelectionDetails } from "listkit";
import { NextListView } from "listkit/next";
import { useMemo, useState } from "react";

import type { Locale } from "@/lib/i18n/config";

import { formatMoney, type Invoice, invoices } from "./data";

const COPY = {
  en: {
    title: "Invoices",
    number: "Number",
    client: "Client",
    status: "Status",
    total: "Total",
  },
  es: {
    title: "Facturas",
    number: "Número",
    client: "Cliente",
    status: "Estado",
    total: "Total",
  },
} as const;

/** Row selection with bulk actions, and the details a server would receive —
 * including the "every matching result" mode that no id list can express. */
export const SelectionCase = ({ locale }: { locale: Locale }) => {
  const copy = COPY[locale];
  const [details, setDetails] = useState<SelectionDetails | null>(null);

  const config = useMemo(
    () =>
      defineListConfig<Invoice>({
        id: "gallery-selection",
        title: copy.title,
        pageSize: 8,
        colorTheme: "blue",
        labels: locale === "es" ? ES_LABELS : undefined,
        search: { fields: ["number", "client"] },
        getItemKey: (row) => row.id,
        selection: {
          onSelectionChange: (_rows, next) => setDetails(next),
        },
        table: {
          columns: [
            { key: "number", header: copy.number, sortable: true },
            { key: "client", header: copy.client, grow: true },
            { key: "status", header: copy.status },
            {
              key: "total",
              header: copy.total,
              align: "right",
              render: (r) => formatMoney(r.total, r.currency),
            },
          ],
        },
      }),
    [locale, copy],
  );

  return (
    // Container, not viewport: the shell's column is far narrower than the
    // window. @see AGENTS.md#invariants
    <div className="@container">
      <div className="grid gap-4 @3xl:grid-cols-[minmax(0,1fr)_280px]">
        <div className="min-w-0">
          <NextListView config={config} data={invoices} />
        </div>
        <aside className="bg-fd-muted min-w-0 rounded-lg p-3 font-mono text-xs">
          <p className="text-fd-muted-foreground mb-2 font-semibold">
            onSelectionChange → details
          </p>
          <pre className="overflow-x-auto break-words whitespace-pre-wrap">
            {JSON.stringify(details ?? { mode: "none", count: 0 }, null, 2)}
          </pre>
        </aside>
      </div>
    </div>
  );
};

Primitives only

Table, Cards, Pagination and SearchInput driven by your own state, with no configuration or provider.

18 of 84
1 / 11
PrimitivesCase.tsx
import { Pagination, SearchInput, Table, type ColumnDef } from "listkit";
import { useMemo, useState } from "react";

import type { Locale } from "@/lib/i18n/config";

import { formatMoney, type Invoice, invoices } from "./data";

const PAGE_SIZES = [8, 16, 24];

const COPY = {
  en: {
    number: "Number",
    client: "Client",
    status: "Status",
    total: "Total",
    search: "Search number or client…",
  },
  es: {
    number: "Número",
    client: "Cliente",
    status: "Estado",
    total: "Total",
    search: "Buscar por número o cliente…",
  },
} as const;

const columns = (locale: Locale): ColumnDef<Invoice>[] => [
  { key: "number", header: COPY[locale].number, sortable: true },
  { key: "client", header: COPY[locale].client, grow: true },
  { key: "status", header: COPY[locale].status },
  {
    key: "total",
    header: COPY[locale].total,
    align: "right",
    sortable: true,
    render: (i) => formatMoney(i.total, i.currency),
  },
];

/** No config, no adapter, no provider: the primitives over your own state.
 * Table only reports the sort click; who sorts the data is you. */
export const PrimitivesCase = ({ locale }: { locale: Locale }) => {
  const cols = useMemo(() => columns(locale), [locale]);
  const [term, setTerm] = useState("");
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
  const [sort, setSort] = useState<{ field: string; dir: "asc" | "desc" }>({
    field: "number",
    dir: "asc",
  });

  const rows = useMemo(() => {
    const needle = term.trim().toLowerCase();
    const matched = needle
      ? invoices.filter(
          (i) =>
            i.number.toLowerCase().includes(needle) ||
            i.client.toLowerCase().includes(needle),
        )
      : invoices;
    const factor = sort.dir === "desc" ? -1 : 1;
    return [...matched].sort((a, b) =>
      sort.field === "total"
        ? (a.total - b.total) * factor
        : a.number.localeCompare(b.number) * factor,
    );
  }, [term, sort]);

  const pageRows = rows.slice((page - 1) * pageSize, page * pageSize);

  return (
    <div className="flex flex-col gap-4">
      <div className="max-w-xs">
        <SearchInput
          value={term}
          onChange={(v) => {
            setTerm(v);
            setPage(1);
          }}
          placeholder={COPY[locale].search}
        />
      </div>
      <Table
        columns={cols}
        data={pageRows}
        sort={sort}
        onSort={(field) =>
          setSort((s) => ({
            field,
            dir: s.field === field && s.dir === "asc" ? "desc" : "asc",
          }))
        }
      />
      <Pagination
        currentPage={page}
        totalPages={Math.max(1, Math.ceil(rows.length / pageSize))}
        totalItems={rows.length}
        itemsPerPage={pageSize}
        onPageChange={setPage}
        pageSize={{
          value: pageSize,
          options: PAGE_SIZES,
          onChange: (size) => {
            setPageSize(size);
            setPage(1);
          },
        }}
      />
    </div>
  );
};

On this page