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(null); const [sortDirection, setSortDirection] = useState("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 (
{children}
); } const toggleSort = (columnIndex: number) => { if (sortColumn !== columnIndex) { setSortColumn(columnIndex); setSortDirection("asc"); } else if (sortDirection === "asc") { setSortDirection("desc"); } else { setSortColumn(null); } }; return (
{sortColumn !== null && (
)}
{headerCells.map((cell, columnIndex) => { const isActive = sortColumn === columnIndex; const Icon = isActive ? (sortDirection === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown; return ( ); })} {sortedRows.map((row, rowIndex) => ( {row.map((cell, columnIndex) => ( ))} ))}
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" > {cell.content}
{cell.content}
); }