UNPKG

@poserjs/react-table-csv

Version:

React component for exploring CSV data with filers, grouping, sorting, and CSV export/import.

273 lines (255 loc) 9.11 kB
import React, { useEffect, useState } from 'react'; import Papa from 'papaparse'; import useCsvData from './hooks/useCsvData'; import useTableState from './hooks/useTableState'; import Toolbar from './components/Toolbar'; import SettingsPanel from './components/SettingsPanel'; import DataTable from './components/DataTable'; import styles from './ReactTableCsv.module.css'; import { Filter, Settings as SettingsIcon, Plus, Minus } from 'lucide-react'; const ReactTableCSV = ({ csvString, csvURL, csvData, downloadFilename = 'data.csv', storageKey = 'react-table-csv-key', defaultSettings = '', title, collapsed: collapsedProp = false, maxHeight = 'unlimited', maxWidth = 'unlimited', fontSize: fontSizeProp = 13, }) => { const { originalHeaders, data, error } = useCsvData({ csvString, csvURL, csvData }); const [customize, setCustomize] = useState(false); const [collapsed, setCollapsed] = useState(!!collapsedProp); // Sync internal collapsed state when prop changes useEffect(() => { setCollapsed(!!collapsedProp); }, [collapsedProp]); const table = useTableState({ originalHeaders, storageKey, defaultSettings, customize, setCustomize, defaultMaxHeight: maxHeight, defaultMaxWidth: maxWidth, defaultFontSize: fontSizeProp, }); // Auto-sync Settings panel visibility with customize mode useEffect(() => { try { table.setShowStylePanel(!!customize); } catch { /* ignore */ } }, [customize]); const buildCsv = () => { if (!table.tableState.visibleHeaders.length) return null; const exportRows = table.tableState.rows.map((row) => { const o = {}; table.tableState.visibleHeaders.forEach((h) => { o[h] = row[h]; }); return o; }); const csv = Papa.unparse(exportRows, { header: true, columns: table.tableState.visibleHeaders, delimiter: ',', newline: '\r\n', }); return '\ufeff' + csv; }; const buildMarkdown = () => { if (!table.tableState.visibleHeaders.length) return null; const header = `| ${table.tableState.visibleHeaders.join(' | ')} |`; const separator = `| ${table.tableState.visibleHeaders.map(() => '---').join(' | ')} |`; const body = table.tableState.rows.map((row) => { const cells = table.tableState.visibleHeaders.map((h) => { const v = row[h] == null ? '' : String(row[h]); return v.replace(/\|/g, '\\|'); }); return `| ${cells.join(' | ')} |`; }); return [header, separator, ...body].join('\n'); }; const handleDownload = () => { const csv = buildCsv(); if (!csv) return; const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = downloadFilename || 'data.csv'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const handleCopyCsv = () => { const csv = buildCsv(); if (!csv) return; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); } }; const handleCopyMarkdown = () => { const md = buildMarkdown(); if (!md) return; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(md).catch(() => {}); } }; const handleCopyUrl = () => { try { const json = JSON.stringify(table.buildSettings()); const url = new URL(window.location.href); url.searchParams.set('defaultSetting', json); const str = url.toString(); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(str).catch(() => {}); } } catch { /* ignore */ } }; if (error) { const { status, message } = error; return <div>{status ? `${status}: ${message}` : message}</div>; } const tableBody = ( <> {!title && ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 8, }} > {table.showTableInfo && ( <div className={styles.info}> Showing {table.tableState.rows.length} of {data.length} rows | {table.tableState.visibleHeaders.length} of {originalHeaders.length} columns </div> )} <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <button onClick={() => table.setShowFilterRow(!table.showFilterRow)} className={`${styles.iconBtn} ${styles.headerBtn} ${table.showFilterRow ? styles.iconBtnActive : ''}`} title={table.showFilterRow ? 'Hide Filters' : 'Show Filters'} > <Filter size={16} /> </button> <button onClick={() => setCustomize((c) => !c)} className={`${styles.iconBtn} ${styles.headerBtn} ${customize ? styles.iconBtnActive : ''}`} title="Toggle customize mode" > <SettingsIcon size={16} /> </button> </div> </div> )} {customize && ( <Toolbar {...table} handleCopyUrl={handleCopyUrl} handleCopyMarkdown={handleCopyMarkdown} handleCopyCsv={handleCopyCsv} handleDownload={handleDownload} /> )} <SettingsPanel {...table} visibleHeaders={table.tableState.visibleHeaders} originalHeaders={originalHeaders} data={data} storageKey={storageKey} /> <DataTable {...table} data={data} originalHeaders={originalHeaders} isCustomize={customize} onDataProcessed={table.setTableState} /> </> ); if (!title) { return ( <div className={`${styles.root} ${styles[table.currentTheme] || styles.lite}`} style={{ ...(table.tableMaxHeight === 'unlimited' ? { minHeight: '100vh' } : {}), ...(table.tableMaxWidth !== 'unlimited' ? { maxWidth: table.tableMaxWidth, margin: '0 auto' } : {}), minWidth: '320px', }} > <div className={styles.container}> <div className={styles.card}>{tableBody}</div> </div> </div> ); } return ( <div className={styles[table.currentTheme] || styles.lite} style={{ marginBottom: 16, border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)', maxWidth: table.tableMaxWidth === 'unlimited' ? '100%' : table.tableMaxWidth, minWidth: '320px', overflowX: 'hidden', ...(table.tableMaxWidth !== 'unlimited' ? { marginLeft: 'auto', marginRight: 'auto' } : {}), }} > <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--control-bg)', borderBottom: '1px solid var(--border)', color: 'var(--text)', }} > <div style={{ fontWeight: 600 }}>{title}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> {table.showTableInfo && ( <div className={styles.info}> Showing {table.tableState.rows.length} of {data.length} rows | {table.tableState.visibleHeaders.length} of {originalHeaders.length} columns </div> )} <button onClick={() => table.setShowFilterRow(!table.showFilterRow)} className={`${styles.iconBtn} ${styles.headerBtn} ${table.showFilterRow ? styles.iconBtnActive : ''}`} title={table.showFilterRow ? 'Hide Filters' : 'Show Filters'} > <Filter size={16} /> </button> <button onClick={() => setCustomize((c) => !c)} className={`${styles.iconBtn} ${styles.headerBtn} ${customize ? styles.iconBtnActive : ''}`} title="Toggle customize mode" > <SettingsIcon size={16} /> </button> <button onClick={() => setCollapsed((c) => !c)} className={`${styles.iconBtn} ${styles.headerBtn}`} title={collapsed ? 'Expand' : 'Collapse'} > {collapsed ? <Plus size={16} /> : <Minus size={16} />} </button> </div> </div> <div style={{ padding: 8, display: collapsed ? 'none' : 'block', maxWidth: '100%', minWidth: '320px', overflowX: 'hidden' }}> <div className={styles.root} style={{ minHeight: 0 }}> <div className={styles.container}> <div className={styles.card}>{tableBody}</div> </div> </div> </div> </div> ); }; export default ReactTableCSV;