listkit@5.0.1
Playground
Demostraciones en vivo del paquete publicado. Cada ejemplo importa la versión exacta disponible en npm.
Vista general
La lista completa: búsqueda, filtros, ordenamiento, paginación, tabla y tarjetas desde una sola configuración.
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} />;
};Todos los tipos de filtro
Texto, selección, selección múltiple, booleano, rango numérico y rango de fechas, cada uno como pill rápida y en el panel lateral.
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} />;
};Tarjetas y tema personalizados
Un tema de marca fuera de las paletas integradas y un renderizador de tarjeta que recibe el contexto de la lista.
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} />;
};Selección de filas
Casillas, acciones masivas y selección de todos los resultados coincidentes, incluido lo que recibiría un servidor.
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>
);
};Solo primitivas
Table, Cards, Pagination y SearchInput controlados por tu propio estado, sin configuración ni provider.
Rows
Showing 1–8 of 84|Page 1 of 11
1 / 11
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>
);
};