@tidecloak/ui-framework 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +377 -0
- package/dist/index.d.mts +2739 -0
- package/dist/index.d.ts +2739 -0
- package/dist/index.js +12869 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +12703 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/components/common/ActionButton.tsx +234 -0
- package/src/components/common/EmptyState.tsx +140 -0
- package/src/components/common/LoadingSkeleton.tsx +121 -0
- package/src/components/common/RefreshButton.tsx +127 -0
- package/src/components/common/StatusBadge.tsx +177 -0
- package/src/components/common/index.ts +31 -0
- package/src/components/data-table/DataTable.tsx +201 -0
- package/src/components/data-table/PaginatedTable.tsx +247 -0
- package/src/components/data-table/index.ts +2 -0
- package/src/components/dialogs/CollapsibleSection.tsx +184 -0
- package/src/components/dialogs/ConfirmDialog.tsx +264 -0
- package/src/components/dialogs/DetailDialog.tsx +228 -0
- package/src/components/dialogs/index.ts +3 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
- package/src/components/pages/base/LogsPageBase.tsx +581 -0
- package/src/components/pages/base/RolesPageBase.tsx +1470 -0
- package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
- package/src/components/pages/base/UsersPageBase.tsx +843 -0
- package/src/components/pages/base/index.ts +58 -0
- package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
- package/src/components/pages/connected/LogsPage.tsx +267 -0
- package/src/components/pages/connected/RolesPage.tsx +525 -0
- package/src/components/pages/connected/TemplatesPage.tsx +181 -0
- package/src/components/pages/connected/UsersPage.tsx +237 -0
- package/src/components/pages/connected/index.ts +36 -0
- package/src/components/pages/index.ts +5 -0
- package/src/components/tabs/TabsView.tsx +300 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/ui/index.tsx +1001 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useAutoRefresh.ts +119 -0
- package/src/hooks/usePagination.ts +152 -0
- package/src/hooks/useSelection.ts +81 -0
- package/src/index.ts +256 -0
- package/src/theme.ts +185 -0
- package/src/tide/index.ts +19 -0
- package/src/tide/tidePolicy.ts +270 -0
- package/src/types/index.ts +484 -0
- package/src/utils/index.ts +121 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Clock,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
XCircle,
|
|
6
|
+
Check,
|
|
7
|
+
Ban,
|
|
8
|
+
AlertCircle,
|
|
9
|
+
Info,
|
|
10
|
+
AlertTriangle,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import type { StatusType, StatusConfig } from "../../types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Status color configurations as inline styles
|
|
16
|
+
*/
|
|
17
|
+
export const STATUS_STYLES: Record<StatusType, React.CSSProperties> = {
|
|
18
|
+
pending: { backgroundColor: "#fefce8", color: "#a16207", borderColor: "#fef08a" },
|
|
19
|
+
approved: { backgroundColor: "#f0fdf4", color: "#15803d", borderColor: "#bbf7d0" },
|
|
20
|
+
rejected: { backgroundColor: "#fef2f2", color: "#b91c1c", borderColor: "#fecaca" },
|
|
21
|
+
committed: { backgroundColor: "#eff6ff", color: "#1d4ed8", borderColor: "#bfdbfe" },
|
|
22
|
+
cancelled: { backgroundColor: "#f9fafb", color: "#374151", borderColor: "#e5e7eb" },
|
|
23
|
+
mixed: { backgroundColor: "#faf5ff", color: "#7e22ce", borderColor: "#e9d5ff" },
|
|
24
|
+
ready: { backgroundColor: "#f0fdf4", color: "#15803d", borderColor: "#bbf7d0" },
|
|
25
|
+
error: { backgroundColor: "#fef2f2", color: "#b91c1c", borderColor: "#fecaca" },
|
|
26
|
+
warning: { backgroundColor: "#fff7ed", color: "#c2410c", borderColor: "#fed7aa" },
|
|
27
|
+
info: { backgroundColor: "#eff6ff", color: "#1d4ed8", borderColor: "#bfdbfe" },
|
|
28
|
+
success: { backgroundColor: "#f0fdf4", color: "#15803d", borderColor: "#bbf7d0" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Keep class names for backwards compatibility with custom Badge components
|
|
32
|
+
export const STATUS_COLORS: Record<StatusType, string> = {
|
|
33
|
+
pending: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
|
34
|
+
approved: "bg-green-50 text-green-700 border-green-200",
|
|
35
|
+
rejected: "bg-red-50 text-red-700 border-red-200",
|
|
36
|
+
committed: "bg-blue-50 text-blue-700 border-blue-200",
|
|
37
|
+
cancelled: "bg-gray-50 text-gray-700 border-gray-200",
|
|
38
|
+
mixed: "bg-purple-50 text-purple-700 border-purple-200",
|
|
39
|
+
ready: "bg-green-50 text-green-700 border-green-200",
|
|
40
|
+
error: "bg-red-50 text-red-700 border-red-200",
|
|
41
|
+
warning: "bg-orange-50 text-orange-700 border-orange-200",
|
|
42
|
+
info: "bg-blue-50 text-blue-700 border-blue-200",
|
|
43
|
+
success: "bg-green-50 text-green-700 border-green-200",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default status icons
|
|
48
|
+
*/
|
|
49
|
+
export const STATUS_ICONS: Record<StatusType, React.ComponentType<{ style?: React.CSSProperties }>> = {
|
|
50
|
+
pending: Clock,
|
|
51
|
+
approved: CheckCircle2,
|
|
52
|
+
rejected: XCircle,
|
|
53
|
+
committed: Check,
|
|
54
|
+
cancelled: Ban,
|
|
55
|
+
mixed: AlertCircle,
|
|
56
|
+
ready: CheckCircle2,
|
|
57
|
+
error: XCircle,
|
|
58
|
+
warning: AlertTriangle,
|
|
59
|
+
info: Info,
|
|
60
|
+
success: CheckCircle2,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface StatusBadgeProps {
|
|
64
|
+
/** Status type */
|
|
65
|
+
status: StatusType | string;
|
|
66
|
+
/** Custom label (defaults to capitalized status) */
|
|
67
|
+
label?: string;
|
|
68
|
+
/** Show icon */
|
|
69
|
+
showIcon?: boolean;
|
|
70
|
+
/** Custom icon override */
|
|
71
|
+
icon?: React.ReactNode;
|
|
72
|
+
/** Additional class name */
|
|
73
|
+
className?: string;
|
|
74
|
+
/** Custom status configurations for non-standard statuses */
|
|
75
|
+
customStatuses?: Record<string, { colorClass?: string; style?: React.CSSProperties; icon?: React.ComponentType<{ style?: React.CSSProperties }> }>;
|
|
76
|
+
/** Custom badge component */
|
|
77
|
+
BadgeComponent?: React.ComponentType<{
|
|
78
|
+
variant?: string;
|
|
79
|
+
className?: string;
|
|
80
|
+
style?: React.CSSProperties;
|
|
81
|
+
children: React.ReactNode;
|
|
82
|
+
}>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* StatusBadge - Displays a colored badge based on status
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```tsx
|
|
90
|
+
* <StatusBadge status="pending" />
|
|
91
|
+
* <StatusBadge status="approved" showIcon />
|
|
92
|
+
* <StatusBadge status="custom" customStatuses={{ custom: { style: { backgroundColor: "#fdf4ff", color: "#a21caf" } } }} />
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function StatusBadge({
|
|
96
|
+
status,
|
|
97
|
+
label,
|
|
98
|
+
showIcon = true,
|
|
99
|
+
icon,
|
|
100
|
+
className,
|
|
101
|
+
customStatuses = {},
|
|
102
|
+
BadgeComponent,
|
|
103
|
+
}: StatusBadgeProps) {
|
|
104
|
+
const normalizedStatus = status.toLowerCase() as StatusType;
|
|
105
|
+
|
|
106
|
+
// Get style from built-in statuses or custom statuses
|
|
107
|
+
const statusStyle =
|
|
108
|
+
STATUS_STYLES[normalizedStatus] ||
|
|
109
|
+
customStatuses[normalizedStatus]?.style ||
|
|
110
|
+
STATUS_STYLES.info;
|
|
111
|
+
|
|
112
|
+
// Get color class for custom Badge components
|
|
113
|
+
const colorClass =
|
|
114
|
+
STATUS_COLORS[normalizedStatus] ||
|
|
115
|
+
customStatuses[normalizedStatus]?.colorClass ||
|
|
116
|
+
STATUS_COLORS.info;
|
|
117
|
+
|
|
118
|
+
// Get icon
|
|
119
|
+
const IconComponent =
|
|
120
|
+
STATUS_ICONS[normalizedStatus] ||
|
|
121
|
+
customStatuses[normalizedStatus]?.icon ||
|
|
122
|
+
Info;
|
|
123
|
+
|
|
124
|
+
const displayLabel = label || status.charAt(0).toUpperCase() + status.slice(1);
|
|
125
|
+
|
|
126
|
+
const iconStyle: React.CSSProperties = { height: "0.75rem", width: "0.75rem", marginRight: "0.25rem" };
|
|
127
|
+
|
|
128
|
+
const badgeContent = (
|
|
129
|
+
<>
|
|
130
|
+
{showIcon && (
|
|
131
|
+
icon || <IconComponent style={iconStyle} />
|
|
132
|
+
)}
|
|
133
|
+
{displayLabel}
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// If custom badge component provided, use it
|
|
138
|
+
if (BadgeComponent) {
|
|
139
|
+
return (
|
|
140
|
+
<BadgeComponent variant="outline" className={className} style={statusStyle}>
|
|
141
|
+
{badgeContent}
|
|
142
|
+
</BadgeComponent>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Default badge implementation with inline styles
|
|
147
|
+
const badgeStyle: React.CSSProperties = {
|
|
148
|
+
display: "inline-flex",
|
|
149
|
+
alignItems: "center",
|
|
150
|
+
borderRadius: "9999px",
|
|
151
|
+
border: "1px solid",
|
|
152
|
+
padding: "0.125rem 0.625rem",
|
|
153
|
+
fontSize: "0.75rem",
|
|
154
|
+
fontWeight: 600,
|
|
155
|
+
...statusStyle,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<span style={badgeStyle} className={className}>
|
|
160
|
+
{badgeContent}
|
|
161
|
+
</span>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Helper to get status badge configuration
|
|
167
|
+
*/
|
|
168
|
+
export function getStatusConfig(status: string): StatusConfig {
|
|
169
|
+
const normalizedStatus = status.toLowerCase() as StatusType;
|
|
170
|
+
const Icon = STATUS_ICONS[normalizedStatus] || Info;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
label: status.charAt(0).toUpperCase() + status.slice(1),
|
|
174
|
+
variant: STATUS_COLORS[normalizedStatus] ? normalizedStatus : "info",
|
|
175
|
+
icon: <Icon style={{ height: "0.75rem", width: "0.75rem", marginRight: "0.25rem" }} />,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { RefreshButton, type RefreshButtonProps } from "./RefreshButton";
|
|
2
|
+
export {
|
|
3
|
+
StatusBadge,
|
|
4
|
+
STATUS_COLORS,
|
|
5
|
+
STATUS_ICONS,
|
|
6
|
+
getStatusConfig,
|
|
7
|
+
type StatusBadgeProps,
|
|
8
|
+
} from "./StatusBadge";
|
|
9
|
+
export {
|
|
10
|
+
ActionButton,
|
|
11
|
+
ActionButtonGroup,
|
|
12
|
+
ACTION_COLORS,
|
|
13
|
+
ACTION_ICONS,
|
|
14
|
+
type ActionButtonProps,
|
|
15
|
+
type ActionButtonGroupProps,
|
|
16
|
+
type ActionType,
|
|
17
|
+
} from "./ActionButton";
|
|
18
|
+
export {
|
|
19
|
+
EmptyState,
|
|
20
|
+
EmptyStateNoData,
|
|
21
|
+
EmptyStateNoResults,
|
|
22
|
+
EmptyStateNoFiles,
|
|
23
|
+
type EmptyStateProps,
|
|
24
|
+
} from "./EmptyState";
|
|
25
|
+
export {
|
|
26
|
+
Skeleton,
|
|
27
|
+
LoadingSkeleton,
|
|
28
|
+
TableRowSkeleton,
|
|
29
|
+
type SkeletonProps,
|
|
30
|
+
type LoadingSkeletonProps,
|
|
31
|
+
} from "./LoadingSkeleton";
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { LoadingSkeleton } from "../common/LoadingSkeleton";
|
|
3
|
+
import { EmptyState } from "../common/EmptyState";
|
|
4
|
+
import type { BaseDataItem, ColumnDef, EmptyStateConfig } from "../../types";
|
|
5
|
+
|
|
6
|
+
export interface DataTableProps<T extends BaseDataItem> {
|
|
7
|
+
/** Data items to display */
|
|
8
|
+
data: T[];
|
|
9
|
+
/** Column definitions */
|
|
10
|
+
columns: ColumnDef<T>[];
|
|
11
|
+
/** Loading state */
|
|
12
|
+
isLoading?: boolean;
|
|
13
|
+
/** Row key getter (defaults to item.id) */
|
|
14
|
+
getRowKey?: (item: T) => string;
|
|
15
|
+
/** Row click handler */
|
|
16
|
+
onRowClick?: (item: T) => void;
|
|
17
|
+
/** Selected row IDs for highlighting */
|
|
18
|
+
selectedIds?: string[];
|
|
19
|
+
/** Empty state configuration */
|
|
20
|
+
emptyState?: EmptyStateConfig;
|
|
21
|
+
/** Additional class name */
|
|
22
|
+
className?: string;
|
|
23
|
+
/** Custom table components */
|
|
24
|
+
components?: {
|
|
25
|
+
Table?: React.ComponentType<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }>;
|
|
26
|
+
TableHeader?: React.ComponentType<{ children: React.ReactNode }>;
|
|
27
|
+
TableBody?: React.ComponentType<{ children: React.ReactNode }>;
|
|
28
|
+
TableRow?: React.ComponentType<{ children: React.ReactNode; className?: string; style?: React.CSSProperties; onClick?: () => void }>;
|
|
29
|
+
TableHead?: React.ComponentType<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }>;
|
|
30
|
+
TableCell?: React.ComponentType<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }>;
|
|
31
|
+
Skeleton?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Alignment style mapping
|
|
37
|
+
*/
|
|
38
|
+
const ALIGN_STYLES: Record<string, React.CSSProperties> = {
|
|
39
|
+
left: { textAlign: "left" },
|
|
40
|
+
center: { textAlign: "center" },
|
|
41
|
+
right: { textAlign: "right" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* DataTable - Generic data table component
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* <DataTable
|
|
50
|
+
* data={items}
|
|
51
|
+
* columns={[
|
|
52
|
+
* { key: 'name', header: 'Name', cell: (item) => item.name },
|
|
53
|
+
* { key: 'status', header: 'Status', cell: (item) => <StatusBadge status={item.status} /> },
|
|
54
|
+
* { key: 'actions', header: 'Actions', cell: (item) => <ActionButton action="view" onClick={() => view(item)} /> },
|
|
55
|
+
* ]}
|
|
56
|
+
* isLoading={isLoading}
|
|
57
|
+
* emptyState={{ title: 'No items', description: 'Create an item to get started.' }}
|
|
58
|
+
* />
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function DataTable<T extends BaseDataItem>({
|
|
62
|
+
data,
|
|
63
|
+
columns,
|
|
64
|
+
isLoading = false,
|
|
65
|
+
getRowKey = (item) => item.id,
|
|
66
|
+
onRowClick,
|
|
67
|
+
selectedIds = [],
|
|
68
|
+
emptyState = { title: "No data found" },
|
|
69
|
+
className,
|
|
70
|
+
components = {},
|
|
71
|
+
}: DataTableProps<T>) {
|
|
72
|
+
// Default components (can be overridden with shadcn/ui components)
|
|
73
|
+
const Table = components.Table || DefaultTable;
|
|
74
|
+
const TableHeader = components.TableHeader || DefaultTableHeader;
|
|
75
|
+
const TableBody = components.TableBody || DefaultTableBody;
|
|
76
|
+
const TableRow = components.TableRow || DefaultTableRow;
|
|
77
|
+
const TableHead = components.TableHead || DefaultTableHead;
|
|
78
|
+
const TableCell = components.TableCell || DefaultTableCell;
|
|
79
|
+
const Skeleton = components.Skeleton;
|
|
80
|
+
|
|
81
|
+
// Show loading skeleton
|
|
82
|
+
if (isLoading && data.length === 0) {
|
|
83
|
+
return <LoadingSkeleton rows={5} type="table" SkeletonComponent={Skeleton} />;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Show empty state
|
|
87
|
+
if (!data || data.length === 0) {
|
|
88
|
+
return <EmptyState {...emptyState} />;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div style={{ overflowX: "auto" }} className={className}>
|
|
93
|
+
<Table>
|
|
94
|
+
<TableHeader>
|
|
95
|
+
<TableRow>
|
|
96
|
+
{columns.map((column) => (
|
|
97
|
+
<TableHead
|
|
98
|
+
key={column.key}
|
|
99
|
+
style={column.align ? ALIGN_STYLES[column.align] : undefined}
|
|
100
|
+
>
|
|
101
|
+
{column.header}
|
|
102
|
+
</TableHead>
|
|
103
|
+
))}
|
|
104
|
+
</TableRow>
|
|
105
|
+
</TableHeader>
|
|
106
|
+
<TableBody>
|
|
107
|
+
{data.map((item, index) => {
|
|
108
|
+
const rowKey = getRowKey(item);
|
|
109
|
+
const isSelected = selectedIds.includes(rowKey);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<TableRow
|
|
113
|
+
key={rowKey}
|
|
114
|
+
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
|
115
|
+
style={{
|
|
116
|
+
cursor: onRowClick ? "pointer" : undefined,
|
|
117
|
+
backgroundColor: isSelected ? "rgba(59, 130, 246, 0.05)" : undefined,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{columns.map((column) => (
|
|
121
|
+
<TableCell
|
|
122
|
+
key={column.key}
|
|
123
|
+
style={column.align ? ALIGN_STYLES[column.align] : undefined}
|
|
124
|
+
>
|
|
125
|
+
{column.cell(item, index)}
|
|
126
|
+
</TableCell>
|
|
127
|
+
))}
|
|
128
|
+
</TableRow>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</TableBody>
|
|
132
|
+
</Table>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Default table component implementations with inline styles
|
|
138
|
+
function DefaultTable({ children, className, style }: { children: React.ReactNode; className?: string; style?: React.CSSProperties }) {
|
|
139
|
+
return (
|
|
140
|
+
<table style={{ width: "100%", fontSize: "0.875rem", borderCollapse: "collapse", ...style }} className={className}>
|
|
141
|
+
{children}
|
|
142
|
+
</table>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function DefaultTableHeader({ children }: { children: React.ReactNode }) {
|
|
147
|
+
return <thead>{children}</thead>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function DefaultTableBody({ children }: { children: React.ReactNode }) {
|
|
151
|
+
return <tbody>{children}</tbody>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function DefaultTableRow({
|
|
155
|
+
children,
|
|
156
|
+
className,
|
|
157
|
+
style,
|
|
158
|
+
onClick,
|
|
159
|
+
}: {
|
|
160
|
+
children: React.ReactNode;
|
|
161
|
+
className?: string;
|
|
162
|
+
style?: React.CSSProperties;
|
|
163
|
+
onClick?: () => void;
|
|
164
|
+
}) {
|
|
165
|
+
return (
|
|
166
|
+
<tr
|
|
167
|
+
style={{ borderBottom: "1px solid #e5e7eb", ...style }}
|
|
168
|
+
className={className}
|
|
169
|
+
onClick={onClick}
|
|
170
|
+
>
|
|
171
|
+
{children}
|
|
172
|
+
</tr>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function DefaultTableHead({ children, className, style }: { children: React.ReactNode; className?: string; style?: React.CSSProperties }) {
|
|
177
|
+
return (
|
|
178
|
+
<th
|
|
179
|
+
style={{
|
|
180
|
+
height: "3rem",
|
|
181
|
+
padding: "0 1rem",
|
|
182
|
+
textAlign: "left",
|
|
183
|
+
verticalAlign: "middle",
|
|
184
|
+
fontWeight: 500,
|
|
185
|
+
color: "#6b7280",
|
|
186
|
+
...style,
|
|
187
|
+
}}
|
|
188
|
+
className={className}
|
|
189
|
+
>
|
|
190
|
+
{children}
|
|
191
|
+
</th>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function DefaultTableCell({ children, className, style }: { children: React.ReactNode; className?: string; style?: React.CSSProperties }) {
|
|
196
|
+
return (
|
|
197
|
+
<td style={{ padding: "1rem", verticalAlign: "middle", ...style }} className={className}>
|
|
198
|
+
{children}
|
|
199
|
+
</td>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import { cn } from "../../utils";
|
|
3
|
+
import { DataTable, type DataTableProps } from "./DataTable";
|
|
4
|
+
import { usePagination, type UsePaginationOptions } from "../../hooks/usePagination";
|
|
5
|
+
import type { BaseDataItem } from "../../types";
|
|
6
|
+
|
|
7
|
+
export interface PaginatedTableProps<T extends BaseDataItem> extends Omit<DataTableProps<T>, 'data'> {
|
|
8
|
+
/** Data items (all or paginated from server) */
|
|
9
|
+
data: T[];
|
|
10
|
+
/** Total items count (for server-side pagination) */
|
|
11
|
+
totalItems?: number;
|
|
12
|
+
/** Whether pagination is server-side */
|
|
13
|
+
serverSide?: boolean;
|
|
14
|
+
/** Current page (controlled) */
|
|
15
|
+
page?: number;
|
|
16
|
+
/** Page size (controlled) */
|
|
17
|
+
pageSize?: number;
|
|
18
|
+
/** Page change callback */
|
|
19
|
+
onPageChange?: (page: number) => void;
|
|
20
|
+
/** Page size change callback */
|
|
21
|
+
onPageSizeChange?: (pageSize: number) => void;
|
|
22
|
+
/** Whether to show pagination controls */
|
|
23
|
+
showPagination?: boolean;
|
|
24
|
+
/** Available page sizes */
|
|
25
|
+
pageSizes?: number[];
|
|
26
|
+
/** Whether to enable auto page size */
|
|
27
|
+
autoPageSize?: boolean;
|
|
28
|
+
/** Header content (rendered above table) */
|
|
29
|
+
headerContent?: React.ReactNode;
|
|
30
|
+
/** Custom components */
|
|
31
|
+
components?: DataTableProps<T>['components'] & {
|
|
32
|
+
Select?: React.ComponentType<{
|
|
33
|
+
value: string;
|
|
34
|
+
onValueChange: (value: string) => void;
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}>;
|
|
37
|
+
SelectTrigger?: React.ComponentType<{ className?: string; children: React.ReactNode }>;
|
|
38
|
+
SelectValue?: React.ComponentType<Record<string, unknown>>;
|
|
39
|
+
SelectContent?: React.ComponentType<{ children: React.ReactNode }>;
|
|
40
|
+
SelectItem?: React.ComponentType<{ value: string; children: React.ReactNode }>;
|
|
41
|
+
Button?: React.ComponentType<{
|
|
42
|
+
size?: string;
|
|
43
|
+
variant?: string;
|
|
44
|
+
onClick?: () => void;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
className?: string;
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
}>;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PaginatedTable - DataTable with built-in pagination
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* // Client-side pagination
|
|
58
|
+
* <PaginatedTable
|
|
59
|
+
* data={items}
|
|
60
|
+
* columns={columns}
|
|
61
|
+
* showPagination
|
|
62
|
+
* />
|
|
63
|
+
*
|
|
64
|
+
* // Server-side pagination
|
|
65
|
+
* <PaginatedTable
|
|
66
|
+
* data={pageData}
|
|
67
|
+
* columns={columns}
|
|
68
|
+
* serverSide
|
|
69
|
+
* totalItems={totalCount}
|
|
70
|
+
* page={page}
|
|
71
|
+
* pageSize={pageSize}
|
|
72
|
+
* onPageChange={setPage}
|
|
73
|
+
* onPageSizeChange={setPageSize}
|
|
74
|
+
* />
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function PaginatedTable<T extends BaseDataItem>({
|
|
78
|
+
data,
|
|
79
|
+
totalItems,
|
|
80
|
+
serverSide = false,
|
|
81
|
+
page: controlledPage,
|
|
82
|
+
pageSize: controlledPageSize,
|
|
83
|
+
onPageChange,
|
|
84
|
+
onPageSizeChange,
|
|
85
|
+
showPagination = true,
|
|
86
|
+
pageSizes = [25, 50, 100, 200],
|
|
87
|
+
autoPageSize = false,
|
|
88
|
+
headerContent,
|
|
89
|
+
components = {},
|
|
90
|
+
className,
|
|
91
|
+
...tableProps
|
|
92
|
+
}: PaginatedTableProps<T>) {
|
|
93
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
94
|
+
|
|
95
|
+
// Use internal pagination hook for client-side pagination
|
|
96
|
+
const pagination = usePagination({
|
|
97
|
+
initialPage: controlledPage ?? 0,
|
|
98
|
+
initialPageSize: controlledPageSize ?? 25,
|
|
99
|
+
totalItems: serverSide ? totalItems : data.length,
|
|
100
|
+
autoPageSize,
|
|
101
|
+
viewportRef: autoPageSize ? viewportRef : undefined,
|
|
102
|
+
onPageChange,
|
|
103
|
+
onPageSizeChange,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Use controlled values if provided
|
|
107
|
+
const page = controlledPage ?? pagination.page;
|
|
108
|
+
const pageSize = controlledPageSize ?? pagination.pageSize;
|
|
109
|
+
|
|
110
|
+
// Compute visible data for client-side pagination
|
|
111
|
+
const visibleData = serverSide
|
|
112
|
+
? data
|
|
113
|
+
: data.slice(page * pageSize, (page + 1) * pageSize);
|
|
114
|
+
|
|
115
|
+
const hasNextPage = serverSide
|
|
116
|
+
? data.length === pageSize
|
|
117
|
+
: (page + 1) * pageSize < data.length;
|
|
118
|
+
|
|
119
|
+
const canPrevPage = page > 0;
|
|
120
|
+
|
|
121
|
+
// Components
|
|
122
|
+
const Button = components.Button || DefaultButton;
|
|
123
|
+
const Select = components.Select;
|
|
124
|
+
const SelectTrigger = components.SelectTrigger;
|
|
125
|
+
const SelectValue = components.SelectValue;
|
|
126
|
+
const SelectContent = components.SelectContent;
|
|
127
|
+
const SelectItem = components.SelectItem;
|
|
128
|
+
|
|
129
|
+
const hasSelectComponents = Select && SelectTrigger && SelectValue && SelectContent && SelectItem;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className={cn("flex flex-col", className)}>
|
|
133
|
+
{/* Pagination Header */}
|
|
134
|
+
{showPagination && (
|
|
135
|
+
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 py-2 sm:py-3 border-b border-border">
|
|
136
|
+
<div className="text-xs sm:text-sm text-muted-foreground">
|
|
137
|
+
Page <span className="font-medium text-foreground">{page + 1}</span>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="flex items-center gap-2 sm:gap-3">
|
|
140
|
+
{/* Page Size Selector */}
|
|
141
|
+
{hasSelectComponents && (
|
|
142
|
+
<div className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground">
|
|
143
|
+
Page size
|
|
144
|
+
<Select
|
|
145
|
+
value={pagination.pageSizeSelectValue}
|
|
146
|
+
onValueChange={pagination.onPageSizeSelect}
|
|
147
|
+
>
|
|
148
|
+
<SelectTrigger className="h-8 w-[92px]">
|
|
149
|
+
<SelectValue />
|
|
150
|
+
</SelectTrigger>
|
|
151
|
+
<SelectContent>
|
|
152
|
+
{autoPageSize && <SelectItem value="auto">Auto</SelectItem>}
|
|
153
|
+
{pageSizes.map((size) => (
|
|
154
|
+
<SelectItem key={size} value={String(size)}>
|
|
155
|
+
{size}
|
|
156
|
+
</SelectItem>
|
|
157
|
+
))}
|
|
158
|
+
</SelectContent>
|
|
159
|
+
</Select>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* Page Navigation */}
|
|
164
|
+
<div className="flex items-center gap-1 sm:gap-2">
|
|
165
|
+
<Button
|
|
166
|
+
size="sm"
|
|
167
|
+
variant="outline"
|
|
168
|
+
onClick={() => {
|
|
169
|
+
const newPage = Math.max(0, page - 1);
|
|
170
|
+
if (onPageChange) onPageChange(newPage);
|
|
171
|
+
else pagination.setPage(newPage);
|
|
172
|
+
}}
|
|
173
|
+
disabled={!canPrevPage}
|
|
174
|
+
className="px-2 sm:px-3"
|
|
175
|
+
>
|
|
176
|
+
<span className="hidden sm:inline">Previous</span>
|
|
177
|
+
<span className="sm:hidden">Prev</span>
|
|
178
|
+
</Button>
|
|
179
|
+
<Button
|
|
180
|
+
size="sm"
|
|
181
|
+
variant="outline"
|
|
182
|
+
onClick={() => {
|
|
183
|
+
const newPage = page + 1;
|
|
184
|
+
if (onPageChange) onPageChange(newPage);
|
|
185
|
+
else pagination.setPage(newPage);
|
|
186
|
+
}}
|
|
187
|
+
disabled={!hasNextPage}
|
|
188
|
+
className="px-2 sm:px-3"
|
|
189
|
+
>
|
|
190
|
+
Next
|
|
191
|
+
</Button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{/* Header Content */}
|
|
198
|
+
{headerContent}
|
|
199
|
+
|
|
200
|
+
{/* Table */}
|
|
201
|
+
<div
|
|
202
|
+
ref={viewportRef}
|
|
203
|
+
className={cn(
|
|
204
|
+
autoPageSize && "h-[max(200px,calc(100vh-480px))] sm:h-[max(240px,calc(100vh-420px))] md:h-[max(320px,calc(100vh-360px))] overflow-auto"
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
<DataTable
|
|
208
|
+
{...tableProps}
|
|
209
|
+
data={visibleData}
|
|
210
|
+
components={components}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Default button
|
|
218
|
+
function DefaultButton({
|
|
219
|
+
children,
|
|
220
|
+
className,
|
|
221
|
+
disabled,
|
|
222
|
+
onClick,
|
|
223
|
+
}: {
|
|
224
|
+
size?: string;
|
|
225
|
+
variant?: string;
|
|
226
|
+
onClick?: () => void;
|
|
227
|
+
disabled?: boolean;
|
|
228
|
+
className?: string;
|
|
229
|
+
children: React.ReactNode;
|
|
230
|
+
}) {
|
|
231
|
+
return (
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={onClick}
|
|
235
|
+
disabled={disabled}
|
|
236
|
+
className={cn(
|
|
237
|
+
"inline-flex items-center justify-center rounded-md text-sm font-medium",
|
|
238
|
+
"h-8 px-3",
|
|
239
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
240
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
241
|
+
className
|
|
242
|
+
)}
|
|
243
|
+
>
|
|
244
|
+
{children}
|
|
245
|
+
</button>
|
|
246
|
+
);
|
|
247
|
+
}
|