Components
Loading preview...
Shadcn table with enhanced appearance
npx shadcn@latest add https://21st.dev/r/originui/table"use client";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useId, useMemo, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Column,
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedMinMaxValues,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
RowData,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronDown, ChevronUp, ExternalLink, Search } from "lucide-react";
declare module "@tanstack/react-table" {
//allows us to define custom properties for our columns
interface ColumnMeta<TData extends RowData, TValue> {
filterVariant?: "text" | "range" | "select";
}
}
type Item = {
id: string;
keyword: string;
intents: Array<"Informational" | "Navigational" | "Commercial" | "Transactional">;
volume: number;
cpc: number;
traffic: number;
link: string;
};
const columns: ColumnDef<Item>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
},
{
header: "Keyword",
accessorKey: "keyword",
cell: ({ row }) => <div className="font-medium">{row.getValue("keyword")}</div>,
},
{
header: "Intents",
accessorKey: "intents",
cell: ({ row }) => {
const intents = row.getValue("intents") as string[];
return (
<div className="flex gap-1">
{intents.map((intent) => {
const styles = {
Informational: "bg-indigo-400/20 text-indigo-500",
Navigational: "bg-emerald-400/20 text-emerald-500",
Commercial: "bg-amber-400/20 text-amber-500",
Transactional: "bg-rose-400/20 text-rose-500",
}[intent];
return (
<div
key={intent}
className={cn(
"flex size-5 items-center justify-center rounded text-xs font-medium",
styles,
)}
>
{intent.charAt(0)}
</div>
);
})}
</div>
);
},
enableSorting: false,
meta: {
filterVariant: "select",
},
filterFn: (row, id, filterValue) => {
const rowValue = row.getValue(id);
return Array.isArray(rowValue) && rowValue.includes(filterValue);
},
},
{
header: "Volume",
accessorKey: "volume",
cell: ({ row }) => {
const volume = parseInt(row.getValue("volume"));
return new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(volume);
},
meta: {
filterVariant: "range",
},
},
{
header: "CPC",
accessorKey: "cpc",
cell: ({ row }) => <div>${row.getValue("cpc")}</div>,
meta: {
filterVariant: "range",
},
},
{
header: "Traffic",
accessorKey: "traffic",
cell: ({ row }) => {
const traffic = parseInt(row.getValue("traffic"));
return new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(traffic);
},
meta: {
filterVariant: "range",
},
},
{
header: "Link",
accessorKey: "link",
cell: ({ row }) => (
<a
className="inline-flex items-center gap-1 hover:underline"
href={row.getValue("link")}
target="_blank"
>
{row.getValue("link")} <ExternalLink size={12} strokeWidth={2} aria-hidden="true" />
</a>
),
enableSorting: false,
},
];
const items: Item[] = [
{
id: "1",
keyword: "react components",
intents: ["Informational", "Navigational"],
volume: 2507,
cpc: 2.5,
traffic: 88,
link: "#",
},
{
id: "2",
keyword: "buy react templates",
intents: ["Commercial", "Transactional"],
volume: 1850,
cpc: 4.75,
traffic: 65,
link: "#",
},
{
id: "3",
keyword: "react ui library",
intents: ["Informational", "Commercial"],
volume: 3200,
cpc: 3.25,
traffic: 112,
link: "#",
},
{
id: "4",
keyword: "tailwind components download",
intents: ["Transactional"],
volume: 890,
cpc: 1.95,
traffic: 45,
link: "#",
},
{
id: "5",
keyword: "react dashboard template free",
intents: ["Commercial", "Transactional"],
volume: 4100,
cpc: 5.5,
traffic: 156,
link: "#",
},
{
id: "6",
keyword: "how to use react components",
intents: ["Informational"],
volume: 1200,
cpc: 1.25,
traffic: 42,
link: "#",
},
{
id: "7",
keyword: "react ui kit premium",
intents: ["Commercial", "Transactional"],
volume: 760,
cpc: 6.8,
traffic: 28,
link: "#",
},
{
id: "8",
keyword: "react component documentation",
intents: ["Informational", "Navigational"],
volume: 950,
cpc: 1.8,
traffic: 35,
link: "#",
},
];
function Component() {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([
{
id: "traffic",
desc: false,
},
]);
const table = useReactTable({
data: items,
columns,
state: {
sorting,
columnFilters,
},
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), //client-side filtering
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(), // client-side faceting
getFacetedUniqueValues: getFacetedUniqueValues(), // generate unique values for select filter/autocomplete
getFacetedMinMaxValues: getFacetedMinMaxValues(), // generate min/max values for range filter
onSortingChange: setSorting,
enableSortingRemoval: false,
});
return (
<div className="space-y-6 bg-background">
{/* Filters */}
<div className="flex flex-wrap gap-3">
{/* Search input */}
<div className="w-44">
<Filter column={table.getColumn("keyword")!} />
</div>
{/* Intents select */}
<div className="w-36">
<Filter column={table.getColumn("intents")!} />
</div>
{/* Volume inputs */}
<div className="w-36">
<Filter column={table.getColumn("volume")!} />
</div>
{/* CPC inputs */}
<div className="w-36">
<Filter column={table.getColumn("cpc")!} />
</div>
{/* Traffic inputs */}
<div className="w-36">
<Filter column={table.getColumn("traffic")!} />
</div>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
className="relative h-10 select-none border-t"
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: "none"
}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<div
className={cn(
header.column.getCanSort() &&
"flex h-full cursor-pointer select-none items-center justify-between gap-2",
)}
onClick={header.column.getToggleSortingHandler()}
onKeyDown={(e) => {
// Enhanced keyboard handling for sorting
if (header.column.getCanSort() && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
header.column.getToggleSortingHandler()?.(e);
}
}}
tabIndex={header.column.getCanSort() ? 0 : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: (
<ChevronUp
className="shrink-0 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
),
desc: (
<ChevronDown
className="shrink-0 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
),
}[header.column.getIsSorted() as string] ?? (
<span className="size-4" aria-hidden="true" />
)}
</div>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<p className="mt-4 text-center text-sm text-muted-foreground">
Data table with filters made with{" "}
<a
className="underline hover:text-foreground"
href="https://tanstack.com/table"
target="_blank"
rel="noopener noreferrer"
>
TanStack Table
</a>
</p>
</div>
);
}
function Filter({ column }: { column: Column<any, unknown> }) {
const id = useId();
const columnFilterValue = column.getFilterValue();
const { filterVariant } = column.columnDef.meta ?? {};
const columnHeader = typeof column.columnDef.header === "string" ? column.columnDef.header : "";
const sortedUniqueValues = useMemo(() => {
if (filterVariant === "range") return [];
// Get all unique values from the column
const values = Array.from(column.getFacetedUniqueValues().keys());
// If the values are arrays, flatten them and get unique items
const flattenedValues = values.reduce((acc: string[], curr) => {
if (Array.isArray(curr)) {
return [...acc, ...curr];
}
return [...acc, curr];
}, []);
// Get unique values and sort them
return Array.from(new Set(flattenedValues)).sort();
}, [column.getFacetedUniqueValues(), filterVariant]);
if (filterVariant === "range") {
return (
<div className="space-y-2">
<Label>{columnHeader}</Label>
<div className="flex">
<Input
id={`${id}-range-1`}
className="flex-1 rounded-e-none [-moz-appearance:_textfield] focus:z-10 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none"
value={(columnFilterValue as [number, number])?.[0] ?? ""}
onChange={(e) =>
column.setFilterValue((old: [number, number]) => [
e.target.value ? Number(e.target.value) : undefined,
old?.[1],
])
}
placeholder="Min"
type="number"
aria-label={`${columnHeader} min`}
/>
<Input
id={`${id}-range-2`}
className="-ms-px flex-1 rounded-s-none [-moz-appearance:_textfield] focus:z-10 [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none"
value={(columnFilterValue as [number, number])?.[1] ?? ""}
onChange={(e) =>
column.setFilterValue((old: [number, number]) => [
old?.[0],
e.target.value ? Number(e.target.value) : undefined,
])
}
placeholder="Max"
type="number"
aria-label={`${columnHeader} max`}
/>
</div>
</div>
);
}
if (filterVariant === "select") {
return (
<div className="space-y-2">
<Label htmlFor={`${id}-select`}>{columnHeader}</Label>
<Select
value={columnFilterValue?.toString() ?? "all"}
onValueChange={(value) => {
column.setFilterValue(value === "all" ? undefined : value);
}}
>
<SelectTrigger id={`${id}-select`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{sortedUniqueValues.map((value) => (
<SelectItem key={String(value)} value={String(value)}>
{String(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
return (
<div className="space-y-2">
<Label htmlFor={`${id}-input`}>{columnHeader}</Label>
<div className="relative">
<Input
id={`${id}-input`}
className="peer ps-9"
value={(columnFilterValue ?? "") as string}
onChange={(e) => column.setFilterValue(e.target.value)}
placeholder={`Search ${columnHeader.toLowerCase()}`}
type="text"
/>
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50">
<Search size={16} strokeWidth={2} />
</div>
</div>
</div>
);
}
export { Component }