UNPKG

@poserjs/react-table-csv

Version:

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

536 lines (518 loc) 21.5 kB
import React, { useState, useRef } from 'react'; import { Hash, SunMoon, Type, Paintbrush, AlignLeft, AlignCenter, AlignRight, Columns, List, WrapText, Eye, EyeOff, Pin, PinOff, Scissors, X, Maximize, Info } from 'lucide-react'; import styles from '../ReactTableCsv.module.css'; const SettingsPanel = ({ showStylePanel, selectedColumn, columnStyles, setSelectedColumn, updateColumnStyle, hiddenColumns, toggleColumnVisibility, setPinnedAnchor, visibleHeaders = [], pinnedIndex, cycleTheme, currentTheme, originalHeaders, data = [], showRowNumbers, setShowRowNumbers, showTableInfo, setShowTableInfo, buildSettings, applySettings, storageKey, tableMaxHeight, setTableMaxHeight, tableMaxWidth, setTableMaxWidth, fontSize, setFontSize, }) => { const [showExport, setShowExport] = useState(false); const [exportText, setExportText] = useState(''); const exportTextareaRef = useRef(null); const handleExportSettings = () => { try { const json = JSON.stringify(buildSettings(), null, 2); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(json).catch(() => { // Ignore clipboard errors (e.g., unsupported browser) }); } setExportText(json); setShowExport(true); } catch { // JSON.stringify may fail for circular structures } }; const handleImportSettings = () => { const input = window.prompt('Paste settings JSON:'); if (!input) return; try { const parsed = JSON.parse(input); applySettings(parsed); window.localStorage.setItem(storageKey, JSON.stringify(parsed)); } catch { alert('Invalid JSON. Settings not applied.'); } }; if (!showStylePanel) return null; const inferTypeForColumn = (col) => { if (!col) return 'text'; let hasAny = false; let allStrings = true; let allNumbers = true; let allIntegers = true; for (const row of data) { const v = row[col]; if (v === '' || v == null) continue; hasAny = true; const t = typeof v; if (t === 'string') { allNumbers = false; } else if (t === 'number') { allStrings = false; if (!Number.isFinite(v)) { allNumbers = false; allIntegers = false; } else if (!Number.isInteger(v)) { allIntegers = false; } } else if (t === 'bigint') { allStrings = false; } else { allStrings = false; allNumbers = false; allIntegers = false; } } if (!hasAny) return 'text'; if (allStrings) return 'text'; if (allNumbers && !allIntegers) return 'number'; if (allNumbers && allIntegers) return 'integer'; return 'text'; }; return ( <div className={styles.stylePanel}> {showExport && ( <div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-label="Export settings JSON"> <div className={styles.modal}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <div style={{ fontWeight: 600 }}>Exported Settings</div> <button className={styles.btn} onClick={() => setShowExport(false)}>Close</button> </div> <textarea ref={exportTextareaRef} className={styles.textarea} readOnly value={exportText} onFocus={(e) => e.currentTarget.select()} /> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 8 }}> <button className={styles.btn} onClick={() => { try { exportTextareaRef.current?.select(); } catch (err) { // Ignore selection issues (e.g., sandboxed iframes) } }} > Select All </button> </div> </div> </div> )} <div className={styles.styleSection}> <label className={styles.label}>Table Options:</label> <label className={styles.checkboxRow}> <input type="checkbox" checked={showRowNumbers} onChange={(e) => setShowRowNumbers(e.target.checked)} /> <Hash size={16} /> <span>Show row numbers</span> </label> <label className={styles.checkboxRow}> <input type="checkbox" checked={showTableInfo} onChange={(e) => setShowTableInfo(e.target.checked)} /> <Info size={16} /> <span>Show table info</span> </label> <div className={styles.widthGroup}> <Maximize size={16} /> <span className={styles.muted}>Max height:</span> <input type="number" placeholder="unlimited" value={tableMaxHeight === 'unlimited' ? '' : parseInt(tableMaxHeight, 10) || ''} onChange={(e) => { const v = e.target.value; const unit = tableMaxHeight === 'unlimited' ? 'px' : tableMaxHeight.endsWith('vh') ? 'vh' : 'px'; if (!v) setTableMaxHeight('unlimited'); else setTableMaxHeight(`${v}${unit}`); }} className={styles.widthInput} disabled={tableMaxHeight === 'unlimited'} /> <select value={tableMaxHeight === 'unlimited' ? 'unlimited' : tableMaxHeight.endsWith('vh') ? 'vh' : 'px'} onChange={(e) => { const unit = e.target.value; if (unit === 'unlimited') { setTableMaxHeight('unlimited'); } else { const num = tableMaxHeight === 'unlimited' ? 0 : parseInt(tableMaxHeight, 10) || 0; setTableMaxHeight(`${num}${unit}`); } }} className={styles.unitSelect} > <option value="unlimited">unlimited</option> <option value="px">px</option> <option value="vh">vh</option> </select> </div> <div className={styles.widthGroup}> <Maximize size={16} /> <span className={styles.muted}>Max width:</span> <input type="number" placeholder="unlimited" value={tableMaxWidth === 'unlimited' ? '' : parseInt(tableMaxWidth, 10) || ''} onChange={(e) => { const v = e.target.value; const unit = tableMaxWidth === 'unlimited' ? 'px' : (tableMaxWidth.endsWith('%') ? '%' : (tableMaxWidth.endsWith('vh') ? 'vh' : 'px')); if (!v) setTableMaxWidth('unlimited'); else setTableMaxWidth(`${v}${unit}`); }} className={styles.widthInput} disabled={tableMaxWidth === 'unlimited'} /> <select value={tableMaxWidth === 'unlimited' ? 'unlimited' : (tableMaxWidth.endsWith('%') ? '%' : (tableMaxWidth.endsWith('vh') ? 'vh' : 'px'))} onChange={(e) => { const unit = e.target.value; if (unit === 'unlimited') { setTableMaxWidth('unlimited'); } else { const num = tableMaxWidth === 'unlimited' ? 0 : parseInt(tableMaxWidth, 10) || 0; setTableMaxWidth(`${num}${unit}`); } }} className={styles.unitSelect} > <option value="unlimited">unlimited</option> <option value="px">px</option> <option value="%">%</option> <option value="vh">vh</option> </select> </div> <div className={styles.widthGroup}> <Type size={16} /> <span className={styles.muted}>Font:</span> <input type="number" value={fontSize} onChange={(e) => { const v = parseInt(e.target.value, 10); if (!Number.isNaN(v)) setFontSize(v); }} className={styles.widthInput} /> <span className={styles.muted}>px</span> </div> <div className={styles.reducerGroup}> <button className={styles.btn} onClick={handleExportSettings} title="Copy settings JSON to clipboard">Export settings</button> <button className={styles.btn} onClick={handleImportSettings} title="Paste settings JSON to import">Import settings</button> <button className={styles.btn} onClick={cycleTheme} title="Cycle table theme"> <SunMoon size={16} /> Theme: {currentTheme} </button> </div> </div> <div className={styles.styleSection}> <label className={styles.label}>Select Column to Style:</label> <select className={styles.select} value={selectedColumn || ''} onChange={(e) => setSelectedColumn(e.target.value)} > <option value="">Choose a column...</option> {originalHeaders.map(header => ( <option key={header} value={header}> {header} {hiddenColumns.has(header) ? '(hidden)' : ''} </option> ))} </select> {selectedColumn && ( <span className={styles.positionInfo}> Position: #{visibleHeaders.indexOf(selectedColumn) + 1} of {originalHeaders.length} </span> )} </div> {selectedColumn && ( <div className={styles.styleOptions}> {/* Line 2: Group/Reducer/Split plus No wrap, Pin, Hide as labeled controls */} <div className={styles.headerBottom}> <label className={styles.checkboxRow} title="Group by this column"> <input type="checkbox" checked={!!columnStyles[selectedColumn]?.groupBy} onChange={(e) => updateColumnStyle(selectedColumn, 'groupBy', e.target.checked)} /> <List size={14} /> <span>Group by this column</span> </label> {!columnStyles[selectedColumn]?.groupBy && ( <div className={styles.reducerGroup}> <label className={styles.smallLabel}>Reducer:</label> <select className={styles.select} value={columnStyles[selectedColumn]?.reducer || 'first'} onChange={(e) => updateColumnStyle(selectedColumn, 'reducer', e.target.value)} > <option value="first">first</option> <option value="last">last</option> <option value="cnt">cnt</option> <option value="rowcnt">rowcnt</option> <option value="unique cnt">unique cnt</option> <option value="unique rowcnt">unique rowcnt</option> <option value="sum">sum</option> <option value="avg">avg</option> <option value="min">min</option> <option value="max">max</option> <option value="min-max">min - max</option> <option value="concat">concat</option> <option value="unique concat">unique concat</option> </select> </div> )} <label className={styles.checkboxRow} title="Split table by this column"> <input type="checkbox" checked={!!columnStyles[selectedColumn]?.splitBy} onChange={(e) => updateColumnStyle(selectedColumn, 'splitBy', e.target.checked)} /> <Scissors size={14} /> <span>Split table by this column</span> </label> <label className={styles.checkboxRow} title="No wrap"> <input type="checkbox" checked={!!columnStyles[selectedColumn]?.noWrap} onChange={(e) => updateColumnStyle(selectedColumn, 'noWrap', e.target.checked)} /> <WrapText size={14} /> <span>No wrap</span> </label> <label className={styles.checkboxRow} title="Pin up to this column"> <input type="checkbox" checked={(() => { const idx = visibleHeaders.indexOf(selectedColumn); return pinnedIndex >= 0 && idx > -1 && idx <= pinnedIndex; })()} onChange={(e) => { const checked = e.target.checked; const idx = visibleHeaders.indexOf(selectedColumn); if (!checked) { if (idx <= 0) setPinnedAnchor(null); else setPinnedAnchor(visibleHeaders[idx - 1] || null); } else { setPinnedAnchor(selectedColumn); } }} /> {(visibleHeaders.indexOf(selectedColumn) <= pinnedIndex && pinnedIndex >= 0) ? <Pin size={14} /> : <PinOff size={14} />} <span>Pin up to this column</span> </label> <label className={styles.checkboxRow} title="Hide column"> <input type="checkbox" checked={hiddenColumns.has(selectedColumn)} onChange={() => toggleColumnVisibility(selectedColumn)} /> {hiddenColumns.has(selectedColumn) ? <Eye size={14} /> : <EyeOff size={14} />} <span>{hiddenColumns.has(selectedColumn) ? 'Hidden' : 'Visible'}</span> </label> </div> <div className={styles.optionRow}> <div className={styles.colorGroup}> <Type size={16} /> <label className={styles.smallLabel}>Text:</label> <input type="color" value={columnStyles[selectedColumn]?.color || '#000000'} onChange={(e) => updateColumnStyle(selectedColumn, 'color', e.target.value)} className={styles.colorInput} title="Text color" /> </div> <div className={styles.colorGroup}> <Paintbrush size={16} /> <label className={styles.smallLabel}>Background:</label> <input type="color" value={columnStyles[selectedColumn]?.backgroundColor || '#ffffff'} onChange={(e) => updateColumnStyle(selectedColumn, 'backgroundColor', e.target.value)} className={styles.colorInput} title="Background color" /> <button onClick={() => updateColumnStyle(selectedColumn, 'backgroundColor', 'transparent')} className={styles.smallBtn} title="Reset to transparent" > Clear </button> </div> <button onClick={() => updateColumnStyle(selectedColumn, 'bold', !columnStyles[selectedColumn]?.bold)} className={`${styles.btnToggle} ${columnStyles[selectedColumn]?.bold ? styles.active : ''}`} > <Type size={16} /> Bold </button> <div className={styles.alignGroup}> <button onClick={() => updateColumnStyle(selectedColumn, 'align', 'left')} className={`${styles.alignBtn} ${columnStyles[selectedColumn]?.align === 'left' || !columnStyles[selectedColumn]?.align ? styles.active : ''}`} > <AlignLeft size={16} /> </button> <button onClick={() => updateColumnStyle(selectedColumn, 'align', 'center')} className={`${styles.alignBtn} ${columnStyles[selectedColumn]?.align === 'center' ? styles.active : ''}`} > <AlignCenter size={16} /> </button> <button onClick={() => updateColumnStyle(selectedColumn, 'align', 'right')} className={`${styles.alignBtn} ${columnStyles[selectedColumn]?.align === 'right' ? styles.active : ''}`} > <AlignRight size={16} /> </button> </div> <div className={styles.widthGroup}> <Columns size={16} /> <span className={styles.muted}>Width:</span> <input type="text" placeholder="auto" value={columnStyles[selectedColumn]?.width?.replace('auto', '').replace('px', '').replace('%', '') || ''} onChange={(e) => { const value = e.target.value; const unit = columnStyles[selectedColumn]?.width?.includes('%') ? '%' : 'px'; if (!value) { updateColumnStyle(selectedColumn, 'width', 'auto'); } else if (!isNaN(value)) { updateColumnStyle(selectedColumn, 'width', `${value}${unit}`); } }} className={styles.widthInput} /> <select value={columnStyles[selectedColumn]?.width?.includes('%') ? '%' : 'px'} onChange={(e) => { const currentWidth = columnStyles[selectedColumn]?.width; if (currentWidth && currentWidth !== 'auto') { const numValue = parseInt(currentWidth); if (!isNaN(numValue)) { updateColumnStyle(selectedColumn, 'width', `${numValue}${e.target.value}`); } } }} className={styles.unitSelect} > <option value="px">px</option> <option value="%">%</option> </select> </div> {/* Line 4: Sort, Type, Number format */} <div className={styles.headerBottom}> <div className={styles.reducerGroup}> <label className={styles.smallLabel}>Sort:</label> <select className={styles.select} value={columnStyles[selectedColumn]?.sort || 'none'} onChange={(e) => updateColumnStyle(selectedColumn, 'sort', e.target.value)} > <option value="none">none</option> <option value="up">up</option> <option value="down">down</option> <option value="up numbers">up numbers</option> <option value="down numbers">down numbers</option> </select> </div> <div className={styles.reducerGroup}> <label className={styles.smallLabel}>Type:</label> <select className={styles.select} value={columnStyles[selectedColumn]?.type || 'auto'} onChange={(e) => updateColumnStyle(selectedColumn, 'type', e.target.value)} > <option value="auto">auto</option> <option value="text">text</option> <option value="number">number</option> <option value="integer">integer</option> </select> <span className={styles.muted} style={{ marginLeft: 8 }}> inferred: {inferTypeForColumn(selectedColumn)} </span> </div> <div className={styles.reducerGroup}> <label className={styles.smallLabel}>Number format:</label> <select className={styles.select} value={columnStyles[selectedColumn]?.numFormat || 'general'} onChange={(e) => updateColumnStyle(selectedColumn, 'numFormat', e.target.value)} > <option value="general">general</option> <option value="int">no decimals</option> <option value="fixed2">2 decimals</option> <option value="thousand">thousands</option> <option value="thousand2">thousands + 2 decimals</option> <option value="currency">$ currency</option> <option value="currency-red">$ currency, red if negative</option> <option value="currency-paren-red">$ currency, parentheses + red if negative</option> <option value="paren-red">parentheses + red if negative</option> </select> </div> </div> </div> </div> )} {/* Hidden columns list */} {hiddenColumns.size > 0 && ( <div className={styles.hiddenColumns}> <p className={styles.hiddenTitle}>Hidden Columns:</p> <div className={styles.hiddenList}> {Array.from(hiddenColumns).map(col => ( <button key={col} onClick={() => toggleColumnVisibility(col)} className={styles.hiddenPill} title="Click to show column" > <EyeOff size={14} /> {col} <X size={14} /> </button> ))} </div> </div> )} </div> ); }; export default SettingsPanel;