@schandlergarcia/sf-web-components 1.9.38 → 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 +36 -17
  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,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
+
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Minimal D3 chart host:
5
+ * - Owns the <svg> element
6
+ * - Computes responsive dimensions from container size
7
+ * - Calls renderChart(svgEl, data, dims, options)
8
+ */
9
+ export default function D3Chart({
10
+ data,
11
+ renderChart,
12
+ options = {},
13
+ width,
14
+ height = 280,
15
+ responsive = false,
16
+ aspectRatio,
17
+ className = "",
18
+ style,
19
+ containerStyle,
20
+ svgStyle,
21
+ loading = false,
22
+ error,
23
+ ariaLabel = "Chart"
24
+ }) {
25
+ const containerRef = React.useRef(null);
26
+ const svgRef = React.useRef(null);
27
+ const [containerWidth, setContainerWidth] = React.useState(null);
28
+
29
+ React.useEffect(() => {
30
+ if (!responsive) return;
31
+ const el = containerRef.current;
32
+ if (!el) return;
33
+
34
+ const obs = new ResizeObserver((entries) => {
35
+ const w = entries?.[0]?.contentRect?.width;
36
+ if (typeof w === "number" && Number.isFinite(w)) setContainerWidth(w);
37
+ });
38
+ obs.observe(el);
39
+ return () => obs.disconnect();
40
+ }, [responsive]);
41
+
42
+ const computedWidth = responsive ? containerWidth : width;
43
+ const computedHeight = React.useMemo(() => {
44
+ if (!responsive) return height;
45
+ if (!containerWidth) return height;
46
+ if (aspectRatio && Number.isFinite(aspectRatio) && aspectRatio > 0) return containerWidth / aspectRatio;
47
+ return height;
48
+ }, [responsive, containerWidth, height, aspectRatio]);
49
+
50
+ React.useEffect(() => {
51
+ if (loading || error) return;
52
+ if (!renderChart) return;
53
+ const svgEl = svgRef.current;
54
+ if (!svgEl) return;
55
+
56
+ const dims = {
57
+ width: computedWidth ?? 0,
58
+ height: computedHeight ?? 0
59
+ };
60
+
61
+ // Avoid calling renderChart before we have a measurable width in responsive mode
62
+ if (responsive && (!dims.width || dims.width < 10)) return;
63
+ if (!dims.height || dims.height < 10) return;
64
+
65
+ renderChart(svgEl, data, dims, options);
66
+ }, [data, renderChart, options, computedWidth, computedHeight, responsive, loading, error]);
67
+
68
+ if (error) {
69
+ return (
70
+ <div
71
+ ref={containerRef}
72
+ className={["w-full 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", className]
73
+ .filter(Boolean)
74
+ .join(" ")}
75
+ style={containerStyle}
76
+ >
77
+ {String(error)}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <div
84
+ ref={containerRef}
85
+ className={["w-full", className].filter(Boolean).join(" ")}
86
+ style={{ ...containerStyle, position: "relative" }}
87
+ >
88
+ {loading ? (
89
+ <div className="h-full w-full rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
90
+ <div className="space-y-3">
91
+ <div className="h-4 w-1/3 animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
92
+ <div className="h-44 w-full animate-pulse rounded bg-slate-200 dark:bg-slate-800" />
93
+ </div>
94
+ </div>
95
+ ) : (
96
+ <svg
97
+ ref={svgRef}
98
+ role="img"
99
+ aria-label={ariaLabel}
100
+ width={computedWidth ?? width ?? "100%"}
101
+ height={computedHeight}
102
+ style={{ ...svgStyle, ...style, display: "block" }}
103
+ />
104
+ )}
105
+ </div>
106
+ );
107
+ }
108
+
109
+
@@ -0,0 +1,126 @@
1
+ import * as d3 from "d3";
2
+
3
+ function clear(svg) {
4
+ d3.select(svg).selectAll("*").remove();
5
+ }
6
+
7
+ export const D3ChartTemplates = {
8
+ lineChart(svg, data, dims, opts = {}) {
9
+ const {
10
+ xKey = "x",
11
+ yKey = "y",
12
+ margin = { top: 12, right: 16, bottom: 28, left: 40 },
13
+ stroke = "#6366F1",
14
+ strokeWidth = 2,
15
+ showAxes = true,
16
+ showGrid = true
17
+ } = opts;
18
+
19
+ clear(svg);
20
+
21
+ const width = dims.width;
22
+ const height = dims.height;
23
+ const innerW = Math.max(0, width - margin.left - margin.right);
24
+ const innerH = Math.max(0, height - margin.top - margin.bottom);
25
+
26
+ const g = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`).append("g").attr("transform", `translate(${margin.left},${margin.top})`);
27
+
28
+ const xs = data.map((d) => d?.[xKey]).filter((v) => v != null);
29
+ const ys = data.map((d) => d?.[yKey]).filter((v) => v != null);
30
+
31
+ const xDomain = d3.extent(xs);
32
+ const yDomain = d3.extent(ys);
33
+
34
+ const x = d3.scaleLinear().domain(xDomain).nice().range([0, innerW]);
35
+ const y = d3.scaleLinear().domain(yDomain).nice().range([innerH, 0]);
36
+
37
+ if (showGrid) {
38
+ g.append("g")
39
+ .attr("class", "grid")
40
+ .call(d3.axisLeft(y).ticks(5).tickSize(-innerW).tickFormat(""))
41
+ .call((grid) => grid.selectAll("line").attr("stroke", "currentColor").attr("opacity", 0.12))
42
+ .call((grid) => grid.selectAll("path").attr("stroke", "none"));
43
+ }
44
+
45
+ const line = d3
46
+ .line()
47
+ .x((d) => x(d[xKey]))
48
+ .y((d) => y(d[yKey]))
49
+ .defined((d) => d?.[xKey] != null && d?.[yKey] != null);
50
+
51
+ g.append("path")
52
+ .datum(data)
53
+ .attr("fill", "none")
54
+ .attr("stroke", stroke)
55
+ .attr("stroke-width", strokeWidth)
56
+ .attr("d", line);
57
+
58
+ if (showAxes) {
59
+ g.append("g")
60
+ .attr("transform", `translate(0,${innerH})`)
61
+ .call(d3.axisBottom(x).ticks(6))
62
+ .call((ax) => ax.selectAll("text").attr("font-size", 10));
63
+
64
+ g.append("g")
65
+ .call(d3.axisLeft(y).ticks(5))
66
+ .call((ax) => ax.selectAll("text").attr("font-size", 10));
67
+ }
68
+ },
69
+
70
+ groupedBarChart(svg, data, dims, opts = {}) {
71
+ const {
72
+ groups = [],
73
+ margin = { top: 20, right: 20, bottom: 40, left: 55 },
74
+ xKey = "x",
75
+ colors = ["#6366F1", "#CBD5E1"],
76
+ barRadius = 4,
77
+ yFormat = ",.0f",
78
+ showGrid = true,
79
+ } = opts;
80
+
81
+ clear(svg);
82
+
83
+ const width = dims.width;
84
+ const height = dims.height;
85
+ const innerW = Math.max(0, width - margin.left - margin.right);
86
+ const innerH = Math.max(0, height - margin.top - margin.bottom);
87
+
88
+ const sel = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
89
+ const g = sel.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
90
+
91
+ const groupKeys = groups.length ? groups : Object.keys(data[0] || {}).filter((k) => k !== xKey);
92
+
93
+ const x0 = d3.scaleBand().domain(data.map((d) => d[xKey])).range([0, innerW]).padding(0.3);
94
+ const x1 = d3.scaleBand().domain(groupKeys).range([0, x0.bandwidth()]).padding(0.05);
95
+ const yMax = d3.max(data, (d) => d3.max(groupKeys, (k) => d[k])) * 1.15;
96
+ const y = d3.scaleLinear().domain([0, yMax]).range([innerH, 0]);
97
+
98
+ g.append("g")
99
+ .attr("transform", `translate(0,${innerH})`)
100
+ .call(d3.axisBottom(x0).tickSize(0))
101
+ .call((ax) => ax.selectAll("text").attr("fill", "#94a3b8").attr("font-size", "11px"))
102
+ .call((ax) => ax.select(".domain").remove());
103
+
104
+ g.append("g")
105
+ .call(d3.axisLeft(y).ticks(5).tickFormat(d3.format(yFormat)).tickSize(showGrid ? -innerW : 0))
106
+ .call((ax) => ax.selectAll("text").attr("fill", "#94a3b8").attr("font-size", "10px"))
107
+ .call((ax) => ax.selectAll(".tick line").attr("stroke", "#e2e8f0").attr("stroke-dasharray", "2,2"))
108
+ .call((ax) => ax.select(".domain").remove());
109
+
110
+ const rows = g.selectAll(".bar-group").data(data).join("g")
111
+ .attr("class", "bar-group")
112
+ .attr("transform", (d) => `translate(${x0(d[xKey])},0)`);
113
+
114
+ groupKeys.forEach((key, i) => {
115
+ rows.append("rect")
116
+ .attr("x", x1(key))
117
+ .attr("y", (d) => y(d[key]))
118
+ .attr("width", x1.bandwidth())
119
+ .attr("height", (d) => innerH - y(d[key]))
120
+ .attr("rx", barRadius)
121
+ .attr("fill", colors[i % colors.length]);
122
+ });
123
+ },
124
+ };
125
+
126
+