DataTable
WhereUIตารางข้อมูลแบบสมบูรณ์บน TanStack Table v9 ครบในตัวเดียว: เรียงคอลัมน์ ค้นหารวม แบ่งหน้า เลือกแถวด้วย checkbox สถานะ loading และ empty พร้อม toolbar สำหรับปุ่มเพิ่มเติม
| สมชาย ใจดี | ผู้จัดการฝ่ายบุคคล | ฝ่ายบุคคล | 65,000 บาท | 4 มี.ค. 2562 |
| สมหญิง รักงาน | นักบัญชี | ฝ่ายการเงิน | 42,000 บาท | 15 มิ.ย. 2563 |
| ธนกฤต ศรีสุข | นักพัฒนาซอฟต์แวร์ | ฝ่ายไอที | 55,000 บาท | 11 ม.ค. 2564 |
| ปวีณา ทองดี | ผู้จัดการฝ่ายการขาย | ฝ่ายขาย | 68,000 บาท | 1 พ.ย. 2561 |
| ณัฐพล วงศ์ดี | นักวิเคราะห์ระบบ | ฝ่ายไอที | 50,000 บาท | 14 ก.พ. 2565 |
| กมลชนก แสงทอง | เจ้าหน้าที่ฝ่ายสนับสนุนลูกค้า | ฝ่ายขาย | 28,000 บาท | 22 พ.ค. 2566 |
| พิชญะ ตั้งมั่น | นักการตลาดดิจิทัล | ฝ่ายการตลาด | 38,000 บาท | 1 ส.ค. 2565 |
| อรทัย พูนสุข | ผู้จัดการฝ่ายการเงิน | ฝ่ายการเงิน | 72,000 บาท | 3 เม.ย. 2560 |
| ศิริชัย มั่นคง | วิศวกร DevOps | ฝ่ายไอที | 60,000 บาท | 6 ก.ย. 2564 |
| จิราภรณ์ สุขเจริญ | เจ้าหน้าที่ฝ่ายบุคคล | ฝ่ายบุคคล | 30,000 บาท | 16 ม.ค. 2566 |
แถวที่ 1–10 จาก 25
หน้า 1 จาก 3
ติดตั้ง
Terminal
pnpm dlx shadcn@latest add @whereui/data-tableต้องผูก registry และติดตั้ง @whereui/theme นำหน้าเสมอเมื่อติดตั้งครั้งแรก
การใช้งาน
import { DataTable, type DataTableFeatures } from "@/components/ui/data-table"import type { ColumnDef } from "@tanstack/react-table"const columns: ColumnDef<DataTableFeatures, Employee, any>[] = [ { accessorKey: "name", header: "ชื่อ-นามสกุล" }, { accessorKey: "department", header: "แผนก" },]<DataTable columns={columns} data={employees} />ตัวอย่างเพิ่มเติม
เลือกแถวด้วย checkbox
เลือกแล้ว 0 รายการ
| สมชาย ใจดี | ผู้จัดการฝ่ายบุคคล | ฝ่ายบุคคล | 65,000 บาท | 4 มี.ค. 2562 | |
| สมหญิง รักงาน | นักบัญชี | ฝ่ายการเงิน | 42,000 บาท | 15 มิ.ย. 2563 | |
| ธนกฤต ศรีสุข | นักพัฒนาซอฟต์แวร์ | ฝ่ายไอที | 55,000 บาท | 11 ม.ค. 2564 | |
| ปวีณา ทองดี | ผู้จัดการฝ่ายการขาย | ฝ่ายขาย | 68,000 บาท | 1 พ.ย. 2561 | |
| ณัฐพล วงศ์ดี | นักวิเคราะห์ระบบ | ฝ่ายไอที | 50,000 บาท | 14 ก.พ. 2565 | |
| กมลชนก แสงทอง | เจ้าหน้าที่ฝ่ายสนับสนุนลูกค้า | ฝ่ายขาย | 28,000 บาท | 22 พ.ค. 2566 | |
| พิชญะ ตั้งมั่น | นักการตลาดดิจิทัล | ฝ่ายการตลาด | 38,000 บาท | 1 ส.ค. 2565 | |
| อรทัย พูนสุข | ผู้จัดการฝ่ายการเงิน | ฝ่ายการเงิน | 72,000 บาท | 3 เม.ย. 2560 | |
| ศิริชัย มั่นคง | วิศวกร DevOps | ฝ่ายไอที | 60,000 บาท | 6 ก.ย. 2564 | |
| จิราภรณ์ สุขเจริญ | เจ้าหน้าที่ฝ่ายบุคคล | ฝ่ายบุคคล | 30,000 บาท | 16 ม.ค. 2566 |
แถวที่ 1–10 จาก 25
หน้า 1 จาก 3
สถานะ loading (skeleton)
กำลังโหลดข้อมูล…
หน้า 1 จาก 1
Props
| prop | type | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
| columns | ColumnDef<DataTableFeatures, TData, any>[] | — | นิยามคอลัมน์ตาม API ของ TanStack Table v9 เช่น accessorKey, header, cell, enableSorting |
| data | ReadonlyArray<TData> | — | ข้อมูลแถวของตาราง (generic ตามชนิดที่คอลัมน์อ้างถึง) |
| loading | boolean | false | แสดง skeleton แทนแถวข้อมูลระหว่างโหลด |
| enableSelection | boolean | false | เพิ่มคอลัมน์ checkbox สำหรับเลือกแถว (หัวคอลัมน์เลือกทั้งหน้าได้ มีสถานะ indeterminate) |
| toolbar | React.ReactNode | — | ช่องวางปุ่มเพิ่มเติมถัดจากช่องค้นหา เช่น ปุ่มเพิ่มรายการ หรือส่งออกไฟล์ |
| pageSizeOptions | number[] | [10, 25, 50] | ตัวเลือกขนาดหน้าใน dropdown ท้ายตาราง (ค่าแรกใช้เป็นขนาดหน้าเริ่มต้น) |
| getRowId | (row: TData, index: number) => string | — | กำหนด id ของแถว แนะนำให้ใส่เมื่อเปิด enableSelection เพื่อให้การเลือกติดกับข้อมูลถูกต้องแม้เรียงใหม่ |
| onSelectionChange | (selectedRows: TData[]) => void | — | เรียกเมื่อการเลือกแถวเปลี่ยน ได้อาร์เรย์ข้อมูลดิบของแถวที่เลือกทั้งหมด |
| className | string | — | class เพิ่มเติมของ element ราก (ส่งต่อ props อื่นของ div ได้ด้วย) |
ข้อควรรู้
- จอแคบ: ตารางเลื่อนแนวนอนได้ในกรอบของตัวเอง ไม่ดันหน้าเว็บให้กว้าง
- เข้าถึงได้: หัวคอลัมน์ที่เรียงได้เป็นปุ่มจริงใช้คีย์บอร์ดได้ มี aria-sort และ checkbox มี aria-label ภาษาไทย
- กด Shift ค้างพร้อมคลิกหัวคอลัมน์เพื่อเรียงหลายคอลัมน์พร้อมกัน
- ข้อความสถานะ (ค้นหา, ว่าง, แบ่งหน้า) เป็นภาษาไทยสำเร็จรูปในตัว component
ซอร์สโค้ด
components/ui/data-table.tsx
"use client"import * as React from "react"import { columnFilteringFeature, createFilteredRowModel, createPaginatedRowModel, createSortedRowModel, filterFn_includesString, globalFilteringFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, tableFeatures, useTable, FlexRender, type ColumnDef, type RowData,} from "@tanstack/react-table"import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, SearchIcon, SearchXIcon,} from "lucide-react"import { cn } from "cn"import { Button } from "@/components/ui/button"import { Checkbox } from "@/components/ui/checkbox"import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,} from "@/components/ui/empty"import { Input } from "@/components/ui/input"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { Skeleton } from "@/components/ui/skeleton"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"// ชุด features ที่ DataTable ใช้ (TanStack Table v9 ต้องประกาศ features + row model เอง)// ประกาศนอก component ตามคำแนะนำของห้องสมุดconst dataTableFeatures = tableFeatures({ columnFilteringFeature, globalFilteringFeature, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, filteredRowModel: createFilteredRowModel(), paginatedRowModel: createPaginatedRowModel(), sortedRowModel: createSortedRowModel(),})export type DataTableFeatures = typeof dataTableFeaturesexport interface DataTableProps<TData extends RowData> extends Omit<React.ComponentProps<"div">, "children"> { /** นิยามคอลัมน์ตาม API ของ TanStack Table v9 */ columns: Array<ColumnDef<DataTableFeatures, TData, any>> /** ข้อมูลแถวของตาราง */ data: ReadonlyArray<TData> /** แสดง skeleton แทนแถวข้อมูลระหว่างโหลด */ loading?: boolean /** เพิ่มคอลัมน์ checkbox เลือกแถว (เลือกทั้งหน้าได้) */ enableSelection?: boolean /** ช่องวางปุ่มเพิ่มเติมถัดจากช่องค้นหา */ toolbar?: React.ReactNode /** ตัวเลือกขนาดหน้าใน dropdown */ pageSizeOptions?: number[] /** กำหนด id ของแถว (แนะนำให้ใส่เมื่อเปิด enableSelection เพื่อให้การเลือกติดตามข้อมูลถูกต้อง) */ getRowId?: (row: TData, index: number) => string /** เรียกเมื่อการเลือกแถวเปลี่ยน ได้ข้อมูลดิบของแถวที่เลือกทั้งหมด */ onSelectionChange?: (selectedRows: TData[]) => void}function DataTable<TData extends RowData>({ columns, data, loading = false, enableSelection = false, toolbar, pageSizeOptions = [10, 25, 50], getRowId, onSelectionChange, className, ...props}: DataTableProps<TData>) { // คอลัมน์เลือกแถว (แสดงเฉพาะเมื่อ enableSelection) const selectColumn = React.useMemo< ColumnDef<DataTableFeatures, TData, any> >( () => ({ id: "select", enableSorting: false, enableGlobalFilter: false, header: ({ table: t }) => ( <Checkbox aria-label="เลือกทุกแถวในหน้านี้" checked={t.getIsAllPageRowsSelected()} indeterminate={ t.getIsSomePageRowsSelected() && !t.getIsAllPageRowsSelected() } onCheckedChange={(checked) => t.toggleAllPageRowsSelected(Boolean(checked)) } /> ), cell: ({ row }) => ( <Checkbox aria-label="เลือกแถวนี้" checked={row.getIsSelected()} onCheckedChange={(checked) => row.toggleSelected(Boolean(checked))} /> ), }), [] ) const allColumns = React.useMemo( () => (enableSelection ? [selectColumn, ...columns] : columns), [enableSelection, selectColumn, columns] ) const table = useTable({ features: dataTableFeatures, columns: allColumns, data, getRowId, // ค้นหารวมแบบไม่สนตัวพิมพ์เล็ก-ใหญ่ globalFilterFn: filterFn_includesString, initialState: { pagination: { pageIndex: 0, pageSize: pageSizeOptions[0] }, }, }) const { globalFilter, pagination, rowSelection } = table.state const rows = table.getRowModel().rows const columnCount = table.getAllLeafColumns().length const total = table.getRowCount() const pageCount = table.getPageCount() const start = total === 0 ? 0 : pagination.pageIndex * pagination.pageSize + 1 const end = Math.min((pagination.pageIndex + 1) * pagination.pageSize, total) // แจ้งแถวที่เลือกเฉพาะเมื่อสถานะการเลือกเปลี่ยน React.useEffect(() => { onSelectionChange?.( table.getSelectedRowModel().rows.map((row) => row.original) ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [rowSelection]) return ( <div data-slot="data-table" className={cn("flex w-full min-w-0 flex-col gap-3", className)} {...props} > <div data-slot="data-table-toolbar" className="flex flex-wrap items-center gap-2" > <div className="relative w-full sm:max-w-64"> <SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> <Input aria-label="ค้นหาในตาราง" placeholder="ค้นหา…" className="pl-8" value={globalFilter ?? ""} onChange={(event) => table.setGlobalFilter(event.target.value)} /> </div> {toolbar ? ( <div className="ms-auto flex flex-wrap items-center gap-2"> {toolbar} </div> ) : null} </div> <div className="overflow-hidden rounded-xl border border-border bg-surface shadow-card"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50" > {headerGroup.headers.map((header) => { const sorted = header.column.getIsSorted() return ( <TableHead key={header.id} colSpan={header.colSpan} aria-sort={ sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined } > {header.isPlaceholder ? null : header.column.getCanSort() ? ( <Button variant="ghost" size="sm" className="-ml-2 gap-1 font-medium" onClick={header.column.getToggleSortingHandler()} > <FlexRender header={header} /> {sorted === "asc" ? ( <ArrowUpIcon /> ) : sorted === "desc" ? ( <ArrowDownIcon /> ) : ( <ArrowUpDownIcon className="size-3.5 opacity-60" /> )} </Button> ) : ( <FlexRender header={header} /> )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {loading ? ( // กำลังโหลด: แสดง skeleton แทนแถวข้อมูล Array.from({ length: 6 }, (_, rowIdx) => ( <TableRow key={`loading-${rowIdx}`}> {table.getAllLeafColumns().map((column) => ( <TableCell key={column.id}> <Skeleton className="h-4 w-full max-w-24" /> </TableCell> ))} </TableRow> )) ) : rows.length === 0 ? ( <TableRow> <TableCell colSpan={columnCount} className="p-0"> <Empty className="min-h-48"> <EmptyHeader> <EmptyMedia variant="icon"> <SearchXIcon /> </EmptyMedia> <EmptyTitle>ไม่พบข้อมูล</EmptyTitle> <EmptyDescription> ลองเปลี่ยนคำค้นหา หรือเพิ่มข้อมูลใหม่แล้วลองอีกครั้ง </EmptyDescription> </EmptyHeader> </Empty> </TableCell> </TableRow> ) : ( rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined} > {row.getAllCells().map((cell) => ( <TableCell key={cell.id}> <FlexRender cell={cell} /> </TableCell> ))} </TableRow> )) )} </TableBody> </Table> </div> <div data-slot="data-table-pagination" className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" > <p className="text-sm whitespace-nowrap text-muted-foreground"> {loading ? "กำลังโหลดข้อมูล…" : total === 0 ? "ไม่พบข้อมูล" : `แถวที่ ${start}–${end} จาก ${total}`} </p> <div className="flex flex-wrap items-center gap-2"> <Select value={String(pagination.pageSize)} onValueChange={(value) => table.setPagination({ pageIndex: 0, pageSize: Number(value), }) } > <SelectTrigger size="sm" aria-label="เลือกขนาดหน้า"> <SelectValue>{(value) => `${value} แถว/หน้า`}</SelectValue> </SelectTrigger> <SelectContent> {pageSizeOptions.map((size) => ( <SelectItem key={size} value={String(size)}> {size} แถว/หน้า </SelectItem> ))} </SelectContent> </Select> <div className="flex items-center gap-1"> <Button variant="ghost" size="icon-sm" aria-label="หน้าแรก" disabled={!table.getCanPreviousPage()} onClick={table.firstPage} > <ChevronsLeftIcon /> </Button> <Button variant="ghost" size="icon-sm" aria-label="หน้าก่อนหน้า" disabled={!table.getCanPreviousPage()} onClick={table.previousPage} > <ChevronLeftIcon /> </Button> <Button variant="ghost" size="icon-sm" aria-label="หน้าถัดไป" disabled={!table.getCanNextPage()} onClick={table.nextPage} > <ChevronRightIcon /> </Button> <Button variant="ghost" size="icon-sm" aria-label="หน้าสุดท้าย" disabled={!table.getCanNextPage()} onClick={table.lastPage} > <ChevronsRightIcon /> </Button> </div> <span className="text-sm whitespace-nowrap text-muted-foreground"> หน้า {pagination.pageIndex + 1} จาก {Math.max(pageCount, 1)} </span> </div> </div> </div> )}export { DataTable }