@schandlergarcia/sf-web-components 1.9.37 → 1.9.39

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 (109) hide show
  1. package/package.json +4 -1
  2. package/scripts/postinstall.mjs +116 -65
  3. package/src/components/library/cards/ActionList.jsx +38 -0
  4. package/src/components/library/cards/ActivityCard.jsx +56 -0
  5. package/src/components/library/cards/BaseCard.jsx +109 -0
  6. package/src/components/library/cards/CalloutCard.jsx +37 -0
  7. package/src/components/library/cards/ChartCard.jsx +105 -0
  8. package/src/components/library/cards/FeedPanel.jsx +39 -0
  9. package/src/components/library/cards/ListCard.jsx +193 -0
  10. package/src/components/library/cards/MetricCard.jsx +109 -0
  11. package/src/components/library/cards/MetricsStrip.jsx +78 -0
  12. package/src/components/library/cards/SectionCard.jsx +83 -0
  13. package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
  14. package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
  15. package/src/components/library/cards/SemanticTableCard.jsx +48 -0
  16. package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
  17. package/src/components/library/cards/StatusCard.jsx +220 -0
  18. package/src/components/library/cards/TableCard.jsx +337 -0
  19. package/src/components/library/cards/WidgetCard.jsx +90 -0
  20. package/src/components/library/charts/D3Chart.jsx +109 -0
  21. package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
  22. package/src/components/library/charts/GeoMap.jsx +293 -0
  23. package/src/components/library/chat/ChatBar.jsx +256 -0
  24. package/src/components/library/chat/ChatInput.jsx +89 -0
  25. package/src/components/library/chat/ChatMessage.jsx +178 -0
  26. package/src/components/library/chat/ChatMessageList.jsx +73 -0
  27. package/src/components/library/chat/ChatPanel.jsx +97 -0
  28. package/src/components/library/chat/ChatSuggestions.jsx +28 -0
  29. package/src/components/library/chat/ChatToolCall.jsx +100 -0
  30. package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
  31. package/src/components/library/chat/ChatWelcome.jsx +43 -0
  32. package/src/components/library/chat/index.jsx +10 -0
  33. package/src/components/library/chat/useChatState.jsx +130 -0
  34. package/src/components/library/data/DataModeProvider.jsx +67 -0
  35. package/src/components/library/data/DataModeToggle.jsx +36 -0
  36. package/src/components/library/data/chartDataProvider.jsx +61 -0
  37. package/src/components/library/data/filterUtils.jsx +141 -0
  38. package/src/components/library/data/useDataSource.jsx +33 -0
  39. package/src/components/library/data/usePageFilters.jsx +99 -0
  40. package/src/components/library/filters/FilterBar.jsx +95 -0
  41. package/src/components/library/filters/SearchFilter.jsx +36 -0
  42. package/src/components/library/filters/SelectFilter.jsx +55 -0
  43. package/src/components/library/filters/ToggleFilter.jsx +52 -0
  44. package/src/components/library/filters/index.jsx +4 -0
  45. package/src/components/library/forms/FormField.jsx +291 -0
  46. package/src/components/library/forms/FormModal.jsx +201 -0
  47. package/src/components/library/forms/FormRenderer.jsx +46 -0
  48. package/src/components/library/forms/FormSection.jsx +69 -0
  49. package/src/components/library/forms/index.jsx +5 -0
  50. package/src/components/library/forms/useFormState.jsx +165 -0
  51. package/src/components/library/heroui/Accordion.jsx +26 -0
  52. package/src/components/library/heroui/Alert.jsx +8 -0
  53. package/src/components/library/heroui/Badge.jsx +8 -0
  54. package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
  55. package/src/components/library/heroui/Button.jsx +58 -0
  56. package/src/components/library/heroui/Card.jsx +8 -0
  57. package/src/components/library/heroui/Collapsible.jsx +42 -0
  58. package/src/components/library/heroui/DatePicker.jsx +34 -0
  59. package/src/components/library/heroui/Dialog.jsx +37 -0
  60. package/src/components/library/heroui/Drawer.jsx +32 -0
  61. package/src/components/library/heroui/Dropdown.jsx +28 -0
  62. package/src/components/library/heroui/Field.jsx +51 -0
  63. package/src/components/library/heroui/Input.jsx +6 -0
  64. package/src/components/library/heroui/Kbd.jsx +8 -0
  65. package/src/components/library/heroui/Meter.jsx +8 -0
  66. package/src/components/library/heroui/Modal.jsx +32 -0
  67. package/src/components/library/heroui/Pagination.jsx +8 -0
  68. package/src/components/library/heroui/Popover.jsx +64 -0
  69. package/src/components/library/heroui/ProgressBar.jsx +8 -0
  70. package/src/components/library/heroui/ProgressCircle.jsx +8 -0
  71. package/src/components/library/heroui/ScrollShadow.jsx +8 -0
  72. package/src/components/library/heroui/Select.jsx +37 -0
  73. package/src/components/library/heroui/Separator.jsx +8 -0
  74. package/src/components/library/heroui/Skeleton.jsx +8 -0
  75. package/src/components/library/heroui/Tabs.jsx +26 -0
  76. package/src/components/library/heroui/Toast.jsx +25 -0
  77. package/src/components/library/heroui/Toggle.jsx +14 -0
  78. package/src/components/library/heroui/Tooltip.jsx +21 -0
  79. package/src/components/library/index.jsx +146 -0
  80. package/src/components/library/layout/PageContainer.jsx +11 -0
  81. package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
  82. package/src/components/library/theme/AppThemeProvider.jsx +67 -0
  83. package/src/components/library/theme/tokens.jsx +72 -0
  84. package/src/components/library/ui/Alert.jsx +80 -0
  85. package/src/components/library/ui/Avatar.jsx +44 -0
  86. package/src/components/library/ui/BreadcrumbExtras.tsx +120 -0
  87. package/src/components/library/ui/Button.jsx +61 -0
  88. package/src/components/library/ui/Card.jsx +117 -0
  89. package/src/components/library/ui/Checkbox.jsx +17 -0
  90. package/src/components/library/ui/Chip.jsx +38 -0
  91. package/src/components/library/ui/Collapsible.tsx +31 -0
  92. package/src/components/library/ui/Container.jsx +56 -0
  93. package/src/components/library/ui/DatePicker.tsx +34 -0
  94. package/src/components/library/ui/Dialog.tsx +141 -0
  95. package/src/components/library/ui/EmptyState.jsx +46 -0
  96. package/src/components/library/ui/Field.tsx +82 -0
  97. package/src/components/library/ui/FieldGroup.jsx +17 -0
  98. package/src/components/library/ui/Input.jsx +21 -0
  99. package/src/components/library/ui/Label.jsx +22 -0
  100. package/src/components/library/ui/PaginationExtras.tsx +142 -0
  101. package/src/components/library/ui/Popover.tsx +39 -0
  102. package/src/components/library/ui/Select.tsx +113 -0
  103. package/src/components/library/ui/Spinner.d.ts +10 -0
  104. package/src/components/library/ui/Spinner.jsx +64 -0
  105. package/src/components/library/ui/Text.jsx +46 -0
  106. package/src/components/library/ui/Toggle.jsx +42 -0
  107. package/src/components/workspace/ComponentRegistry.jsx +297 -0
  108. package/src/lib/index.ts +1 -0
  109. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,220 @@
1
+ import React from "react";
2
+ import BaseCard from "./BaseCard";
3
+ import UIText from "../ui/Text";
4
+
5
+ const STATUS_META = {
6
+ operational: {
7
+ label: "Operational",
8
+ dot: "bg-emerald-500",
9
+ chip: "bg-emerald-100 text-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200"
10
+ },
11
+ degraded: {
12
+ label: "Degraded",
13
+ dot: "bg-amber-500",
14
+ chip: "bg-amber-100 text-amber-900 dark:bg-amber-950/30 dark:text-amber-200"
15
+ },
16
+ outage: {
17
+ label: "Outage",
18
+ dot: "bg-rose-500",
19
+ chip: "bg-rose-100 text-rose-900 dark:bg-rose-950/30 dark:text-rose-200"
20
+ },
21
+ maintenance: {
22
+ label: "Maintenance",
23
+ dot: "bg-slate-500",
24
+ chip: "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100"
25
+ }
26
+ };
27
+
28
+ function normalizeStatus(status) {
29
+ const s = String(status ?? "operational").toLowerCase();
30
+ if (s === "ok" || s === "healthy" || s === "up") return "operational";
31
+ if (s === "warn" || s === "warning" || s === "partial") return "degraded";
32
+ if (s === "down" || s === "critical") return "outage";
33
+ return STATUS_META[s] ? s : "operational";
34
+ }
35
+
36
+ function formatTimestamp(ts) {
37
+ if (!ts) return "";
38
+ try {
39
+ const d = ts instanceof Date ? ts : new Date(ts);
40
+ if (Number.isNaN(d.getTime())) return String(ts);
41
+ return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
42
+ } catch {
43
+ return String(ts);
44
+ }
45
+ }
46
+
47
+ function StatusChip({ status }) {
48
+ const key = normalizeStatus(status);
49
+ const meta = STATUS_META[key];
50
+ return (
51
+ <span className={["inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-semibold", meta.chip].join(" ")}>
52
+ <span className={["h-2 w-2 rounded-full", meta.dot].join(" ")} aria-hidden="true" />
53
+ {meta.label}
54
+ </span>
55
+ );
56
+ }
57
+
58
+ function ItemRow({ item, showTimestamp }) {
59
+ const name = item?.title ?? item?.name ?? "Item";
60
+ const desc = item?.description;
61
+ const value = item?.value;
62
+ const unit = item?.unit;
63
+ const ts = item?.timestamp;
64
+ const status = item?.status;
65
+
66
+ return (
67
+ <div className="flex items-start justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-900">
68
+ <div className="min-w-0">
69
+ <div className="flex items-center gap-2">
70
+ <div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">{name}</div>
71
+ {status ? <StatusChip status={status} /> : null}
72
+ </div>
73
+ {desc ? (
74
+ <div className="mt-1 text-sm text-slate-600 dark:text-slate-300">{desc}</div>
75
+ ) : null}
76
+ {showTimestamp && ts ? (
77
+ <div className="mt-1 text-xs text-slate-500 dark:text-slate-400">{formatTimestamp(ts)}</div>
78
+ ) : null}
79
+ </div>
80
+ {value != null ? (
81
+ <div className="shrink-0 text-right">
82
+ <div className="text-sm font-semibold text-slate-900 dark:text-slate-50">
83
+ {String(value)}
84
+ {unit ? <span className="ml-1 text-xs font-medium text-slate-500 dark:text-slate-400">{unit}</span> : null}
85
+ </div>
86
+ </div>
87
+ ) : null}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ function Timeline({ items, showTimestamp }) {
93
+ return (
94
+ <div className="relative mt-4">
95
+ <div className="absolute left-3 top-0 h-full w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
96
+ <div className="space-y-3">
97
+ {items.map((item, idx) => {
98
+ const key = item?.id ?? idx;
99
+ const status = item?.status;
100
+ const meta = STATUS_META[normalizeStatus(status)];
101
+ return (
102
+ <div key={key} className="relative pl-8">
103
+ <div className={["absolute left-[9px] top-3 h-2.5 w-2.5 rounded-full", meta.dot].join(" ")} aria-hidden="true" />
104
+ <ItemRow item={item} showTimestamp={showTimestamp} />
105
+ </div>
106
+ );
107
+ })}
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ export default function StatusCard({
114
+ title,
115
+ subtitle,
116
+ status = "operational",
117
+ items = [],
118
+ layout = "list",
119
+ showProgress = false,
120
+ showTimestamp = true,
121
+ actions,
122
+ loading = false,
123
+ error,
124
+ emptyMessage = "No status items.",
125
+ maxBodyHeight,
126
+ ...cardProps
127
+ }) {
128
+ const overall = normalizeStatus(status);
129
+ const header = (
130
+ <div className="flex items-start justify-between gap-3">
131
+ <div className="min-w-0">
132
+ {title ? (
133
+ <UIText as="div" size="sm" weight="medium">
134
+ {title}
135
+ </UIText>
136
+ ) : null}
137
+ {subtitle ? (
138
+ <UIText as="div" size="xs" muted className="mt-1">
139
+ {subtitle}
140
+ </UIText>
141
+ ) : null}
142
+ </div>
143
+ <div className="flex items-center gap-2">
144
+ <StatusChip status={overall} />
145
+ {actions ? <div className="ml-1">{actions}</div> : null}
146
+ </div>
147
+ </div>
148
+ );
149
+
150
+ if (error) {
151
+ return (
152
+ <BaseCard
153
+ variant="status"
154
+ header={header}
155
+ body={
156
+ <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100">
157
+ {String(error)}
158
+ </div>
159
+ }
160
+ {...cardProps}
161
+ />
162
+ );
163
+ }
164
+
165
+ const total = items.length;
166
+ const okCount = items.filter((it) => normalizeStatus(it?.status) === "operational").length;
167
+ const percentOk = total > 0 ? Math.round((okCount / total) * 100) : 100;
168
+
169
+ const scrollStyle = maxBodyHeight ? { maxHeight: maxBodyHeight, overflowY: "auto" } : {};
170
+
171
+ const itemsContent = loading ? (
172
+ <div className="space-y-3">
173
+ {Array.from({ length: 3 }).map((_, i) => (
174
+ <div key={i} className="flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-900">
175
+ <div className="h-4 w-1/4 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
176
+ <div className="h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
177
+ </div>
178
+ ))}
179
+ </div>
180
+ ) : items.length === 0 ? (
181
+ <div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-950/30 dark:text-slate-300">
182
+ {emptyMessage}
183
+ </div>
184
+ ) : layout === "grid" ? (
185
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
186
+ {items.map((it, idx) => (
187
+ <ItemRow key={it?.id ?? idx} item={it} showTimestamp={showTimestamp} />
188
+ ))}
189
+ </div>
190
+ ) : layout === "timeline" ? (
191
+ <Timeline items={items} showTimestamp={showTimestamp} />
192
+ ) : (
193
+ <div className="space-y-3">
194
+ {items.map((it, idx) => (
195
+ <ItemRow key={it?.id ?? idx} item={it} showTimestamp={showTimestamp} />
196
+ ))}
197
+ </div>
198
+ );
199
+
200
+ const body = (
201
+ <div className="mt-4">
202
+ {showProgress && total > 0 ? (
203
+ <div className="mb-4">
204
+ <div className="flex items-center justify-between text-xs text-slate-600 dark:text-slate-300">
205
+ <span>{okCount} / {total} operational</span>
206
+ <span>{percentOk}%</span>
207
+ </div>
208
+ <div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
209
+ <div className="h-full bg-emerald-500" style={{ width: `${percentOk}%` }} />
210
+ </div>
211
+ </div>
212
+ ) : null}
213
+ <div style={scrollStyle}>{itemsContent}</div>
214
+ </div>
215
+ );
216
+
217
+ return <BaseCard variant="status" header={header} body={body} {...cardProps} />;
218
+ }
219
+
220
+
@@ -0,0 +1,337 @@
1
+ import React from "react";
2
+ import BaseCard from "./BaseCard";
3
+ import UIInput from "../ui/Input";
4
+ import UIButton from "../ui/Button";
5
+ import UIText from "../ui/Text";
6
+
7
+ function defaultTypeFormat(type, value) {
8
+ if (value == null) return "";
9
+ if (!type) return String(value);
10
+
11
+ if (type === "currency") {
12
+ const n = Number(value);
13
+ if (Number.isFinite(n)) return n.toLocaleString(undefined, { style: "currency", currency: "USD" });
14
+ }
15
+ if (type === "percentage") {
16
+ const n = Number(value);
17
+ if (Number.isFinite(n)) return `${n}%`;
18
+ }
19
+ if (type === "number") {
20
+ const n = Number(value);
21
+ if (Number.isFinite(n)) return n.toLocaleString();
22
+ }
23
+ return String(value);
24
+ }
25
+
26
+ function stableSort(data, compare) {
27
+ return data
28
+ .map((item, idx) => ({ item, idx }))
29
+ .sort((a, b) => {
30
+ const c = compare(a.item, b.item);
31
+ return c !== 0 ? c : a.idx - b.idx;
32
+ })
33
+ .map((x) => x.item);
34
+ }
35
+
36
+ export default function TableCard({
37
+ data = [],
38
+ columns = [],
39
+ title,
40
+ subtitle,
41
+ searchable = false,
42
+ sortable = false,
43
+ paginated = false,
44
+ selectable = false,
45
+ pageSize = 10,
46
+ actions,
47
+ rowActions,
48
+ onRowSelect,
49
+ onSort,
50
+ onSearch,
51
+ loading = false,
52
+ error,
53
+ emptyMessage = "No results.",
54
+ simulateInitialLoad = false,
55
+ minInitialDelayMs = 350,
56
+ maxInitialDelayMs = 900,
57
+ ...cardProps
58
+ }) {
59
+ const [query, setQuery] = React.useState("");
60
+ const [sortKey, setSortKey] = React.useState(null);
61
+ const [sortDir, setSortDir] = React.useState("asc");
62
+ const [page, setPage] = React.useState(1);
63
+ const [selectedId, setSelectedId] = React.useState(null);
64
+ const [simLoading, setSimLoading] = React.useState(simulateInitialLoad);
65
+
66
+ React.useEffect(() => {
67
+ if (!simulateInitialLoad) return;
68
+ const delay =
69
+ Math.floor(Math.random() * (maxInitialDelayMs - minInitialDelayMs + 1)) + minInitialDelayMs;
70
+ const t = setTimeout(() => setSimLoading(false), delay);
71
+ return () => clearTimeout(t);
72
+ }, [simulateInitialLoad, minInitialDelayMs, maxInitialDelayMs]);
73
+
74
+ const effectiveLoading = loading || simLoading;
75
+
76
+ const filtered = React.useMemo(() => {
77
+ if (!searchable || !query.trim()) return data;
78
+ const q = query.trim().toLowerCase();
79
+ return data.filter((row) =>
80
+ columns.some((col) => {
81
+ const raw = row?.[col.key];
82
+ if (raw == null) return false;
83
+ return String(raw).toLowerCase().includes(q);
84
+ })
85
+ );
86
+ }, [data, columns, query, searchable]);
87
+
88
+ const sorted = React.useMemo(() => {
89
+ if (!sortable || !sortKey) return filtered;
90
+ const col = columns.find((c) => c.key === sortKey);
91
+ if (!col) return filtered;
92
+ const dir = sortDir === "desc" ? -1 : 1;
93
+ return stableSort(filtered, (a, b) => {
94
+ const av = a?.[sortKey];
95
+ const bv = b?.[sortKey];
96
+ if (av == null && bv == null) return 0;
97
+ if (av == null) return -1 * dir;
98
+ if (bv == null) return 1 * dir;
99
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
100
+ return String(av).localeCompare(String(bv)) * dir;
101
+ });
102
+ }, [filtered, sortable, sortKey, sortDir, columns]);
103
+
104
+ const total = sorted.length;
105
+ const totalPages = paginated ? Math.max(1, Math.ceil(total / pageSize)) : 1;
106
+ const clampedPage = Math.min(page, totalPages);
107
+
108
+ const pageData = React.useMemo(() => {
109
+ if (!paginated) return sorted;
110
+ const start = (clampedPage - 1) * pageSize;
111
+ return sorted.slice(start, start + pageSize);
112
+ }, [sorted, paginated, clampedPage, pageSize]);
113
+
114
+ React.useEffect(() => {
115
+ if (page !== clampedPage) setPage(clampedPage);
116
+ }, [page, clampedPage]);
117
+
118
+ const header = (
119
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
120
+ <div className="min-w-0">
121
+ {title ? (
122
+ <UIText as="div" size="sm" weight="medium">
123
+ {title}
124
+ </UIText>
125
+ ) : null}
126
+ {subtitle ? (
127
+ <UIText as="div" size="xs" muted className="mt-1">
128
+ {subtitle}
129
+ </UIText>
130
+ ) : null}
131
+ </div>
132
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
133
+ {searchable ? (
134
+ <div className="w-full sm:w-64">
135
+ <UIInput
136
+ value={query}
137
+ onChange={(e) => {
138
+ setQuery(e.target.value);
139
+ setPage(1);
140
+ onSearch?.(e.target.value);
141
+ }}
142
+ placeholder="Search…"
143
+ aria-label="Search table"
144
+ />
145
+ </div>
146
+ ) : null}
147
+ {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
148
+ </div>
149
+ </div>
150
+ );
151
+
152
+ if (error) {
153
+ return (
154
+ <BaseCard
155
+ header={header}
156
+ body={
157
+ <div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100">
158
+ {String(error)}
159
+ </div>
160
+ }
161
+ {...cardProps}
162
+ />
163
+ );
164
+ }
165
+
166
+ const canSort = (col) => sortable && (col.sortable ?? true);
167
+
168
+ return (
169
+ <BaseCard
170
+ variant="table"
171
+ header={header}
172
+ body={
173
+ <div className="mt-4 overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800">
174
+ <div className="w-full overflow-x-auto">
175
+ <table className="min-w-full border-separate border-spacing-0">
176
+ <thead className="bg-slate-50 dark:bg-slate-950/30">
177
+ <tr>
178
+ {columns.map((col) => {
179
+ const active = sortKey === col.key;
180
+ return (
181
+ <th
182
+ key={col.key}
183
+ scope="col"
184
+ className={[
185
+ "whitespace-nowrap border-b border-slate-200 px-4 py-3 text-left text-xs font-semibold text-slate-600 dark:border-slate-800 dark:text-slate-300",
186
+ col.className ?? ""
187
+ ]
188
+ .filter(Boolean)
189
+ .join(" ")}
190
+ >
191
+ {canSort(col) ? (
192
+ <button
193
+ type="button"
194
+ className="inline-flex items-center gap-2 hover:text-slate-900 dark:hover:text-slate-50"
195
+ onClick={() => {
196
+ const nextDir = active && sortDir === "asc" ? "desc" : "asc";
197
+ setSortKey(col.key);
198
+ setSortDir(nextDir);
199
+ onSort?.({ key: col.key, direction: nextDir });
200
+ }}
201
+ >
202
+ <span>{col.label}</span>
203
+ <span className="text-[10px] opacity-70">
204
+ {active ? (sortDir === "asc" ? "▲" : "▼") : "↕"}
205
+ </span>
206
+ </button>
207
+ ) : (
208
+ col.label
209
+ )}
210
+ </th>
211
+ );
212
+ })}
213
+ {rowActions ? (
214
+ <th className="border-b border-slate-200 px-4 py-3 text-right text-xs font-semibold text-slate-600 dark:border-slate-800 dark:text-slate-300">
215
+ Actions
216
+ </th>
217
+ ) : null}
218
+ </tr>
219
+ </thead>
220
+
221
+ <tbody className="bg-white dark:bg-slate-900">
222
+ {effectiveLoading ? (
223
+ Array.from({ length: Math.min(pageSize, 6) }).map((_, idx) => (
224
+ <tr key={idx}>
225
+ {columns.map((col) => (
226
+ <td
227
+ key={col.key}
228
+ className="border-b border-slate-200 px-4 py-3 dark:border-slate-800"
229
+ >
230
+ <div className="h-4 w-3/4 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
231
+ </td>
232
+ ))}
233
+ {rowActions ? (
234
+ <td className="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
235
+ <div className="h-4 w-10 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
236
+ </td>
237
+ ) : null}
238
+ </tr>
239
+ ))
240
+ ) : pageData.length === 0 ? (
241
+ <tr>
242
+ <td
243
+ colSpan={columns.length + (rowActions ? 1 : 0)}
244
+ className="px-4 py-10 text-center text-sm text-slate-600 dark:text-slate-300"
245
+ >
246
+ {emptyMessage}
247
+ </td>
248
+ </tr>
249
+ ) : (
250
+ pageData.map((row, idx) => {
251
+ const rowId = row?.id ?? idx;
252
+ const selected = selectable && selectedId === rowId;
253
+ return (
254
+ <tr
255
+ key={rowId}
256
+ className={[
257
+ "group",
258
+ selectable ? "cursor-pointer" : "",
259
+ selected ? "bg-brand-50 dark:bg-brand-950/30" : ""
260
+ ]
261
+ .filter(Boolean)
262
+ .join(" ")}
263
+ onClick={() => {
264
+ if (!selectable) return;
265
+ setSelectedId(rowId);
266
+ onRowSelect?.(row);
267
+ }}
268
+ >
269
+ {columns.map((col) => {
270
+ const raw = row?.[col.key];
271
+ const content = col.render ? col.render(raw, row) : defaultTypeFormat(col.type, raw);
272
+ return (
273
+ <td
274
+ key={col.key}
275
+ className={[
276
+ "border-b border-slate-200 px-4 py-3 text-sm text-slate-700 dark:border-slate-800 dark:text-slate-200",
277
+ col.mono ? "font-mono text-[13px]" : "",
278
+ col.className ?? ""
279
+ ]
280
+ .filter(Boolean)
281
+ .join(" ")}
282
+ >
283
+ {content}
284
+ </td>
285
+ );
286
+ })}
287
+ {rowActions ? (
288
+ <td className="border-b border-slate-200 px-4 py-3 text-right text-sm dark:border-slate-800">
289
+ <div className="inline-flex items-center justify-end gap-2 opacity-100 sm:opacity-0 sm:group-hover:opacity-100">
290
+ {rowActions(row)}
291
+ </div>
292
+ </td>
293
+ ) : null}
294
+ </tr>
295
+ );
296
+ })
297
+ )}
298
+ </tbody>
299
+ </table>
300
+ </div>
301
+
302
+ {paginated && !effectiveLoading ? (
303
+ <div className="flex flex-col gap-2 border-t border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/30 sm:flex-row sm:items-center sm:justify-between">
304
+ <div className="text-xs text-slate-600 dark:text-slate-300">
305
+ {total === 0 ? "0 results" : `Showing ${(clampedPage - 1) * pageSize + 1}-${Math.min(clampedPage * pageSize, total)} of ${total}`}
306
+ </div>
307
+ <div className="flex items-center gap-2">
308
+ <UIButton
309
+ variant="outline"
310
+ size="sm"
311
+ disabled={clampedPage <= 1}
312
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
313
+ >
314
+ Prev
315
+ </UIButton>
316
+ <div className="text-xs text-slate-600 dark:text-slate-300">
317
+ Page {clampedPage} / {totalPages}
318
+ </div>
319
+ <UIButton
320
+ variant="outline"
321
+ size="sm"
322
+ disabled={clampedPage >= totalPages}
323
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
324
+ >
325
+ Next
326
+ </UIButton>
327
+ </div>
328
+ </div>
329
+ ) : null}
330
+ </div>
331
+ }
332
+ {...cardProps}
333
+ />
334
+ );
335
+ }
336
+
337
+
@@ -0,0 +1,90 @@
1
+ import React from "react";
2
+ import BaseCard from "./BaseCard";
3
+ import UIText from "../ui/Text";
4
+
5
+ function Section({ title, actions, content, divided }) {
6
+ return (
7
+ <div className={divided ? "border-t border-slate-200 pt-4 dark:border-slate-800" : ""}>
8
+ {(title || actions) ? (
9
+ <div className="mb-3 flex items-start justify-between gap-3">
10
+ {title ? (
11
+ <UIText as="div" size="sm" weight="medium">
12
+ {title}
13
+ </UIText>
14
+ ) : (
15
+ <div />
16
+ )}
17
+ {actions ? <div className="shrink-0">{actions}</div> : null}
18
+ </div>
19
+ ) : null}
20
+ <div>{content}</div>
21
+ </div>
22
+ );
23
+ }
24
+
25
+ export default function WidgetCard({
26
+ header,
27
+ sections = [],
28
+ footer,
29
+ divided = false,
30
+ collapsible = false,
31
+ defaultExpanded = true,
32
+ loading = false,
33
+ emptyMessage = "No sections.",
34
+ ...cardProps
35
+ }) {
36
+ const [expanded, setExpanded] = React.useState(defaultExpanded);
37
+
38
+ const hdr = (
39
+ <div className="flex items-start justify-between gap-3">
40
+ <div className="min-w-0">{header}</div>
41
+ {collapsible ? (
42
+ <button
43
+ type="button"
44
+ className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs font-semibold text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
45
+ onClick={() => setExpanded((e) => !e)}
46
+ aria-expanded={expanded}
47
+ aria-label="Toggle widget"
48
+ >
49
+ {expanded ? "Collapse" : "Expand"}
50
+ </button>
51
+ ) : null}
52
+ </div>
53
+ );
54
+
55
+ const body = (
56
+ <div className="mt-4 space-y-4">
57
+ {loading ? (
58
+ <div className="space-y-3">
59
+ <div className="h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
60
+ <div className="h-4 w-2/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
61
+ <div className="h-4 w-1/2 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
62
+ </div>
63
+ ) : expanded ? (
64
+ sections.length === 0 ? (
65
+ <div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-950/30 dark:text-slate-300">
66
+ {emptyMessage}
67
+ </div>
68
+ ) : (
69
+ sections.map((s, idx) => (
70
+ <Section
71
+ key={s?.id ?? idx}
72
+ title={s?.title}
73
+ actions={s?.actions}
74
+ content={s?.content}
75
+ divided={divided && idx !== 0}
76
+ />
77
+ ))
78
+ )
79
+ ) : (
80
+ <div className="text-sm text-slate-600 dark:text-slate-300">Collapsed.</div>
81
+ )}
82
+
83
+ {footer ? <div className={divided ? "border-t border-slate-200 pt-4 dark:border-slate-800" : ""}>{footer}</div> : null}
84
+ </div>
85
+ );
86
+
87
+ return <BaseCard variant="widget" header={hdr} body={body} {...cardProps} />;
88
+ }
89
+
90
+