import type { AggregationFunction, CalculatedField, PivotGroupStart, PivotLayout, PivotResult, PivotValueField } from '@smallwebco/tinypivot-core' import { computeRowSpans, getAggregationLabel, getAggregationSymbol } from '@smallwebco/tinypivot-core' /** * Pivot Table Skeleton + Data Display for React * Visual layout for pivot configuration and results */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useLicense } from '../hooks/useLicense' interface ActiveFilter { column: string valueCount: number values?: string[] displayText?: string isRange?: boolean } interface PivotSkeletonProps { rowFields: string[] columnFields: string[] valueFields: PivotValueField[] calculatedFields?: CalculatedField[] isConfigured: boolean draggingField: string | null pivotResult: PivotResult | null hasActiveFieldFilters?: boolean fontSize?: 'xs' | 'sm' | 'base' activeFilters?: ActiveFilter[] | null totalRowCount?: number filteredRowCount?: number enableDrillDown?: boolean enableDrillThrough?: boolean onAddRowField: (field: string) => void onRemoveRowField: (field: string) => void onAddColumnField: (field: string) => void onRemoveColumnField: (field: string) => void onAddValueField: (field: string, aggregation: AggregationFunction) => void onRemoveValueField: (field: string, aggregation: AggregationFunction) => void onUpdateAggregation: (field: string, oldAgg: AggregationFunction, newAgg: AggregationFunction) => void onReorderRowFields: (fields: string[]) => void onReorderColumnFields: (fields: string[]) => void onToggleCollapse?: (key: string, altKey: boolean) => void onDrillThroughCell?: (payload: { rowPath: string[], columnPath: string[], valueFieldIndex: number }) => void theme?: string pivotLayout?: PivotLayout } export function PivotSkeleton({ rowFields, columnFields, valueFields, calculatedFields, isConfigured, draggingField, pivotResult, hasActiveFieldFilters, fontSize = 'xs', activeFilters, totalRowCount, filteredRowCount, enableDrillDown, enableDrillThrough, onAddRowField, onRemoveRowField, onAddColumnField, onRemoveColumnField, onAddValueField, onRemoveValueField, onReorderRowFields, onReorderColumnFields, onToggleCollapse, onDrillThroughCell, theme, pivotLayout = 'grouped', }: PivotSkeletonProps) { const { showWatermark, canUsePivot, isDemo } = useLicense() // Helper to get display name for value fields (resolves calc IDs to names) const getValueFieldDisplayName = useCallback((field: string): string => { if (field.startsWith('calc:')) { const calcId = field.replace('calc:', '') const calcField = calculatedFields?.find(c => c.id === calcId) return calcField?.name || field } return field }, [calculatedFields]) // Helper to check if field is a calculated field const isCalculatedField = useCallback((field: string): boolean => { return field.startsWith('calc:') }, []) // Drag state const [dragOverArea, setDragOverArea] = useState<'row' | 'column' | 'value' | null>(null) // Reorder drag state const [reorderDragSource, setReorderDragSource] = useState<{ zone: 'row' | 'column', index: number } | null>(null) const [reorderDropTarget, setReorderDropTarget] = useState<{ zone: 'row' | 'column', index: number } | null>(null) // Sorting type SortTarget = 'row' | number const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') const [sortTarget, setSortTarget] = useState('row') const toggleSort = useCallback((target: SortTarget = 'row') => { if (sortTarget === target) { setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc')) } else { setSortTarget(target) setSortDirection('asc') } }, [sortTarget]) // Selection state for cell selection and copy const [selectedCell, setSelectedCell] = useState<{ row: number, col: number } | null>(null) const [selectionStart, setSelectionStart] = useState<{ row: number, col: number } | null>(null) const [selectionEnd, setSelectionEnd] = useState<{ row: number, col: number } | null>(null) const [isSelecting, setIsSelecting] = useState(false) const [showCopyToast, setShowCopyToast] = useState(false) const [copyToastMessage, setCopyToastMessage] = useState('') const selectionBounds = useMemo(() => { if (!selectionStart || !selectionEnd) return null return { minRow: Math.min(selectionStart.row, selectionEnd.row), maxRow: Math.max(selectionStart.row, selectionEnd.row), minCol: Math.min(selectionStart.col, selectionEnd.col), maxCol: Math.max(selectionStart.col, selectionEnd.col), } }, [selectionStart, selectionEnd]) const handleCellMouseDown = useCallback( (rowIndex: number, colIndex: number, event: React.MouseEvent) => { event.preventDefault() if (event.shiftKey && selectedCell) { setSelectionEnd({ row: rowIndex, col: colIndex }) } else { setSelectedCell({ row: rowIndex, col: colIndex }) setSelectionStart({ row: rowIndex, col: colIndex }) setSelectionEnd({ row: rowIndex, col: colIndex }) setIsSelecting(true) } }, [selectedCell], ) const handleCellMouseEnter = useCallback( (rowIndex: number, colIndex: number) => { if (isSelecting) { setSelectionEnd({ row: rowIndex, col: colIndex }) } }, [isSelecting], ) const isCellSelected = useCallback( (rowIndex: number, colIndex: number): boolean => { if (!selectionBounds) { return selectedCell?.row === rowIndex && selectedCell?.col === colIndex } const { minRow, maxRow, minCol, maxCol } = selectionBounds return rowIndex >= minRow && rowIndex <= maxRow && colIndex >= minCol && colIndex <= maxCol }, [selectionBounds, selectedCell], ) // Mouse up handler useEffect(() => { const handleMouseUp = () => setIsSelecting(false) document.addEventListener('mouseup', handleMouseUp) return () => document.removeEventListener('mouseup', handleMouseUp) }, []) // Sorted row indices const sortedRowIndices = useMemo(() => { if (!pivotResult) return [] const indices = pivotResult.rowHeaders.map((_, i) => i) const headers = pivotResult.rowHeaders const data = pivotResult.data indices.sort((a, b) => { let cmp: number if (sortTarget === 'row') { const aHeader = headers[a]?.join(' / ') || '' const bHeader = headers[b]?.join(' / ') || '' cmp = aHeader.localeCompare(bHeader, undefined, { numeric: true, sensitivity: 'base' }) } else { const colIdx = sortTarget as number const aVal = data[a]?.[colIdx]?.value ?? null const bVal = data[b]?.[colIdx]?.value ?? null if (aVal === null && bVal === null) cmp = 0 else if (aVal === null) cmp = 1 else if (bVal === null) cmp = -1 else cmp = aVal - bVal } return sortDirection === 'asc' ? cmp : -cmp }) return indices }, [pivotResult, sortTarget, sortDirection]) // Grouped layout: per-cell rowspan information (indexed by [sortedPosition][columnIndex]) const rowSpans = useMemo(() => { if (!pivotResult || pivotLayout !== 'grouped') return null return computeRowSpans(pivotResult.rowHeaders, sortedRowIndices, rowFields.length) }, [pivotResult, pivotLayout, sortedRowIndices, rowFields.length]) // Copy selection to clipboard const copySelectionToClipboard = useCallback(() => { if (!selectionBounds || !pivotResult) return const { minRow, maxRow, minCol, maxCol } = selectionBounds const lines: string[] = [] for (let r = minRow; r <= maxRow; r++) { const sortedIdx = sortedRowIndices[r] if (sortedIdx === undefined) continue const rowValues: string[] = [] for (let c = minCol; c <= maxCol; c++) { const cell = pivotResult.data[sortedIdx]?.[c] rowValues.push(cell?.formattedValue ?? '') } lines.push(rowValues.join('\t')) } const text = lines.join('\n') navigator.clipboard.writeText(text).then(() => { const cellCount = (maxRow - minRow + 1) * (maxCol - minCol + 1) setCopyToastMessage(`Copied ${cellCount} cell${cellCount > 1 ? 's' : ''}`) setShowCopyToast(true) setTimeout(setShowCopyToast, 2000, false) }).catch((err) => { console.error('Copy failed:', err) }) }, [selectionBounds, pivotResult, sortedRowIndices]) // Keyboard handler for copy useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if (!selectionBounds) return if ((event.ctrlKey || event.metaKey) && event.key === 'c') { event.preventDefault() copySelectionToClipboard() return } if (event.key === 'Escape') { setSelectedCell(null) setSelectionStart(null) setSelectionEnd(null) } } document.addEventListener('keydown', handleKeydown) return () => document.removeEventListener('keydown', handleKeydown) }, [selectionBounds, copySelectionToClipboard]) // Selection statistics for footer const selectionStats = useMemo(() => { if (!selectionBounds || !pivotResult) return null const { minRow, maxRow, minCol, maxCol } = selectionBounds const values: number[] = [] let count = 0 for (let r = minRow; r <= maxRow; r++) { const sortedIdx = sortedRowIndices[r] if (sortedIdx === undefined) continue for (let c = minCol; c <= maxCol; c++) { const cell = pivotResult.data[sortedIdx]?.[c] count++ if (cell?.value !== null && cell?.value !== undefined && typeof cell.value === 'number') { values.push(cell.value) } } } if (count <= 1) return null const sum = values.reduce((a, b) => a + b, 0) const avg = values.length > 0 ? sum / values.length : 0 return { count, numericCount: values.length, sum, avg, } }, [selectionBounds, pivotResult, sortedRowIndices]) const formatStatValue = useCallback((val: number): string => { if (Math.abs(val) >= 1_000_000) return `${(val / 1_000_000).toFixed(2)}M` if (Math.abs(val) >= 1_000) return `${(val / 1_000).toFixed(2)}K` return val.toFixed(2) }, []) // Column headers const columnHeaderCells = useMemo(() => { if (!pivotResult || pivotResult.headers.length === 0) { return [ valueFields.map(vf => ({ label: `${getValueFieldDisplayName(vf.field)} (${getAggregationLabel(vf.aggregation)})`, colspan: 1, })), ] } const result: Array> = [] for (let level = 0; level < pivotResult.headers.length; level++) { const headerRow = pivotResult.headers[level] const cells: Array<{ label: string, colspan: number }> = [] let i = 0 while (i < headerRow.length) { const value = headerRow[i] let colspan = 1 while (i + colspan < headerRow.length && headerRow[i + colspan] === value) { colspan++ } cells.push({ label: value, colspan }) i += colspan } result.push(cells) } return result }, [pivotResult, valueFields]) // Filter status const hasActiveFilters = activeFilters && activeFilters.length > 0 const filterSummary = useMemo(() => { if (!activeFilters || activeFilters.length === 0) return '' return activeFilters.map(f => f.column).join(', ') }, [activeFilters]) // Detailed filter tooltip const [showFilterTooltip, setShowFilterTooltip] = useState(false) const filterTooltipDetails = useMemo(() => { if (!activeFilters || activeFilters.length === 0) return [] return activeFilters.map((f) => { // Handle range filters if (f.isRange && f.displayText) { return { column: f.column, displayText: f.displayText, isRange: true, values: [] as string[], remaining: 0, } } // Handle value filters const values = f.values || [] const maxDisplay = 5 const displayValues = values.slice(0, maxDisplay) const remaining = values.length - maxDisplay return { column: f.column, values: displayValues, remaining: remaining > 0 ? remaining : 0, isRange: false, } }) }, [activeFilters]) // Drag handlers const handleDragOver = useCallback( (area: 'row' | 'column' | 'value', event: React.DragEvent) => { event.preventDefault() event.dataTransfer!.dropEffect = 'move' setDragOverArea(area) }, [], ) const handleDragLeave = useCallback(() => { setDragOverArea(null) }, []) const handleDrop = useCallback( (area: 'row' | 'column' | 'value', event: React.DragEvent) => { event.preventDefault() const field = event.dataTransfer?.getData('text/plain') if (!field || field.startsWith('reorder:')) { setDragOverArea(null) return } if (rowFields.includes(field)) onRemoveRowField(field) if (columnFields.includes(field)) onRemoveColumnField(field) const existingValue = valueFields.find(v => v.field === field) if (existingValue) onRemoveValueField(field, existingValue.aggregation) switch (area) { case 'row': onAddRowField(field) break case 'column': onAddColumnField(field) break case 'value': onAddValueField(field, 'sum') break } setDragOverArea(null) }, [rowFields, columnFields, valueFields, onAddRowField, onRemoveRowField, onAddColumnField, onRemoveColumnField, onAddValueField, onRemoveValueField], ) // Reorder handlers for chips within zones const handleChipDragStart = useCallback( (zone: 'row' | 'column', index: number, event: React.DragEvent) => { setReorderDragSource({ zone, index }) event.dataTransfer!.effectAllowed = 'move' event.dataTransfer!.setData('text/plain', `reorder:${zone}:${index}`) // Clear any zone drag-over state requestAnimationFrame(() => { setDragOverArea(null) }) }, [], ) const handleChipDragEnd = useCallback(() => { setReorderDragSource(null) setReorderDropTarget(null) }, []) const handleChipDragOver = useCallback( (zone: 'row' | 'column', index: number, event: React.DragEvent) => { event.preventDefault() // Only handle reorder within same zone if (reorderDragSource && reorderDragSource.zone === zone) { event.dataTransfer!.dropEffect = 'move' setReorderDropTarget({ zone, index }) } }, [reorderDragSource], ) const handleChipDragLeave = useCallback(() => { setReorderDropTarget(null) }, []) const handleChipDrop = useCallback( (zone: 'row' | 'column', targetIndex: number, event: React.DragEvent) => { event.preventDefault() event.stopPropagation() if (!reorderDragSource || reorderDragSource.zone !== zone) { return } const sourceIndex = reorderDragSource.index if (sourceIndex === targetIndex) { setReorderDragSource(null) setReorderDropTarget(null) return } // Create reordered array const fields = zone === 'row' ? [...rowFields] : [...columnFields] const [movedField] = fields.splice(sourceIndex, 1) fields.splice(targetIndex, 0, movedField) // Emit reorder event if (zone === 'row') { onReorderRowFields(fields) } else { onReorderColumnFields(fields) } setReorderDragSource(null) setReorderDropTarget(null) }, [reorderDragSource, rowFields, columnFields, onReorderRowFields, onReorderColumnFields], ) const isChipDragSource = useCallback( (zone: 'row' | 'column', index: number): boolean => { return reorderDragSource?.zone === zone && reorderDragSource?.index === index }, [reorderDragSource], ) const isChipDropTarget = useCallback( (zone: 'row' | 'column', index: number): boolean => { return reorderDropTarget?.zone === zone && reorderDropTarget?.index === index }, [reorderDropTarget], ) const currentFontSize = fontSize // Computed column paths: for each colIdx, the column field path const columnPaths = useMemo((): string[][] => { if (!pivotResult || columnFields.length === 0) return [] const numCols = pivotResult.data[0]?.length ?? 0 const numValueFields = valueFields.length || 1 const paths: string[][] = [] for (let colIdx = 0; colIdx < numCols; colIdx++) { const colKeyIndex = Math.floor(colIdx / numValueFields) const path: string[] = [] for (let h = 0; h < columnFields.length; h++) { const headerRow = pivotResult.headers[h] if (headerRow) { path.push(headerRow[colKeyIndex * numValueFields] ?? '') } } paths.push(path) } return paths }, [pivotResult, columnFields, valueFields]) // Find groupStart for a given row and column index const findGroupStart = useCallback((sortedIdx: number, idx: number): PivotGroupStart | null => { if (!pivotResult) return null const meta = pivotResult.rowMeta[sortedIdx] if (!meta) return null return meta.groupStarts.find(gs => gs.depth === idx) ?? null }, [pivotResult]) // Handle chevron click const handleChevronClick = useCallback((groupStart: PivotGroupStart, event: React.MouseEvent) => { onToggleCollapse?.(groupStart.key, event.altKey) }, [onToggleCollapse]) // Handle drill-through on a data cell const handleDrillThroughCell = useCallback((sortedIdx: number, colIdx: number) => { if (!pivotResult) return const meta = pivotResult.rowMeta[sortedIdx] const rowPath = meta ? meta.path : [] const colPath = columnPaths[colIdx] ?? [] const numValueFields = valueFields.length || 1 const valueFieldIndex = colIdx % numValueFields onDrillThroughCell?.({ rowPath, columnPath: colPath, valueFieldIndex }) }, [pivotResult, columnPaths, valueFields, onDrillThroughCell]) // Handle drill-through on a row total cell const handleDrillThroughRowTotal = useCallback((sortedIdx: number) => { if (!pivotResult) return const meta = pivotResult.rowMeta[sortedIdx] const rowPath = meta ? meta.path : [] onDrillThroughCell?.({ rowPath, columnPath: [], valueFieldIndex: 0 }) }, [pivotResult, onDrillThroughCell]) // Handle drill-through on a column total cell const handleDrillThroughColTotal = useCallback((colIdx: number) => { const colPath = columnPaths[colIdx] ?? [] const numValueFields = valueFields.length || 1 const valueFieldIndex = colIdx % numValueFields onDrillThroughCell?.({ rowPath: [], columnPath: colPath, valueFieldIndex }) }, [columnPaths, valueFields, onDrillThroughCell]) // Handle drill-through on the grand total cell const handleDrillThroughGrandTotal = useCallback(() => { onDrillThroughCell?.({ rowPath: [], columnPath: [], valueFieldIndex: 0 }) }, [onDrillThroughCell]) // Calculate width per row header column const rowHeaderWidth = 180 const rowHeaderColWidth = useMemo(() => { const numCols = Math.max(rowFields.length, 1) return Math.max(rowHeaderWidth / numCols, 80) }, [rowFields.length]) // Calculate left offset for each row header column (for sticky positioning) const getRowHeaderLeftOffset = useCallback((fieldIdx: number): number => { return fieldIdx * rowHeaderColWidth }, [rowHeaderColWidth]) return (
{/* Copy Toast */} {showCopyToast && (
{copyToastMessage}
)} {/* Header Bar */}
Pivot Table
{hasActiveFilters && (
setShowFilterTooltip(true)} onMouseLeave={() => setShowFilterTooltip(false)} > Filtered: {' '} {filterSummary} {filteredRowCount !== undefined && totalRowCount !== undefined && ( ( {filteredRowCount.toLocaleString()} {' '} of {' '} {totalRowCount.toLocaleString()} {' '} rows) )} {/* Tooltip */} {showFilterTooltip && (
Active Filters
{filterTooltipDetails.map(filter => (
{filter.column}
{filter.isRange ? ( {filter.displayText} ) : ( <> {filter.values.map((val, idx) => ( {val} ))} {filter.remaining > 0 && ( + {filter.remaining} {' '} more )} )}
))} {filteredRowCount !== undefined && totalRowCount !== undefined && (
Showing {' '} {filteredRowCount.toLocaleString()} {' '} of {' '} {totalRowCount.toLocaleString()} {' '} rows
)}
)}
)} {isConfigured && (
{rowFields.length} {' '} row {rowFields.length !== 1 ? 's' : ''} {columnFields.length} {' '} col {columnFields.length !== 1 ? 's' : ''} {valueFields.length} {' '} val {valueFields.length !== 1 ? 's' : ''}
)}
{/* Fallback message */} {!canUsePivot ? (

Pivot Unavailable

Pivot mode could not be enabled in this session. Try reloading the page.

) : ( <> {/* Config Bar */}
{/* Row drop zone */}
handleDragOver('row', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop('row', e)} >
Rows
{rowFields.map((field, idx) => (
handleChipDragStart('row', idx, e)} onDragEnd={handleChipDragEnd} onDragOver={e => handleChipDragOver('row', idx, e)} onDragLeave={handleChipDragLeave} onDrop={e => handleChipDrop('row', idx, e)} > ⋮⋮ {field}
))} {rowFields.length === 0 && Drop here}
{/* Column drop zone */}
handleDragOver('column', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop('column', e)} >
Columns
{columnFields.map((field, idx) => (
handleChipDragStart('column', idx, e)} onDragEnd={handleChipDragEnd} onDragOver={e => handleChipDragOver('column', idx, e)} onDragLeave={handleChipDragLeave} onDrop={e => handleChipDrop('column', idx, e)} > ⋮⋮ {field}
))} {columnFields.length === 0 && Drop here}
{/* Values drop zone */}
handleDragOver('value', e)} onDragLeave={handleDragLeave} onDrop={e => handleDrop('value', e)} >
Σ Values
{valueFields.map(vf => (
{isCalculatedField(vf.field) ? 'ƒ' : getAggregationSymbol(vf.aggregation)} {getValueFieldDisplayName(vf.field)}
))} {valueFields.length === 0 && Drop numeric}
{/* Placeholder when not configured */} {(!isConfigured || !pivotResult) && (
{valueFields.length === 0 ? ( <> Add a {' '} Values {' '} field to see your pivot table ) : rowFields.length === 0 && columnFields.length === 0 ? ( <> Add {' '} Row {' '} or {' '} Column {' '} fields to group your data ) : hasActiveFieldFilters ? ( 'All rows are hidden by axis filters — adjust or clear the filters to see results' ) : ( 'Your pivot table will appear here' )}
)} {/* Data Table */} {isConfigured && pivotResult && (
{columnHeaderCells.map((headerRow, levelIdx) => ( {levelIdx === 0 && (rowFields.length > 0 ? rowFields : ['Rows']).map((field, fieldIdx) => ( ))} {headerRow.map((cell, idx) => ( ))} {pivotResult.rowTotals.length > 0 && levelIdx === 0 && ( )} ))} {sortedRowIndices.map((sortedIdx, rowPos) => ( {/* Grouped layout: merge repeated parent cells via rowspan; omit skipped cells */} {rowSpans ? pivotResult.rowHeaders[sortedIdx].map((val, idx) => { if (!rowSpans[rowPos][idx].render) return null const groupStart = findGroupStart(sortedIdx, idx) return ( ) }) /* Tabular layout: render every cell, always rowspan=1 (current behavior) */ : pivotResult.rowHeaders[sortedIdx].map((val, idx) => { const groupStart = findGroupStart(sortedIdx, idx) return ( ) })} {pivotResult.data[sortedIdx].map((cell, colIdx) => ( ))} {pivotResult.rowTotals[sortedIdx] && ( )} ))} {pivotResult.columnTotals.length > 0 && ( {pivotResult.columnTotals.map((cell, colIdx) => ( ))} {pivotResult.rowTotals.length > 0 && ( )} )}
toggleSort('row')} >
{field} {(fieldIdx === rowFields.length - 1 || rowFields.length === 0) && ( {sortTarget === 'row' ? (sortDirection === 'asc' ? '↑' : '↓') : '⇅'} )}
levelIdx === columnHeaderCells.length - 1 && toggleSort(idx)} >
{cell.label} {levelIdx === columnHeaderCells.length - 1 && ( {sortTarget === idx ? sortDirection === 'asc' ? '↑' : '↓' : '⇅'} )}
Total
{ e.stopPropagation(); handleChevronClick(groupStart, e) } : undefined} > {enableDrillDown !== false && groupStart && ( {groupStart.isCollapsed ? '▸' : '▾'} )} {val} {enableDrillDown !== false && groupStart && ( handleChevronClick(groupStart, e)} > {groupStart.isCollapsed ? '▸' : '▾'} )} {val} handleCellMouseDown(rowPos, colIdx, e)} onMouseEnter={() => handleCellMouseEnter(rowPos, colIdx)} onDoubleClick={enableDrillThrough !== false ? () => handleDrillThroughCell(sortedIdx, colIdx) : undefined} > {cell.formattedValue} handleDrillThroughRowTotal(sortedIdx) : undefined} > {pivotResult.rowTotals[sortedIdx].formattedValue}
Total handleDrillThroughColTotal(colIdx) : undefined} > {cell.formattedValue} handleDrillThroughGrandTotal() : undefined} > {pivotResult.grandTotal.formattedValue}
)} {/* Footer */} {isConfigured && pivotResult && (
{pivotResult.rowHeaders.length} {' '} rows × {pivotResult.data[0]?.length || 0} {' '} columns {selectionStats && selectionStats.count > 1 && (
Count: {selectionStats.count} {selectionStats.numericCount > 0 && ( <> | Sum: {formatStatValue(selectionStats.sum)} | Avg: {formatStatValue(selectionStats.avg)} )}
)}
)} )} {/* Watermark / Demo Banner */} {showWatermark && canUsePivot && (
{isDemo ? ( <> DEMO Pro features unlocked for evaluation Get Pro License → ) : ( Powered by TinyPivot )}
)}
) }