@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.
Files changed (48) hide show
  1. package/README.md +377 -0
  2. package/dist/index.d.mts +2739 -0
  3. package/dist/index.d.ts +2739 -0
  4. package/dist/index.js +12869 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +12703 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +54 -0
  9. package/src/components/common/ActionButton.tsx +234 -0
  10. package/src/components/common/EmptyState.tsx +140 -0
  11. package/src/components/common/LoadingSkeleton.tsx +121 -0
  12. package/src/components/common/RefreshButton.tsx +127 -0
  13. package/src/components/common/StatusBadge.tsx +177 -0
  14. package/src/components/common/index.ts +31 -0
  15. package/src/components/data-table/DataTable.tsx +201 -0
  16. package/src/components/data-table/PaginatedTable.tsx +247 -0
  17. package/src/components/data-table/index.ts +2 -0
  18. package/src/components/dialogs/CollapsibleSection.tsx +184 -0
  19. package/src/components/dialogs/ConfirmDialog.tsx +264 -0
  20. package/src/components/dialogs/DetailDialog.tsx +228 -0
  21. package/src/components/dialogs/index.ts +3 -0
  22. package/src/components/index.ts +5 -0
  23. package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
  24. package/src/components/pages/base/LogsPageBase.tsx +581 -0
  25. package/src/components/pages/base/RolesPageBase.tsx +1470 -0
  26. package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
  27. package/src/components/pages/base/UsersPageBase.tsx +843 -0
  28. package/src/components/pages/base/index.ts +58 -0
  29. package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
  30. package/src/components/pages/connected/LogsPage.tsx +267 -0
  31. package/src/components/pages/connected/RolesPage.tsx +525 -0
  32. package/src/components/pages/connected/TemplatesPage.tsx +181 -0
  33. package/src/components/pages/connected/UsersPage.tsx +237 -0
  34. package/src/components/pages/connected/index.ts +36 -0
  35. package/src/components/pages/index.ts +5 -0
  36. package/src/components/tabs/TabsView.tsx +300 -0
  37. package/src/components/tabs/index.ts +1 -0
  38. package/src/components/ui/index.tsx +1001 -0
  39. package/src/hooks/index.ts +3 -0
  40. package/src/hooks/useAutoRefresh.ts +119 -0
  41. package/src/hooks/usePagination.ts +152 -0
  42. package/src/hooks/useSelection.ts +81 -0
  43. package/src/index.ts +256 -0
  44. package/src/theme.ts +185 -0
  45. package/src/tide/index.ts +19 -0
  46. package/src/tide/tidePolicy.ts +270 -0
  47. package/src/types/index.ts +484 -0
  48. 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
+ }