add table

This commit is contained in:
Mohamadzadeh 2026-06-14 14:49:16 +03:30
parent d0801a5155
commit 8e8fde67bc
4 changed files with 710 additions and 1 deletions

View File

@ -7,7 +7,9 @@
"exports": {
".": "./dist/index.js"
},
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "vite build"
},
@ -19,10 +21,16 @@
"react-dom": "^18.0.0 || ^19.0.0",
"react-icons": "^5.0.0"
},
"peerDependenciesMeta": {
"@tanstack/react-table": {
"optional": true
}
},
"devDependencies": {
"@chakra-ui/react": "^2.0.0",
"@emotion/react": "^11.14.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "4.3.4",
"axios": "^1.17.0",
"keycloak-js": "^26.2.4",

View File

@ -0,0 +1,688 @@
// ----- Imports -----
import {
Box,
Checkbox,
HStack,
Spacer as ChakraSpacer,
Table as ChakraTable,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
VStack,
} from "@chakra-ui/react";
import { Pagination as UIKitPagination } from "../pagination/Pagination.jsx";
import { toFaDigits } from "../../utils/numbers.js";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
// ----- Context -----
const DataTableContext = createContext(null);
function useDataTable() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error("DataTable components must be used inside <DataTable>");
}
return context;
}
// ----- Root Component -----
function Root({
children,
data = [],
columns = [],
page = 1,
size = 10,
pagination,
onPageChange,
getRowId = (row) => String(row?.id),
enableSelection = false,
showIndex = true,
defaultSelectedIds = [],
onSelectionChange,
...props
}) {
// ----- Normalized Pagination Props -----
const normalizedPage = Number(page) || 1;
const normalizedSize = Number(size) || 10;
const totalCount = pagination?.total ?? pagination?.totalCount ?? 0;
const totalPages =
pagination?.total_pages ??
pagination?.totalPages ??
(totalCount > 0 ? Math.ceil(totalCount / normalizedSize) : 0);
// ----- TanStack Pagination State -----
const paginationState = useMemo(() => {
return {
pageIndex: Math.max(normalizedPage - 1, 0),
pageSize: normalizedSize,
};
}, [normalizedPage, normalizedSize]);
const handlePaginationChange = useCallback(
(updater) => {
const next =
typeof updater === "function" ? updater(paginationState) : updater;
const nextPage = next.pageIndex + 1;
if (nextPage !== normalizedPage) {
onPageChange?.(nextPage);
}
},
[paginationState, normalizedPage, onPageChange],
);
// ----- Selection State -----
const [selectedIds, setSelectedIdsState] = useState(defaultSelectedIds);
const setSelectedIds = useCallback(
(updater) => {
setSelectedIdsState((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater;
onSelectionChange?.(next);
return next;
});
},
[onSelectionChange],
);
// ----- Reset Selection On Page Change -----
const previousPageRef = useRef({
page: normalizedPage,
size: normalizedSize,
});
useEffect(() => {
if (!enableSelection) {
return;
}
const previous = previousPageRef.current;
const pageChanged =
previous.page !== normalizedPage || previous.size !== normalizedSize;
previousPageRef.current = {
page: normalizedPage,
size: normalizedSize,
};
if (pageChanged) {
setSelectedIds([]);
}
}, [enableSelection, normalizedPage, normalizedSize, setSelectedIds]);
// ----- Page Row Ids -----
const allPageIds = useMemo(() => {
return data
.map((item) => getRowId(item))
.filter((id) => id !== undefined && id !== null && id !== "");
}, [data, getRowId]);
const pageSelectedIds = useMemo(() => {
return selectedIds.filter((id) => allPageIds.includes(id));
}, [allPageIds, selectedIds]);
// ----- Selection Status -----
const isAllSelected =
allPageIds.length > 0 && pageSelectedIds.length === allPageIds.length;
const isIndeterminate =
pageSelectedIds.length > 0 && pageSelectedIds.length < allPageIds.length;
// ----- Selection Handlers -----
const clearSelection = useCallback(() => {
setSelectedIds([]);
}, [setSelectedIds]);
const onSelectionChangedId = useCallback(
(id) => {
setSelectedIds((prev) => {
if (!allPageIds.includes(id)) {
return prev;
}
if (prev.includes(id)) {
return prev.filter((item) => item !== id);
}
return [...prev, id];
});
},
[allPageIds, setSelectedIds],
);
const toggleRowSelection = onSelectionChangedId;
const selectAll = useCallback(() => {
setSelectedIds(allPageIds);
}, [allPageIds, setSelectedIds]);
const unselectAll = useCallback(() => {
setSelectedIds([]);
}, [setSelectedIds]);
const toggleSelectAll = useCallback(
(checked) => {
if (checked) {
selectAll();
return;
}
unselectAll();
},
[selectAll, unselectAll],
);
// ----- Table Columns -----
const tableColumns = useMemo(() => {
const result = [];
if (enableSelection) {
result.push({
id: "__selection",
header: ({ table }) => {
const meta = table.options.meta;
return (
<Checkbox
isChecked={meta.isAllSelected}
isIndeterminate={meta.isIndeterminate}
onChange={(e) => meta.toggleSelectAll(e.target.checked)}
colorScheme="purple"
size="md"
/>
);
},
cell: ({ row, table }) => {
const meta = table.options.meta;
const rowId = meta.getRowId(row.original);
return (
<Checkbox
isChecked={meta.selectedIds.includes(rowId)}
onChange={() => meta.onSelectionChangedId(rowId)}
colorScheme="purple"
size="md"
/>
);
},
meta: { isCentered: true },
});
}
if (showIndex) {
result.push({
id: "__index",
header: "ردیف",
cell: ({ row, table }) => {
const { pageIndex, pageSize } = table.getState().pagination;
const rowNumber = pageIndex * pageSize + row.index + 1;
return (
<Text fontWeight="bold" sx={{ fontVariantNumeric: "normal" }}>
{toFaDigits(rowNumber)}
</Text>
);
},
meta: { isCentered: true },
});
}
return [...result, ...columns];
}, [columns, enableSelection, showIndex]);
// ----- Table Instance -----
const table = useReactTable({
data,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getRowId,
manualPagination: true,
pageCount: totalPages,
rowCount: totalCount,
state: {
pagination: paginationState,
},
onPaginationChange: handlePaginationChange,
meta: {
selectedIds,
onSelectionChangedId,
getRowId,
allPageIds,
pageSelectedIds,
isAllSelected,
isIndeterminate,
selectAll,
unselectAll,
toggleSelectAll,
clearSelection,
totalCount,
totalPages,
},
});
// ----- Provider Value -----
const value = {
table,
data,
columns: tableColumns,
page: normalizedPage,
size: normalizedSize,
pagination,
onPageChange,
paginationState,
totalCount,
totalPages,
getRowId,
enableSelection,
selectedIds,
setSelectedIds,
clearSelection,
allPageIds,
pageSelectedIds,
isAllSelected,
isIndeterminate,
selectAll,
unselectAll,
toggleSelectAll,
onSelectionChangedId,
toggleRowSelection,
};
// ----- Root Render -----
return (
<DataTableContext.Provider value={value}>
<VStack spacing={4} w="full" {...props}>
{children}
</VStack>
</DataTableContext.Provider>
);
}
// ----- Topbar Components -----
function Topbar({ children, ...props }) {
return (
<HStack w="full" px={2} spacing={4} {...props}>
{children}
</HStack>
);
}
function Title({ children, ...props }) {
return (
<Text fontSize="lg" textAlign="start" fontWeight="bold" {...props}>
{children}
</Text>
);
}
function Spacer(props) {
return <ChakraSpacer {...props} />;
}
function Actions({ children, ...props }) {
const context = useDataTable();
return (
<HStack spacing={4} {...props}>
{typeof children === "function" ? children(context) : children}
</HStack>
);
}
// ----- Selection Components -----
function SelectionActions({ children }) {
const context = useDataTable();
if (context.selectedIds.length === 0) {
return null;
}
return typeof children === "function" ? children(context) : children;
}
function SelectAll({ children = "انتخاب همه", ...props }) {
const {
data,
enableSelection,
isAllSelected,
isIndeterminate,
toggleSelectAll,
} = useDataTable();
if (!enableSelection || data.length === 0) {
return null;
}
return (
<Checkbox
isChecked={isAllSelected}
isIndeterminate={isIndeterminate}
onChange={(e) => toggleSelectAll(e.target.checked)}
colorScheme="purple"
size="lg"
{...props}
>
{children}
</Checkbox>
);
}
function SelectedCount({ children, ...props }) {
const { selectedIds } = useDataTable();
if (selectedIds.length === 0) {
return null;
}
if (typeof children === "function") {
return children({ count: selectedIds.length, selectedIds });
}
return (
<Text fontSize="sm" color="gray.600" {...props}>
{toFaDigits(selectedIds.length)} مورد انتخاب شده
</Text>
);
}
// ----- Content Wrapper -----
function Content({ children, ...props }) {
return (
<Box
display="flex"
flexDirection="column"
w="full"
h="100%"
minH={0}
gap={4}
{...props}
>
{children}
</Box>
);
}
// ----- Table Container -----
function Table({
children,
minW = "1200px",
heightOffset = 355,
containerProps,
tableProps,
}) {
return (
<Box
flex="1"
minH={`calc(100vh - ${heightOffset}px)`}
maxH={`calc(100vh - ${heightOffset}px)`}
overflowX="auto"
overflowY="auto"
bg="white"
borderRadius="lg"
borderWidth="1px"
borderColor="gray.200"
sx={{
"::-webkit-scrollbar": {
width: "6px",
height: "6px",
},
"::-webkit-scrollbar-thumb": {
background: "#CBD5E0",
borderRadius: "8px",
},
"::-webkit-scrollbar-track": {
background: "transparent",
},
}}
{...containerProps}
>
<ChakraTable
variant="simple"
minW={minW}
sx={{
"th, td": {
borderColor: "gray.200",
whiteSpace: "nowrap",
px: 2,
},
"tbody tr:last-child td": {
borderBottom: "none",
},
}}
{...tableProps}
>
{children ?? (
<>
<DataTable.Head />
<DataTable.Body />
</>
)}
</ChakraTable>
</Box>
);
}
// ----- Table Head -----
function Head() {
const { table } = useDataTable();
return (
<Thead position="sticky" top={0} bg="white" zIndex={1}>
{table.getHeaderGroups().map((group) => (
<Tr key={group.id} borderBottomWidth="1px" borderColor="gray.200">
{group.headers.map((header) => (
<Th
key={header.id}
py={4}
borderRightWidth="1px"
borderRightColor="gray.200"
fontSize="md"
color="gray.600"
textAlign={
header.column.columnDef.meta?.isCentered ? "center" : "start"
}
verticalAlign="middle"
textTransform="none"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Th>
))}
</Tr>
))}
</Thead>
);
}
// ----- Table Body -----
function Body({ emptyText = "موردی وجود ندارد" }) {
const { table, columns } = useDataTable();
const rows = table.getRowModel().rows;
return (
<Tbody>
{rows.length > 0 ? (
rows.map((row) => (
<Tr
key={row.id}
borderBottomWidth="1px"
borderColor="gray.200"
_hover={{ bg: "blue.50" }}
transition="0.2s"
>
{row.getVisibleCells().map((cell) => (
<Td
key={cell.id}
py={3}
fontSize="md"
color="gray.700"
borderRightWidth="1px"
borderRightColor="gray.200"
textAlign={
cell.column.columnDef.meta?.isCentered ? "center" : "start"
}
verticalAlign="middle"
>
{cell.column.columnDef.cell
? flexRender(cell.column.columnDef.cell, cell.getContext())
: cell.getValue()}
</Td>
))}
</Tr>
))
) : (
<Tr>
<Td colSpan={columns.length} py={8} textAlign="center">
<Text color="gray.500">{emptyText}</Text>
</Td>
</Tr>
)}
</Tbody>
);
}
// ----- Pagination -----
function Pagination({
pagination: paginationProp,
onPageChange: onPageChangeProp,
}) {
const context = useDataTable();
const { table } = context;
const pagination = paginationProp ?? context.pagination;
const totalCount =
pagination?.total ?? pagination?.totalCount ?? context.totalCount ?? 0;
const totalPages =
pagination?.total_pages ??
pagination?.totalPages ??
context.totalPages ??
table.getPageCount() ??
0;
const currentPage = table.getState().pagination.pageIndex + 1;
const handlePageChange = (nextPage) => {
if (onPageChangeProp) {
onPageChangeProp(nextPage);
return;
}
table.setPageIndex(nextPage - 1);
};
if (!context.onPageChange && !onPageChangeProp) {
return null;
}
if (totalPages <= 0) {
return null;
}
return (
<Box mt={1} flexShrink={0}>
<UIKitPagination
currentPage={currentPage}
onPageChange={handlePageChange}
totalCount={totalCount}
totalPages={totalPages}
/>
</Box>
);
}
// ----- Compound Export -----
export const DataTable = Object.assign(Root, {
Topbar,
Title,
Spacer,
Actions,
SelectionActions,
SelectAll,
SelectedCount,
Content,
Table,
Head,
Body,
Pagination,
});

View File

@ -0,0 +1 @@
export { DataTable } from "./DataTable.jsx";

View File

@ -609,6 +609,18 @@
dependencies:
"@tanstack/query-core" "5.101.0"
"@tanstack/react-table@^8.21.3":
version "8.21.3"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b"
integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==
dependencies:
"@tanstack/table-core" "8.21.3"
"@tanstack/table-core@8.21.3":
version "8.21.3"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c"
integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==
"@types/babel__core@^7.20.5":
version "7.20.5"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"