@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
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tidecloak/ui-framework",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "TideCloak UI Framework - Reusable components for data management interfaces",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"dev": "tsup --watch",
|
|
15
|
+
"lint": "eslint src/",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": "^19.0.0",
|
|
20
|
+
"react-dom": "^19.0.0",
|
|
21
|
+
"heimdall-tide": "^0.12.46",
|
|
22
|
+
"asgard-tide": "^0.12.46"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"heimdall-tide": {
|
|
26
|
+
"optional": true
|
|
27
|
+
},
|
|
28
|
+
"asgard-tide": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@tanstack/react-query": "^5.0.0",
|
|
34
|
+
"clsx": "^2.1.0",
|
|
35
|
+
"lucide-react": "^0.400.0",
|
|
36
|
+
"tailwind-merge": "^2.2.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@tidecloak/js": "file:../tidecloak-js/packages/tidecloak-js",
|
|
40
|
+
"@types/react": "^18.2.0",
|
|
41
|
+
"@types/react-dom": "^18.2.0",
|
|
42
|
+
"heimdall-tide": "^0.12.46",
|
|
43
|
+
"asgard-tide": "^0.12.46",
|
|
44
|
+
"tsup": "^8.0.0",
|
|
45
|
+
"typescript": "^5.3.0"
|
|
46
|
+
},
|
|
47
|
+
"exports": {
|
|
48
|
+
".": {
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"import": "./dist/index.mjs",
|
|
51
|
+
"require": "./dist/index.js"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Eye,
|
|
4
|
+
Upload,
|
|
5
|
+
X,
|
|
6
|
+
Check,
|
|
7
|
+
Trash2,
|
|
8
|
+
Pencil,
|
|
9
|
+
Plus,
|
|
10
|
+
Undo2,
|
|
11
|
+
Code,
|
|
12
|
+
Download,
|
|
13
|
+
Settings,
|
|
14
|
+
MoreHorizontal,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import { cn } from "../../utils";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Action button color presets (Tailwind classes for custom Button components)
|
|
20
|
+
*/
|
|
21
|
+
export const ACTION_COLORS = {
|
|
22
|
+
view: "text-cyan-600 dark:text-cyan-400",
|
|
23
|
+
review: "text-cyan-600 dark:text-cyan-400",
|
|
24
|
+
approve: "text-green-600 dark:text-green-400",
|
|
25
|
+
commit: "text-green-600 dark:text-green-400",
|
|
26
|
+
reject: "text-red-600 dark:text-red-400",
|
|
27
|
+
delete: "text-red-600 dark:text-red-400",
|
|
28
|
+
cancel: "text-red-600 dark:text-red-400",
|
|
29
|
+
revoke: "text-orange-600 dark:text-orange-400",
|
|
30
|
+
edit: "text-blue-600 dark:text-blue-400",
|
|
31
|
+
create: "text-primary",
|
|
32
|
+
download: "text-purple-600 dark:text-purple-400",
|
|
33
|
+
settings: "text-gray-600 dark:text-gray-400",
|
|
34
|
+
default: "",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Action button inline color styles (for default button without Tailwind)
|
|
39
|
+
*/
|
|
40
|
+
const ACTION_INLINE_COLORS: Record<ActionType, { color: string; hoverBg: string }> = {
|
|
41
|
+
view: { color: "#0891b2", hoverBg: "#ecfeff" },
|
|
42
|
+
review: { color: "#0891b2", hoverBg: "#ecfeff" },
|
|
43
|
+
approve: { color: "#16a34a", hoverBg: "#dcfce7" },
|
|
44
|
+
commit: { color: "#16a34a", hoverBg: "#dcfce7" },
|
|
45
|
+
reject: { color: "#dc2626", hoverBg: "#fef2f2" },
|
|
46
|
+
delete: { color: "#dc2626", hoverBg: "#fef2f2" },
|
|
47
|
+
cancel: { color: "#dc2626", hoverBg: "#fef2f2" },
|
|
48
|
+
revoke: { color: "#ea580c", hoverBg: "#fff7ed" },
|
|
49
|
+
edit: { color: "#2563eb", hoverBg: "#eff6ff" },
|
|
50
|
+
create: { color: "#6366f1", hoverBg: "#eef2ff" },
|
|
51
|
+
download: { color: "#9333ea", hoverBg: "#faf5ff" },
|
|
52
|
+
settings: { color: "#6b7280", hoverBg: "#f9fafb" },
|
|
53
|
+
default: { color: "#6b7280", hoverBg: "#f9fafb" },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ActionType = keyof typeof ACTION_COLORS;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default icons for action types
|
|
60
|
+
*/
|
|
61
|
+
export const ACTION_ICONS: Record<ActionType, React.ComponentType<{ className?: string }>> = {
|
|
62
|
+
view: Eye,
|
|
63
|
+
review: Eye,
|
|
64
|
+
approve: Check,
|
|
65
|
+
commit: Upload,
|
|
66
|
+
reject: X,
|
|
67
|
+
delete: Trash2,
|
|
68
|
+
cancel: X,
|
|
69
|
+
revoke: Undo2,
|
|
70
|
+
edit: Pencil,
|
|
71
|
+
create: Plus,
|
|
72
|
+
download: Download,
|
|
73
|
+
settings: Settings,
|
|
74
|
+
default: MoreHorizontal,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface ActionButtonProps {
|
|
78
|
+
/** Action type for automatic styling */
|
|
79
|
+
action?: ActionType;
|
|
80
|
+
/** Click handler */
|
|
81
|
+
onClick: () => void | Promise<void>;
|
|
82
|
+
/** Custom icon override */
|
|
83
|
+
icon?: React.ReactNode;
|
|
84
|
+
/** Button label (for accessibility) */
|
|
85
|
+
label?: string;
|
|
86
|
+
/** Tooltip title */
|
|
87
|
+
title?: string;
|
|
88
|
+
/** Disabled state */
|
|
89
|
+
disabled?: boolean;
|
|
90
|
+
/** Loading state */
|
|
91
|
+
isLoading?: boolean;
|
|
92
|
+
/** Custom color class override */
|
|
93
|
+
colorClass?: string;
|
|
94
|
+
/** Additional class name */
|
|
95
|
+
className?: string;
|
|
96
|
+
/** Button size */
|
|
97
|
+
size?: "sm" | "md" | "lg";
|
|
98
|
+
/** Button variant */
|
|
99
|
+
variant?: "ghost" | "outline" | "default";
|
|
100
|
+
/** Custom button component */
|
|
101
|
+
ButtonComponent?: React.ComponentType<{
|
|
102
|
+
size?: string;
|
|
103
|
+
variant?: string;
|
|
104
|
+
onClick: () => void;
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
title?: string;
|
|
107
|
+
className?: string;
|
|
108
|
+
children: React.ReactNode;
|
|
109
|
+
}>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ActionButton - Icon button with preset action types
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```tsx
|
|
117
|
+
* <ActionButton action="view" onClick={() => handleView(item)} title="View details" />
|
|
118
|
+
* <ActionButton action="delete" onClick={() => handleDelete(item)} title="Delete item" />
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function ActionButton({
|
|
122
|
+
action = "default",
|
|
123
|
+
onClick,
|
|
124
|
+
icon,
|
|
125
|
+
label,
|
|
126
|
+
title,
|
|
127
|
+
disabled = false,
|
|
128
|
+
isLoading = false,
|
|
129
|
+
colorClass,
|
|
130
|
+
className,
|
|
131
|
+
size = "sm",
|
|
132
|
+
variant = "ghost",
|
|
133
|
+
ButtonComponent,
|
|
134
|
+
}: ActionButtonProps) {
|
|
135
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
136
|
+
const IconComponent = ACTION_ICONS[action];
|
|
137
|
+
const defaultColorClass = ACTION_COLORS[action];
|
|
138
|
+
const inlineColors = ACTION_INLINE_COLORS[action];
|
|
139
|
+
|
|
140
|
+
const iconSizeClass = {
|
|
141
|
+
sm: "h-4 w-4",
|
|
142
|
+
md: "h-5 w-5",
|
|
143
|
+
lg: "h-6 w-6",
|
|
144
|
+
}[size];
|
|
145
|
+
|
|
146
|
+
const buttonSize = {
|
|
147
|
+
sm: { height: "2rem", width: "2rem" },
|
|
148
|
+
md: { height: "2.25rem", width: "2.25rem" },
|
|
149
|
+
lg: { height: "2.5rem", width: "2.5rem" },
|
|
150
|
+
}[size];
|
|
151
|
+
|
|
152
|
+
const handleClick = async () => {
|
|
153
|
+
if (disabled || isLoading) return;
|
|
154
|
+
await onClick();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Create icon element
|
|
158
|
+
const iconElement = icon || <IconComponent className={iconSizeClass} />;
|
|
159
|
+
|
|
160
|
+
// If custom button component provided, use it with Tailwind classes
|
|
161
|
+
if (ButtonComponent) {
|
|
162
|
+
return (
|
|
163
|
+
<ButtonComponent
|
|
164
|
+
size={size}
|
|
165
|
+
variant={variant}
|
|
166
|
+
onClick={handleClick}
|
|
167
|
+
disabled={disabled || isLoading}
|
|
168
|
+
title={title || label}
|
|
169
|
+
className={cn(colorClass || defaultColorClass, className)}
|
|
170
|
+
>
|
|
171
|
+
{iconElement}
|
|
172
|
+
</ButtonComponent>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Accessible label - use label prop, title, or action name
|
|
177
|
+
const accessibleLabel = label || title || action.charAt(0).toUpperCase() + action.slice(1);
|
|
178
|
+
|
|
179
|
+
// Default button implementation with inline styles
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={handleClick}
|
|
184
|
+
disabled={disabled || isLoading}
|
|
185
|
+
title={title || label}
|
|
186
|
+
aria-label={accessibleLabel}
|
|
187
|
+
aria-busy={isLoading}
|
|
188
|
+
aria-disabled={disabled || isLoading}
|
|
189
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
190
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
191
|
+
style={{
|
|
192
|
+
display: "inline-flex",
|
|
193
|
+
alignItems: "center",
|
|
194
|
+
justifyContent: "center",
|
|
195
|
+
borderRadius: "0.375rem",
|
|
196
|
+
fontWeight: 500,
|
|
197
|
+
border: variant === "outline" ? "1px solid #e5e7eb" : "none",
|
|
198
|
+
backgroundColor: isHovered ? inlineColors.hoverBg : (variant === "default" ? inlineColors.color : "transparent"),
|
|
199
|
+
color: variant === "default" ? "#ffffff" : inlineColors.color,
|
|
200
|
+
cursor: disabled || isLoading ? "not-allowed" : "pointer",
|
|
201
|
+
opacity: disabled || isLoading ? 0.5 : 1,
|
|
202
|
+
transition: "all 0.15s ease",
|
|
203
|
+
...buttonSize,
|
|
204
|
+
}}
|
|
205
|
+
className={className}
|
|
206
|
+
>
|
|
207
|
+
{iconElement}
|
|
208
|
+
</button>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ActionButtonGroupProps {
|
|
213
|
+
children: React.ReactNode;
|
|
214
|
+
className?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* ActionButtonGroup - Container for action buttons
|
|
219
|
+
*/
|
|
220
|
+
export function ActionButtonGroup({ children, className }: ActionButtonGroupProps) {
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
style={{
|
|
224
|
+
display: "flex",
|
|
225
|
+
alignItems: "center",
|
|
226
|
+
justifyContent: "flex-end",
|
|
227
|
+
gap: "0.25rem"
|
|
228
|
+
}}
|
|
229
|
+
className={className}
|
|
230
|
+
>
|
|
231
|
+
{children}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { FileX, Search, Inbox, FolderOpen } from "lucide-react";
|
|
3
|
+
import type { EmptyStateConfig } from "../../types";
|
|
4
|
+
|
|
5
|
+
export interface EmptyStateProps extends EmptyStateConfig {
|
|
6
|
+
/** Additional class name */
|
|
7
|
+
className?: string;
|
|
8
|
+
/** Custom button component for action */
|
|
9
|
+
ButtonComponent?: React.ComponentType<{
|
|
10
|
+
onClick: () => void;
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
style?: React.CSSProperties;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* EmptyState - Displays when there's no data
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <EmptyState
|
|
23
|
+
* icon={<FileKey style={{ height: "3rem", width: "3rem" }} />}
|
|
24
|
+
* title="No pending requests"
|
|
25
|
+
* description="New requests will appear here."
|
|
26
|
+
* />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function EmptyState({
|
|
30
|
+
icon,
|
|
31
|
+
title,
|
|
32
|
+
description,
|
|
33
|
+
action,
|
|
34
|
+
className,
|
|
35
|
+
ButtonComponent,
|
|
36
|
+
}: EmptyStateProps) {
|
|
37
|
+
const defaultIcon = <Inbox style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />;
|
|
38
|
+
|
|
39
|
+
const containerStyle: React.CSSProperties = {
|
|
40
|
+
display: "flex",
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
alignItems: "center",
|
|
43
|
+
justifyContent: "center",
|
|
44
|
+
padding: "3rem 0",
|
|
45
|
+
textAlign: "center",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div style={containerStyle} className={className} role="status" aria-label={title}>
|
|
50
|
+
<div style={{ marginBottom: "1rem", color: "#6b7280" }} aria-hidden="true">
|
|
51
|
+
{icon || defaultIcon}
|
|
52
|
+
</div>
|
|
53
|
+
<h3 style={{ fontWeight: 500, margin: 0 }}>{title}</h3>
|
|
54
|
+
{description && (
|
|
55
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", marginTop: "0.25rem", maxWidth: "24rem" }}>
|
|
56
|
+
{description}
|
|
57
|
+
</p>
|
|
58
|
+
)}
|
|
59
|
+
{action && ButtonComponent && (
|
|
60
|
+
<ButtonComponent
|
|
61
|
+
onClick={action.onClick}
|
|
62
|
+
style={{ marginTop: "1rem" }}
|
|
63
|
+
>
|
|
64
|
+
{action.label}
|
|
65
|
+
</ButtonComponent>
|
|
66
|
+
)}
|
|
67
|
+
{action && !ButtonComponent && (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={action.onClick}
|
|
71
|
+
style={{
|
|
72
|
+
marginTop: "1rem",
|
|
73
|
+
display: "inline-flex",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
borderRadius: "0.375rem",
|
|
77
|
+
fontSize: "0.875rem",
|
|
78
|
+
fontWeight: 500,
|
|
79
|
+
height: "2.5rem",
|
|
80
|
+
padding: "0.5rem 1rem",
|
|
81
|
+
backgroundColor: "#3b82f6",
|
|
82
|
+
color: "#ffffff",
|
|
83
|
+
border: "none",
|
|
84
|
+
cursor: "pointer",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{action.label}
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Preset empty states for common scenarios
|
|
96
|
+
*/
|
|
97
|
+
export function EmptyStateNoData({
|
|
98
|
+
title = "No data found",
|
|
99
|
+
description,
|
|
100
|
+
...props
|
|
101
|
+
}: Partial<EmptyStateProps>) {
|
|
102
|
+
return (
|
|
103
|
+
<EmptyState
|
|
104
|
+
icon={<Inbox style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />}
|
|
105
|
+
title={title}
|
|
106
|
+
description={description}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function EmptyStateNoResults({
|
|
113
|
+
title = "No results found",
|
|
114
|
+
description = "Try adjusting your search or filters.",
|
|
115
|
+
...props
|
|
116
|
+
}: Partial<EmptyStateProps>) {
|
|
117
|
+
return (
|
|
118
|
+
<EmptyState
|
|
119
|
+
icon={<Search style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />}
|
|
120
|
+
title={title}
|
|
121
|
+
description={description}
|
|
122
|
+
{...props}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function EmptyStateNoFiles({
|
|
128
|
+
title = "No files found",
|
|
129
|
+
description,
|
|
130
|
+
...props
|
|
131
|
+
}: Partial<EmptyStateProps>) {
|
|
132
|
+
return (
|
|
133
|
+
<EmptyState
|
|
134
|
+
icon={<FolderOpen style={{ height: "3rem", width: "3rem", color: "#6b7280" }} />}
|
|
135
|
+
title={title}
|
|
136
|
+
description={description}
|
|
137
|
+
{...props}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export interface SkeletonProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
style?: React.CSSProperties;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base Skeleton component with inline styles
|
|
10
|
+
*/
|
|
11
|
+
export function Skeleton({ className, style }: SkeletonProps) {
|
|
12
|
+
const baseStyle: React.CSSProperties = {
|
|
13
|
+
backgroundColor: "#e5e7eb",
|
|
14
|
+
borderRadius: "0.375rem",
|
|
15
|
+
animation: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
|
16
|
+
...style,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return <div style={baseStyle} className={className} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoadingSkeletonProps {
|
|
23
|
+
/** Number of skeleton rows */
|
|
24
|
+
rows?: number;
|
|
25
|
+
/** Type of skeleton layout */
|
|
26
|
+
type?: "table" | "card" | "list";
|
|
27
|
+
/** Additional class name */
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Custom skeleton component */
|
|
30
|
+
SkeletonComponent?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* LoadingSkeleton - Displays loading state with skeleton placeholders
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* <LoadingSkeleton rows={5} type="table" />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function LoadingSkeleton({
|
|
42
|
+
rows = 3,
|
|
43
|
+
type = "table",
|
|
44
|
+
className,
|
|
45
|
+
SkeletonComponent = Skeleton,
|
|
46
|
+
}: LoadingSkeletonProps) {
|
|
47
|
+
if (type === "table") {
|
|
48
|
+
return (
|
|
49
|
+
<div style={{ padding: "1rem", display: "flex", flexDirection: "column", gap: "0.75rem" }} className={className} role="status" aria-label="Loading content" aria-busy="true">
|
|
50
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
51
|
+
<div key={i} style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
52
|
+
<SkeletonComponent style={{ height: "2.5rem", width: "2.5rem", borderRadius: "9999px" }} />
|
|
53
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
54
|
+
<SkeletonComponent style={{ height: "1rem", width: "8rem" }} />
|
|
55
|
+
<SkeletonComponent style={{ height: "0.75rem", width: "12rem" }} />
|
|
56
|
+
</div>
|
|
57
|
+
<SkeletonComponent style={{ height: "1.5rem", width: "4rem" }} />
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (type === "card") {
|
|
65
|
+
return (
|
|
66
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }} className={className} role="status" aria-label="Loading content" aria-busy="true">
|
|
67
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
68
|
+
<div key={i} style={{ border: "1px solid #e5e7eb", borderRadius: "0.5rem", padding: "1rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
69
|
+
<SkeletonComponent style={{ height: "1.25rem", width: "75%" }} />
|
|
70
|
+
<SkeletonComponent style={{ height: "1rem", width: "50%" }} />
|
|
71
|
+
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
72
|
+
<SkeletonComponent style={{ height: "1.5rem", width: "4rem" }} />
|
|
73
|
+
<SkeletonComponent style={{ height: "1.5rem", width: "5rem" }} />
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// list type
|
|
82
|
+
return (
|
|
83
|
+
<div style={{ padding: "1rem", display: "flex", flexDirection: "column", gap: "0.75rem" }} className={className} role="status" aria-label="Loading content" aria-busy="true">
|
|
84
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
85
|
+
<div key={i} style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
|
86
|
+
<SkeletonComponent style={{ height: "1rem", width: "10rem" }} />
|
|
87
|
+
<SkeletonComponent style={{ height: "1rem", width: "7rem" }} />
|
|
88
|
+
<SkeletonComponent style={{ height: "1rem", width: "10rem" }} />
|
|
89
|
+
<SkeletonComponent style={{ height: "1rem", width: "11rem" }} />
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* TableRowSkeleton - Single skeleton row for tables
|
|
98
|
+
*/
|
|
99
|
+
export function TableRowSkeleton({
|
|
100
|
+
columns = 4,
|
|
101
|
+
className,
|
|
102
|
+
SkeletonComponent = Skeleton,
|
|
103
|
+
}: {
|
|
104
|
+
columns?: number;
|
|
105
|
+
className?: string;
|
|
106
|
+
SkeletonComponent?: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<div style={{ display: "flex", alignItems: "center", gap: "1rem", padding: "0.75rem 1rem" }} className={className}>
|
|
110
|
+
{Array.from({ length: columns }).map((_, i) => (
|
|
111
|
+
<SkeletonComponent
|
|
112
|
+
key={i}
|
|
113
|
+
style={{
|
|
114
|
+
height: "1rem",
|
|
115
|
+
width: i === 0 ? "8rem" : i === columns - 1 ? "5rem" : "6rem",
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { RefreshCw } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export interface RefreshButtonProps {
|
|
5
|
+
onClick: () => void;
|
|
6
|
+
isRefreshing: boolean;
|
|
7
|
+
secondsRemaining: number | null;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
"data-testid"?: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Accessible label for screen readers */
|
|
13
|
+
"aria-label"?: string;
|
|
14
|
+
ButtonComponent?: React.ComponentType<{
|
|
15
|
+
variant?: string;
|
|
16
|
+
onClick: () => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
"data-testid"?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
"aria-label"?: string;
|
|
23
|
+
"aria-busy"?: boolean;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RefreshButton - Displays refresh button with auto-refresh countdown
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* <RefreshButton
|
|
34
|
+
* onClick={() => void refreshNow()}
|
|
35
|
+
* isRefreshing={isLoading}
|
|
36
|
+
* secondsRemaining={secondsRemaining}
|
|
37
|
+
* title="Refresh now"
|
|
38
|
+
* />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function RefreshButton({
|
|
42
|
+
onClick,
|
|
43
|
+
isRefreshing,
|
|
44
|
+
secondsRemaining,
|
|
45
|
+
disabled,
|
|
46
|
+
className,
|
|
47
|
+
"data-testid": dataTestId,
|
|
48
|
+
title,
|
|
49
|
+
"aria-label": ariaLabel,
|
|
50
|
+
ButtonComponent,
|
|
51
|
+
}: RefreshButtonProps) {
|
|
52
|
+
const label = isRefreshing ? "Refreshing..." : "Refresh";
|
|
53
|
+
const subtitle = secondsRemaining !== null ? `Auto refresh in ${secondsRemaining}s` : null;
|
|
54
|
+
const accessibleLabel = ariaLabel || (subtitle ? `${label}. ${subtitle}` : label);
|
|
55
|
+
|
|
56
|
+
const iconStyle: React.CSSProperties = {
|
|
57
|
+
height: "1rem",
|
|
58
|
+
width: "1rem",
|
|
59
|
+
...(isRefreshing && { animation: "spin 1s linear infinite" }),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const buttonContent = (
|
|
63
|
+
<>
|
|
64
|
+
<RefreshCw style={iconStyle} />
|
|
65
|
+
<span style={{ display: "flex", alignItems: "baseline", gap: "0.5rem" }}>
|
|
66
|
+
<span>{label}</span>
|
|
67
|
+
{subtitle && (
|
|
68
|
+
<span style={{ fontSize: "0.75rem", color: "#6b7280" }}>
|
|
69
|
+
{subtitle}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
</span>
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// If a custom button component is provided, use it
|
|
77
|
+
if (ButtonComponent) {
|
|
78
|
+
return (
|
|
79
|
+
<ButtonComponent
|
|
80
|
+
variant="outline"
|
|
81
|
+
onClick={onClick}
|
|
82
|
+
disabled={disabled || isRefreshing}
|
|
83
|
+
data-testid={dataTestId}
|
|
84
|
+
title={title || accessibleLabel}
|
|
85
|
+
aria-label={accessibleLabel}
|
|
86
|
+
aria-busy={isRefreshing}
|
|
87
|
+
style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}
|
|
88
|
+
className={className}
|
|
89
|
+
>
|
|
90
|
+
{buttonContent}
|
|
91
|
+
</ButtonComponent>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Default button implementation
|
|
96
|
+
const buttonStyle: React.CSSProperties = {
|
|
97
|
+
display: "inline-flex",
|
|
98
|
+
alignItems: "center",
|
|
99
|
+
justifyContent: "center",
|
|
100
|
+
gap: "0.5rem",
|
|
101
|
+
borderRadius: "0.375rem",
|
|
102
|
+
fontSize: "0.875rem",
|
|
103
|
+
fontWeight: 500,
|
|
104
|
+
height: "2.5rem",
|
|
105
|
+
padding: "0.5rem 1rem",
|
|
106
|
+
border: "1px solid #e5e7eb",
|
|
107
|
+
backgroundColor: "#ffffff",
|
|
108
|
+
cursor: disabled || isRefreshing ? "not-allowed" : "pointer",
|
|
109
|
+
opacity: disabled || isRefreshing ? 0.5 : 1,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={onClick}
|
|
116
|
+
disabled={disabled || isRefreshing}
|
|
117
|
+
data-testid={dataTestId}
|
|
118
|
+
title={title || accessibleLabel}
|
|
119
|
+
aria-label={accessibleLabel}
|
|
120
|
+
aria-busy={isRefreshing}
|
|
121
|
+
style={buttonStyle}
|
|
122
|
+
className={className}
|
|
123
|
+
>
|
|
124
|
+
{buttonContent}
|
|
125
|
+
</button>
|
|
126
|
+
);
|
|
127
|
+
}
|