orchid-ai 2.2.0 → 2.3.0

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/orchid-ai.css CHANGED
@@ -860,39 +860,6 @@
860
860
  color: #1f2937;
861
861
  }
862
862
 
863
- .ai-chart-card-header {
864
- display: flex;
865
- align-items: center;
866
- justify-content: space-between;
867
- gap: 12px;
868
- margin-bottom: 10px;
869
- }
870
-
871
- .ai-chart-card-header .ai-chart-title {
872
- margin: 0;
873
- }
874
-
875
- .ai-chart-csv-btn {
876
- display: inline-flex;
877
- align-items: center;
878
- gap: 4px;
879
- flex-shrink: 0;
880
- padding: 4px 10px;
881
- font-size: 12px;
882
- font-weight: 500;
883
- color: #1eaaf1;
884
- background: #ffffff;
885
- border: 1px solid #d1d5db;
886
- border-radius: 6px;
887
- cursor: pointer;
888
- transition: background 0.15s, border-color 0.15s;
889
- }
890
-
891
- .ai-chart-csv-btn:hover {
892
- background: #f0f9ff;
893
- border-color: #1eaaf1;
894
- }
895
-
896
863
  .ai-chart-error {
897
864
  color: #b91c1c;
898
865
  background: #fef2f2;
@@ -954,6 +921,9 @@
954
921
  top: 8px;
955
922
  right: 8px;
956
923
  z-index: 12;
924
+ display: flex;
925
+ align-items: center;
926
+ gap: 6px;
957
927
  }
958
928
 
959
929
  .ai-chart-export-btn {
@@ -1012,6 +982,135 @@
1012
982
  opacity: 0.65;
1013
983
  }
1014
984
 
985
+ .ai-chart-export-menu-wrap {
986
+ position: relative;
987
+ }
988
+
989
+ .ai-chart-export-menu {
990
+ position: absolute;
991
+ top: calc(100% + 6px);
992
+ right: 0;
993
+ z-index: 13;
994
+ min-width: 172px;
995
+ padding: 4px;
996
+ display: flex;
997
+ flex-direction: column;
998
+ background: #ffffff;
999
+ border: 1px solid #e5e7eb;
1000
+ border-radius: 8px;
1001
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
1002
+ }
1003
+
1004
+ .ai-chart-export-menu-item {
1005
+ width: 100%;
1006
+ padding: 8px 10px;
1007
+ border: none;
1008
+ border-radius: 6px;
1009
+ background: transparent;
1010
+ color: #1f2937;
1011
+ font-size: 13px;
1012
+ font-family: inherit;
1013
+ text-align: left;
1014
+ cursor: pointer;
1015
+ transition: background 0.12s;
1016
+ }
1017
+
1018
+ .ai-chart-export-menu-item:hover {
1019
+ background: #f3f4f6;
1020
+ }
1021
+
1022
+ /* ── Chart full-screen modal ── */
1023
+ .ai-chart-modal-overlay {
1024
+ position: fixed;
1025
+ inset: 0;
1026
+ z-index: 1000;
1027
+ display: flex;
1028
+ align-items: center;
1029
+ justify-content: center;
1030
+ padding: 24px;
1031
+ background: rgba(17, 24, 39, 0.55);
1032
+ }
1033
+
1034
+ .ai-chart-modal {
1035
+ display: flex;
1036
+ flex-direction: column;
1037
+ width: min(1100px, 96vw);
1038
+ max-height: 92vh;
1039
+ overflow: hidden;
1040
+ background: #ffffff;
1041
+ border-radius: 12px;
1042
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1043
+ }
1044
+
1045
+ .ai-chart-modal-head {
1046
+ display: flex;
1047
+ align-items: center;
1048
+ justify-content: space-between;
1049
+ gap: 12px;
1050
+ padding: 14px 18px;
1051
+ border-bottom: 1px solid #e5e7eb;
1052
+ flex: 0 0 auto;
1053
+ }
1054
+
1055
+ .ai-chart-modal-title {
1056
+ font-size: 15px;
1057
+ font-weight: 600;
1058
+ color: #1f2937;
1059
+ }
1060
+
1061
+ .ai-chart-modal-actions {
1062
+ display: flex;
1063
+ align-items: center;
1064
+ gap: 8px;
1065
+ }
1066
+
1067
+ .ai-chart-modal-close {
1068
+ border: none;
1069
+ background: transparent;
1070
+ font-size: 24px;
1071
+ line-height: 1;
1072
+ color: #6b7280;
1073
+ cursor: pointer;
1074
+ padding: 0 4px;
1075
+ }
1076
+
1077
+ .ai-chart-modal-close:hover {
1078
+ color: #1f2937;
1079
+ }
1080
+
1081
+ /* The body is a single flex scroll context so the table wrap is the only
1082
+ scroller — avoids nested scrolling that let rows peek above the sticky header. */
1083
+ .ai-chart-modal-body {
1084
+ flex: 1 1 auto;
1085
+ min-height: 0;
1086
+ display: flex;
1087
+ flex-direction: column;
1088
+ padding: 0 18px 18px;
1089
+ overflow: hidden;
1090
+ }
1091
+
1092
+ /* Card chrome (border/background/title) is redundant inside the modal */
1093
+ .ai-chart-modal-body .ai-chart-card {
1094
+ flex: 1 1 auto;
1095
+ min-height: 0;
1096
+ display: flex;
1097
+ flex-direction: column;
1098
+ margin: 0;
1099
+ padding: 0;
1100
+ border: none;
1101
+ background: transparent;
1102
+ }
1103
+
1104
+ .ai-chart-modal-body .ai-chart-title {
1105
+ display: none;
1106
+ }
1107
+
1108
+ .ai-chart-modal-body .ai-data-table-wrap {
1109
+ flex: 1 1 auto;
1110
+ min-height: 0;
1111
+ max-height: none;
1112
+ }
1113
+
1015
1114
  .ai-dot-chart-header {
1016
1115
  display: flex;
1017
1116
  justify-content: flex-start;
@@ -1217,11 +1316,14 @@
1217
1316
 
1218
1317
  .ai-data-table-wrap {
1219
1318
  overflow-x: auto;
1319
+ overflow-y: auto;
1320
+ max-height: 620px; /* ~20 rows + header; scrolls beyond, header stays pinned */
1220
1321
  }
1221
1322
 
1222
1323
  .ai-data-table {
1223
1324
  width: 100%;
1224
- border-collapse: collapse;
1325
+ border-collapse: separate; /* not collapse: keeps the sticky header from leaking row slivers */
1326
+ border-spacing: 0;
1225
1327
  font-size: 12px;
1226
1328
  }
1227
1329
 
@@ -1235,6 +1337,37 @@
1235
1337
  letter-spacing: 0.04em;
1236
1338
  border-bottom: 2px solid #e5e7eb;
1237
1339
  white-space: nowrap;
1340
+ position: sticky;
1341
+ top: -1px; /* close the sub-pixel gap so rows can't peek above the header */
1342
+ z-index: 3;
1343
+ background: #eef2f6;
1344
+ background-clip: padding-box;
1345
+ }
1346
+
1347
+ .ai-data-table-th--sortable {
1348
+ cursor: pointer;
1349
+ -webkit-user-select: none;
1350
+ user-select: none;
1351
+ }
1352
+
1353
+ .ai-data-table-th--sortable:hover {
1354
+ color: #1f2937;
1355
+ }
1356
+
1357
+ .ai-data-table-th-inner {
1358
+ display: inline-flex;
1359
+ align-items: center;
1360
+ gap: 6px;
1361
+ }
1362
+
1363
+ .ai-data-table-sort {
1364
+ font-size: 9px;
1365
+ opacity: 0.4;
1366
+ }
1367
+
1368
+ .ai-data-table-th.is-sorted .ai-data-table-sort {
1369
+ opacity: 1;
1370
+ color: #1eaaf1;
1238
1371
  }
1239
1372
 
1240
1373
  .ai-data-table-row:nth-child(even) .ai-data-table-td {
@@ -1248,6 +1381,18 @@
1248
1381
  vertical-align: top;
1249
1382
  }
1250
1383
 
1384
+ /* TOTAL / pinned rows live in <tfoot> and stick to the bottom like a footer.
1385
+ At full scroll they sit naturally below the last row, so nothing is obscured. */
1386
+ .ai-data-table tfoot .ai-data-table-td {
1387
+ position: sticky;
1388
+ bottom: 0;
1389
+ z-index: 2;
1390
+ font-weight: 700;
1391
+ color: #1f2937;
1392
+ background: #e7edf3;
1393
+ border-top: 2px solid #cbd5e1;
1394
+ }
1395
+
1251
1396
  /* ── Bar Chart ── */
1252
1397
 
1253
1398
  .ai-bar-chart {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchid-ai",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Shared Orchid AI chat UI and visualization components",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState } from "react";
1
+ import React, { useRef, useState, useEffect } from "react";
2
2
  import html2canvas from "html2canvas";
3
3
  import {
4
4
  DOT_CHART_TYPE,
@@ -36,76 +36,195 @@ const COMPONENTS = {
36
36
  [SCATTER_PLOT_TYPE]: ScatterPlot,
37
37
  };
38
38
 
39
- function buildChartFilename(chart) {
40
- const typePart = String(chart?.type || "chart").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
41
- const titlePart = String(chart?.title || "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
39
+ const DOWNLOAD_ICON = (
40
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
41
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
42
+ <polyline points="7 10 12 15 17 10" />
43
+ <line x1="12" y1="15" x2="12" y2="3" />
44
+ </svg>
45
+ );
46
+
47
+ const EXPAND_ICON = (
48
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
49
+ <polyline points="15 3 21 3 21 9" />
50
+ <polyline points="9 21 3 21 3 15" />
51
+ <line x1="21" y1="3" x2="14" y2="10" />
52
+ <line x1="3" y1="21" x2="10" y2="14" />
53
+ </svg>
54
+ );
55
+
56
+ function slug(value) {
57
+ return String(value || "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
58
+ }
59
+
60
+ function buildChartFilename(chart, ext) {
61
+ const typePart = slug(chart?.type) || "chart";
62
+ const titlePart = slug(chart?.title);
42
63
  const datePart = new Date().toISOString().slice(0, 10);
43
64
  const base = titlePart ? `${typePart}-${titlePart}` : typePart;
44
- return `${base || "chart"}-${datePart}.png`;
65
+ return `${base || "chart"}-${datePart}.${ext}`;
66
+ }
67
+
68
+ function csvEscape(value) {
69
+ const s = (value ?? "").toString();
70
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
71
+ }
72
+
73
+ function tableToCsv(chart) {
74
+ const columns = chart.columns || [];
75
+ const rows = chart.rows || [];
76
+ const header = columns.map((c) => csvEscape(c.label)).join(",");
77
+ const body = rows.map((row) => columns.map((c) => csvEscape(row[c.key])).join(",")).join("\r\n");
78
+ return `${header}\r\n${body}`;
79
+ }
80
+
81
+ function triggerDownload(href, filename) {
82
+ const link = document.createElement("a");
83
+ link.href = href;
84
+ link.download = filename;
85
+ document.body.appendChild(link);
86
+ link.click();
87
+ document.body.removeChild(link);
88
+ }
89
+
90
+ // Download control: a single PNG button for non-tables, a CSV/Image menu for tables.
91
+ function DownloadMenu({ supportsCsv, isDownloading, onCsv, onImage }) {
92
+ const [open, setOpen] = useState(false);
93
+ const ref = useRef(null);
94
+
95
+ useEffect(() => {
96
+ if (!open) return undefined;
97
+ const onDocMouseDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
98
+ document.addEventListener("mousedown", onDocMouseDown);
99
+ return () => document.removeEventListener("mousedown", onDocMouseDown);
100
+ }, [open]);
101
+
102
+ if (!supportsCsv) {
103
+ return (
104
+ <button type="button" className="ai-chart-export-btn" onClick={onImage} disabled={isDownloading} title="Save chart as PNG" aria-label="Save chart as PNG">
105
+ {DOWNLOAD_ICON}
106
+ <span className="ai-chart-export-label">Download</span>
107
+ </button>
108
+ );
109
+ }
110
+
111
+ return (
112
+ <div className="ai-chart-export-menu-wrap" ref={ref}>
113
+ <button type="button" className="ai-chart-export-btn" onClick={() => setOpen((o) => !o)} disabled={isDownloading} aria-haspopup="menu" aria-expanded={open} title="Download" aria-label="Download">
114
+ {DOWNLOAD_ICON}
115
+ <span className="ai-chart-export-label">Download</span>
116
+ </button>
117
+ {open && (
118
+ <div className="ai-chart-export-menu" role="menu">
119
+ <button type="button" role="menuitem" className="ai-chart-export-menu-item" onClick={() => { setOpen(false); onCsv(); }}>CSV (spreadsheet)</button>
120
+ <button type="button" role="menuitem" className="ai-chart-export-menu-item" onClick={() => { setOpen(false); onImage(); }}>Image (PNG)</button>
121
+ </div>
122
+ )}
123
+ </div>
124
+ );
45
125
  }
46
126
 
47
127
  export default function AiVisualization({ chart }) {
48
128
  const chartRef = useRef(null);
129
+ const modalBodyRef = useRef(null);
49
130
  const [isDownloading, setIsDownloading] = useState(false);
131
+ const [expanded, setExpanded] = useState(false);
132
+
133
+ useEffect(() => {
134
+ if (!expanded) return undefined;
135
+ const onKeyDown = (e) => { if (e.key === "Escape") setExpanded(false); };
136
+ document.addEventListener("keydown", onKeyDown);
137
+ return () => document.removeEventListener("keydown", onKeyDown);
138
+ }, [expanded]);
50
139
 
51
140
  if (!chart || !chart.type) {
52
141
  return null;
53
142
  }
54
143
 
55
144
  const Component = COMPONENTS[chart.type];
56
- const handleDownload = async () => {
57
- if (!chartRef.current || isDownloading) {
58
- return;
59
- }
145
+ const isTable = chart.type === TABLE_TYPE && Array.isArray(chart.columns) && Array.isArray(chart.rows);
146
+ const supportsCsv = isTable;
147
+ const canExpand = isTable && chart.rows.length > 10;
60
148
 
149
+ const downloadImage = async (targetRef) => {
150
+ const node = (targetRef && targetRef.current) || chartRef.current;
151
+ if (!node || isDownloading) return;
61
152
  setIsDownloading(true);
62
153
  try {
63
- const canvas = await html2canvas(chartRef.current, {
64
- backgroundColor: null,
154
+ const canvas = await html2canvas(node, {
155
+ backgroundColor: "#ffffff",
65
156
  useCORS: true,
66
157
  scale: Math.max(window.devicePixelRatio || 1, 2),
67
158
  });
68
- const url = canvas.toDataURL("image/png");
69
- const link = document.createElement("a");
70
- link.href = url;
71
- link.download = buildChartFilename(chart);
72
- link.click();
159
+ triggerDownload(canvas.toDataURL("image/png"), buildChartFilename(chart, "png"));
73
160
  } finally {
74
161
  setIsDownloading(false);
75
162
  }
76
163
  };
77
164
 
78
- if (Component) {
165
+ const downloadCsv = () => {
166
+ const blob = new Blob([`${tableToCsv(chart)}`], { type: "text/csv;charset=utf-8;" });
167
+ const url = URL.createObjectURL(blob);
168
+ triggerDownload(url, buildChartFilename(chart, "csv"));
169
+ setTimeout(() => URL.revokeObjectURL(url), 0);
170
+ };
171
+
172
+ if (!Component) {
79
173
  return (
80
- <div className="ai-chart-export-wrap">
81
- <div className="ai-chart-export-actions">
174
+ <div className="ai-chart-card ai-chart-error">
175
+ <p>Unsupported chart type: {chart.type}</p>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ return (
181
+ <div className="ai-chart-export-wrap">
182
+ <div className="ai-chart-export-actions">
183
+ {canExpand && (
82
184
  <button
83
185
  type="button"
84
186
  className="ai-chart-export-btn"
85
- onClick={handleDownload}
86
- disabled={isDownloading}
87
- title="Save chart as PNG"
88
- aria-label="Save chart as PNG"
187
+ onClick={() => setExpanded(true)}
188
+ title="Expand to full screen"
189
+ aria-label="Expand table to full screen"
89
190
  >
90
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
91
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
92
- <polyline points="7 10 12 15 17 10" />
93
- <line x1="12" y1="15" x2="12" y2="3" />
94
- </svg>
95
- <span className="ai-chart-export-label">{isDownloading ? "Saving..." : "Download"}</span>
191
+ {EXPAND_ICON}
192
+ <span className="ai-chart-export-label">Expand</span>
96
193
  </button>
97
- </div>
98
- <div className="ai-chart-export-target" ref={chartRef}>
99
- <Component chart={chart} />
100
- </div>
194
+ )}
195
+ <DownloadMenu
196
+ supportsCsv={supportsCsv}
197
+ isDownloading={isDownloading}
198
+ onCsv={downloadCsv}
199
+ onImage={() => downloadImage(chartRef)}
200
+ />
101
201
  </div>
102
- );
103
- }
104
202
 
105
- return (
106
- <div className="ai-chart-card ai-chart-error">
107
- <p>Unsupported chart type: {chart.type}</p>
203
+ <div className="ai-chart-export-target" ref={chartRef}>
204
+ <Component chart={chart} />
205
+ </div>
206
+
207
+ {expanded && (
208
+ <div className="ai-chart-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) setExpanded(false); }}>
209
+ <div className="ai-chart-modal" role="dialog" aria-modal="true">
210
+ <div className="ai-chart-modal-head">
211
+ <span className="ai-chart-modal-title">{chart.title || "Table"}</span>
212
+ <div className="ai-chart-modal-actions">
213
+ <DownloadMenu
214
+ supportsCsv={supportsCsv}
215
+ isDownloading={isDownloading}
216
+ onCsv={downloadCsv}
217
+ onImage={() => downloadImage(modalBodyRef)}
218
+ />
219
+ <button type="button" className="ai-chart-modal-close" onClick={() => setExpanded(false)} aria-label="Close">×</button>
220
+ </div>
221
+ </div>
222
+ <div className="ai-chart-modal-body" ref={modalBodyRef}>
223
+ <Component chart={chart} />
224
+ </div>
225
+ </div>
226
+ </div>
227
+ )}
108
228
  </div>
109
229
  );
110
230
  }
111
-
@@ -1,69 +1,78 @@
1
- import React from "react";
1
+ import React, { useState, useMemo } from "react";
2
2
 
3
- function csvEscape(value) {
4
- const s = (value ?? "").toString();
5
- return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
6
- }
7
-
8
- function buildCsv(columns, rows) {
9
- const header = columns.map((c) => csvEscape(c.label)).join(",");
10
- const body = rows.map((row) => columns.map((c) => csvEscape(row[c.key])).join(",")).join("\r\n");
11
- return `${header}\r\n${body}`;
12
- }
13
-
14
- function downloadCsv(filename, csv) {
15
- // Prepend a BOM so Excel reads UTF-8 correctly.
16
- const blob = new Blob([`${csv}`], { type: "text/csv;charset=utf-8;" });
17
- const url = URL.createObjectURL(blob);
18
- const a = document.createElement("a");
19
- a.href = url;
20
- a.download = filename;
21
- document.body.appendChild(a);
22
- a.click();
23
- document.body.removeChild(a);
24
- setTimeout(() => URL.revokeObjectURL(url), 0);
25
- }
26
-
27
- function safeFilename(title) {
28
- const base = (title || "table").replace(/[\\/:*?"<>|]+/g, "").trim().slice(0, 80);
29
- return `${base || "table"}.csv`;
3
+ // Compare two cell values numerically when both look numeric, else as text.
4
+ function compareValues(a, b) {
5
+ const na = parseFloat(String(a ?? "").replace(/[, ]/g, ""));
6
+ const nb = parseFloat(String(b ?? "").replace(/[, ]/g, ""));
7
+ const aNum = String(a ?? "").trim() !== "" && !Number.isNaN(na);
8
+ const bNum = String(b ?? "").trim() !== "" && !Number.isNaN(nb);
9
+ if (aNum && bNum) return na - nb;
10
+ return String(a ?? "").localeCompare(String(b ?? ""), undefined, { numeric: true, sensitivity: "base" });
30
11
  }
31
12
 
32
13
  export default function DataTable({ chart }) {
33
14
  const { title, columns, rows } = chart;
15
+ const [sortKey, setSortKey] = useState(null);
16
+ const [sortDir, setSortDir] = useState("asc");
17
+
18
+ // Rows flagged `_pinned` (e.g. a TOTAL row) render in a sticky <tfoot> so they
19
+ // stay visible like a footer; the rest sort within the <tbody>.
20
+ const { bodyRows, footRows } = useMemo(() => {
21
+ const pinned = rows.filter((r) => r && r._pinned);
22
+ const sortable = rows.filter((r) => !(r && r._pinned));
23
+ const sorted = sortKey
24
+ ? [...sortable].sort((a, b) => {
25
+ const cmp = compareValues(a?.[sortKey], b?.[sortKey]);
26
+ return sortDir === "asc" ? cmp : -cmp;
27
+ })
28
+ : sortable;
29
+ return { bodyRows: sorted, footRows: pinned };
30
+ }, [rows, sortKey, sortDir]);
34
31
 
35
- const handleDownload = () => downloadCsv(safeFilename(title), buildCsv(columns, rows));
32
+ // Cycle: unsorted -> ascending -> descending -> unsorted.
33
+ const handleSort = (key) => {
34
+ if (sortKey !== key) {
35
+ setSortKey(key);
36
+ setSortDir("asc");
37
+ } else if (sortDir === "asc") {
38
+ setSortDir("desc");
39
+ } else {
40
+ setSortKey(null);
41
+ setSortDir("asc");
42
+ }
43
+ };
36
44
 
37
45
  return (
38
46
  <div className="ai-chart-card">
39
- <div className="ai-chart-card-header">
40
- {title ? <h4 className="ai-chart-title">{title}</h4> : <span />}
41
- <button
42
- type="button"
43
- className="ai-chart-csv-btn"
44
- onClick={handleDownload}
45
- title="Download as CSV"
46
- aria-label="Download table as CSV"
47
- >
48
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
49
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
50
- <polyline points="7 10 12 15 17 10" />
51
- <line x1="12" y1="15" x2="12" y2="3" />
52
- </svg>
53
- CSV
54
- </button>
55
- </div>
47
+ {title ? <h4 className="ai-chart-title">{title}</h4> : null}
56
48
  <div className="ai-data-table-wrap">
57
49
  <table className="ai-data-table">
58
50
  <thead>
59
51
  <tr>
60
- {columns.map((col) => (
61
- <th key={col.key} className="ai-data-table-th">{col.label}</th>
62
- ))}
52
+ {columns.map((col) => {
53
+ const active = sortKey === col.key;
54
+ return (
55
+ <th
56
+ key={col.key}
57
+ className={`ai-data-table-th ai-data-table-th--sortable${active ? " is-sorted" : ""}`}
58
+ onClick={() => handleSort(col.key)}
59
+ role="button"
60
+ tabIndex={0}
61
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleSort(col.key); } }}
62
+ aria-sort={active ? (sortDir === "asc" ? "ascending" : "descending") : "none"}
63
+ title={`Sort by ${col.label}`}
64
+ >
65
+ <span className="ai-data-table-th-inner">
66
+ <span>{col.label}</span>
67
+ <span className="ai-data-table-sort" aria-hidden="true">{active ? (sortDir === "asc" ? "▲" : "▼") : "↕"}</span>
68
+ </span>
69
+ </th>
70
+ );
71
+ })}
63
72
  </tr>
64
73
  </thead>
65
74
  <tbody>
66
- {rows.map((row, i) => (
75
+ {bodyRows.map((row, i) => (
67
76
  <tr key={i} className="ai-data-table-row">
68
77
  {columns.map((col) => (
69
78
  <td key={col.key} className="ai-data-table-td">{row[col.key]}</td>
@@ -71,6 +80,17 @@ export default function DataTable({ chart }) {
71
80
  </tr>
72
81
  ))}
73
82
  </tbody>
83
+ {footRows.length > 0 && (
84
+ <tfoot>
85
+ {footRows.map((row, i) => (
86
+ <tr key={i} className="ai-data-table-row ai-data-table-row--pinned">
87
+ {columns.map((col) => (
88
+ <td key={col.key} className="ai-data-table-td">{row[col.key]}</td>
89
+ ))}
90
+ </tr>
91
+ ))}
92
+ </tfoot>
93
+ )}
74
94
  </table>
75
95
  </div>
76
96
  </div>