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