harryagasi commited on
Commit
cac20f9
·
1 Parent(s): 15d8afd

[noticket] feat: add sortable column in table component

Browse files
src/app/components/analysis/MarkdownContent.tsx CHANGED
@@ -3,6 +3,7 @@ import type { Components } from "react-markdown";
3
  import remarkGfm from "remark-gfm";
4
  import remarkMath from "remark-math";
5
  import rehypeKatex from "rehype-katex";
 
6
 
7
  function repairMarkdownMarkers(line: string) {
8
  return line
@@ -70,7 +71,7 @@ const markdownComponents: Components = {
70
  return <code className="rounded bg-slate-100 px-1.5 py-0.5 text-[0.85em] text-slate-900">{children}</code>;
71
  },
72
  pre: ({ children }) => <pre className="my-3 max-w-full overflow-x-auto rounded-lg bg-slate-950 p-3 text-xs leading-6 text-slate-50">{children}</pre>,
73
- table: ({ children }) => <table className="my-4 w-full table-auto border-collapse text-sm">{children}</table>,
74
  th: ({ children }) => <th className="border border-slate-200 bg-slate-50 px-3 py-2 text-left font-semibold text-slate-900">{children}</th>,
75
  td: ({ children }) => <td className="border border-slate-200 px-3 py-2 align-top text-slate-700">{children}</td>,
76
  };
 
3
  import remarkGfm from "remark-gfm";
4
  import remarkMath from "remark-math";
5
  import rehypeKatex from "rehype-katex";
6
+ import { SortableMarkdownTable } from "./SortableMarkdownTable";
7
 
8
  function repairMarkdownMarkers(line: string) {
9
  return line
 
71
  return <code className="rounded bg-slate-100 px-1.5 py-0.5 text-[0.85em] text-slate-900">{children}</code>;
72
  },
73
  pre: ({ children }) => <pre className="my-3 max-w-full overflow-x-auto rounded-lg bg-slate-950 p-3 text-xs leading-6 text-slate-50">{children}</pre>,
74
+ table: ({ children }) => <SortableMarkdownTable>{children}</SortableMarkdownTable>,
75
  th: ({ children }) => <th className="border border-slate-200 bg-slate-50 px-3 py-2 text-left font-semibold text-slate-900">{children}</th>,
76
  td: ({ children }) => <td className="border border-slate-200 px-3 py-2 align-top text-slate-700">{children}</td>,
77
  };
src/app/components/analysis/SortableMarkdownTable.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Children, isValidElement, useMemo, useState, type ReactNode } from "react";
2
+ import { ArrowDown, ArrowUp, ArrowUpDown, RotateCcw } from "lucide-react";
3
+
4
+ type SortDirection = "asc" | "desc";
5
+
6
+ interface Cell {
7
+ content: ReactNode;
8
+ text: string;
9
+ }
10
+
11
+ function extractText(node: ReactNode): string {
12
+ if (node === null || node === undefined || typeof node === "boolean") return "";
13
+ if (typeof node === "string" || typeof node === "number") return String(node);
14
+ if (Array.isArray(node)) return node.map(extractText).join("");
15
+ if (isValidElement(node)) return extractText((node.props as { children?: ReactNode }).children);
16
+ return "";
17
+ }
18
+
19
+ function toCells(row: ReactNode): Cell[] {
20
+ return Children.toArray(row)
21
+ .filter(isValidElement)
22
+ .map((cell) => {
23
+ const content = (cell.props as { children?: ReactNode }).children;
24
+ return { content, text: extractText(content).trim() };
25
+ });
26
+ }
27
+
28
+ function toRows(section: ReactNode): Cell[][] {
29
+ const rows = Children.toArray(section).filter(isValidElement);
30
+ return rows.map((row) => toCells((row.props as { children?: ReactNode }).children));
31
+ }
32
+
33
+ function compareCells(a: Cell, b: Cell, direction: SortDirection) {
34
+ const numA = Number(a.text.replace(/[,$%\s]/g, ""));
35
+ const numB = Number(b.text.replace(/[,$%\s]/g, ""));
36
+ const bothNumeric = a.text !== "" && b.text !== "" && !Number.isNaN(numA) && !Number.isNaN(numB);
37
+ const result = bothNumeric ? numA - numB : a.text.localeCompare(b.text, undefined, { numeric: true, sensitivity: "base" });
38
+ return direction === "asc" ? result : -result;
39
+ }
40
+
41
+ export function SortableMarkdownTable({ children }: { children?: ReactNode }) {
42
+ const sections = Children.toArray(children).filter(isValidElement);
43
+ const theadEl = sections.find((section) => section.type === "thead");
44
+ const tbodyEl = sections.find((section) => section.type === "tbody");
45
+
46
+ const headerCells = theadEl ? toRows((theadEl.props as { children?: ReactNode }).children)[0] ?? [] : [];
47
+ const bodyRows = tbodyEl ? toRows((tbodyEl.props as { children?: ReactNode }).children) : [];
48
+
49
+ const [sortColumn, setSortColumn] = useState<number | null>(null);
50
+ const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
51
+
52
+ const sortedRows = useMemo(() => {
53
+ if (sortColumn === null) return bodyRows;
54
+ return [...bodyRows].sort((rowA, rowB) => {
55
+ const cellA = rowA[sortColumn];
56
+ const cellB = rowB[sortColumn];
57
+ if (!cellA || !cellB) return 0;
58
+ return compareCells(cellA, cellB, sortDirection);
59
+ });
60
+ }, [bodyRows, sortColumn, sortDirection]);
61
+
62
+ if (headerCells.length === 0) {
63
+ // Not a well-formed GFM table (no thead) — fall back to plain rendering.
64
+ return (
65
+ <div className="my-4 overflow-x-auto">
66
+ <table className="w-full table-auto border-collapse text-sm">{children}</table>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ const toggleSort = (columnIndex: number) => {
72
+ if (sortColumn !== columnIndex) {
73
+ setSortColumn(columnIndex);
74
+ setSortDirection("asc");
75
+ } else if (sortDirection === "asc") {
76
+ setSortDirection("desc");
77
+ } else {
78
+ setSortColumn(null);
79
+ }
80
+ };
81
+
82
+ return (
83
+ <div className="my-4">
84
+ {sortColumn !== null && (
85
+ <div className="mb-1.5 flex justify-end">
86
+ <button
87
+ type="button"
88
+ onClick={() => setSortColumn(null)}
89
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-800"
90
+ >
91
+ <RotateCcw className="h-3 w-3" />
92
+ Reset sort
93
+ </button>
94
+ </div>
95
+ )}
96
+ <div className="overflow-x-auto">
97
+ <table className="w-full table-auto border-collapse text-sm">
98
+ <thead>
99
+ <tr>
100
+ {headerCells.map((cell, columnIndex) => {
101
+ const isActive = sortColumn === columnIndex;
102
+ const Icon = isActive ? (sortDirection === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
103
+ return (
104
+ <th
105
+ key={columnIndex}
106
+ onClick={() => toggleSort(columnIndex)}
107
+ aria-sort={isActive ? (sortDirection === "asc" ? "ascending" : "descending") : "none"}
108
+ className="cursor-pointer select-none border border-slate-200 bg-slate-50 px-3 py-2 text-left font-semibold text-slate-900 hover:bg-slate-100"
109
+ >
110
+ <span className="inline-flex items-center gap-1.5">
111
+ {cell.content}
112
+ <Icon className={isActive ? "h-3.5 w-3.5 text-emerald-700" : "h-3.5 w-3.5 text-slate-400"} />
113
+ </span>
114
+ </th>
115
+ );
116
+ })}
117
+ </tr>
118
+ </thead>
119
+ <tbody>
120
+ {sortedRows.map((row, rowIndex) => (
121
+ <tr key={rowIndex}>
122
+ {row.map((cell, columnIndex) => (
123
+ <td key={columnIndex} className="border border-slate-200 px-3 py-2 align-top text-slate-700">
124
+ {cell.content}
125
+ </td>
126
+ ))}
127
+ </tr>
128
+ ))}
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+ </div>
133
+ );
134
+ }