Spaces:
Running
Running
File size: 8,742 Bytes
d6c9678 614e632 0ad99f2 614e632 d0f2c73 d6c9678 0ad99f2 614e632 0ad99f2 614e632 0ad99f2 614e632 0ad99f2 614e632 d0f2c73 f7fe834 d6c9678 614e632 d6c9678 0ad99f2 d6c9678 614e632 d0f2c73 614e632 d0f2c73 614e632 0ad99f2 614e632 d0f2c73 614e632 d6c9678 614e632 f7fe834 614e632 f7fe834 d6c9678 f7fe834 | 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 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | import React from 'react'
const LIMIAR_RENDERIZACAO_VIRTUAL = 1500
const ESTIMATIVA_ALTURA_LINHA_PX = 33
const OVERSCAN_LINHAS = 40
const MIN_JANELA_LINHAS = 220
const SORT_COLLATOR = new Intl.Collator('pt-BR', { numeric: true, sensitivity: 'base' })
function normalizeSortText(value) {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function parseSortableNumber(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
const raw = normalizeSortText(value)
if (!raw) return null
const compact = raw
.replace(/\u00a0/g, '')
.replace(/\s+/g, '')
.replace('%', '')
if (!compact) return null
const candidates = [
compact,
compact.replace(/\./g, '').replace(',', '.'),
compact.replace(/,/g, ''),
]
for (let index = 0; index < candidates.length; index += 1) {
const parsed = Number(candidates[index])
if (Number.isFinite(parsed)) return parsed
}
return null
}
function DataTable({
table,
maxHeight = 320,
highlightedRowIndices = null,
highlightIndexColumn = 'Índice',
highlightClassName = 'table-row-highlight',
}) {
if (!table || !table.columns || !table.rows) {
return <div className="empty-box">Sem dados.</div>
}
const columns = Array.isArray(table.columns) ? table.columns : []
const sourceRows = Array.isArray(table.rows) ? table.rows : []
const [sortConfig, setSortConfig] = React.useState(null)
const sortedRows = React.useMemo(() => {
if (!sortConfig || !columns.includes(sortConfig.column)) return sourceRows
const direction = sortConfig.direction === 'desc' ? 'desc' : 'asc'
const column = sortConfig.column
const indexedRows = sourceRows.map((row, index) => ({ row, index }))
indexedRows.sort((leftItem, rightItem) => {
const leftValue = leftItem.row?.[column]
const rightValue = rightItem.row?.[column]
const leftText = normalizeSortText(leftValue)
const rightText = normalizeSortText(rightValue)
const leftEmpty = leftText === ''
const rightEmpty = rightText === ''
if (leftEmpty || rightEmpty) {
if (leftEmpty && rightEmpty) return leftItem.index - rightItem.index
return leftEmpty ? 1 : -1
}
const leftNumber = parseSortableNumber(leftValue)
const rightNumber = parseSortableNumber(rightValue)
let comparison = 0
if (leftNumber !== null && rightNumber !== null) {
if (leftNumber < rightNumber) comparison = -1
else if (leftNumber > rightNumber) comparison = 1
} else {
comparison = SORT_COLLATOR.compare(leftText, rightText)
}
if (comparison === 0) return leftItem.index - rightItem.index
return direction === 'asc' ? comparison : -comparison
})
return indexedRows.map((item) => item.row)
}, [columns, sourceRows, sortConfig])
const totalRows = sortedRows.length
const colSpan = Math.max(1, columns.length)
const virtualizacaoAtiva = totalRows > LIMIAR_RENDERIZACAO_VIRTUAL
const wrapperRef = React.useRef(null)
const [scrollTop, setScrollTop] = React.useState(0)
const [viewportHeight, setViewportHeight] = React.useState(Number(maxHeight) || 320)
const tableIdentity = React.useMemo(() => {
const colunas = columns.join("|")
return `${colunas}::${sourceRows.length}`
}, [columns, sourceRows.length])
React.useEffect(() => {
setSortConfig((prev) => {
if (!prev) return prev
if (!columns.includes(prev.column)) return null
return prev
})
}, [columns])
React.useEffect(() => {
setScrollTop(0)
if (wrapperRef.current) {
wrapperRef.current.scrollTop = 0
setViewportHeight(wrapperRef.current.clientHeight || Number(maxHeight) || 320)
}
}, [tableIdentity, maxHeight])
React.useEffect(() => {
if (!wrapperRef.current) return undefined
const target = wrapperRef.current
if (typeof ResizeObserver === 'undefined') return undefined
const observer = new ResizeObserver(() => {
setViewportHeight(target.clientHeight || Number(maxHeight) || 320)
})
observer.observe(target)
return () => observer.disconnect()
}, [maxHeight])
const onWrapperScroll = React.useCallback((event) => {
if (!virtualizacaoAtiva) return
setScrollTop(event.currentTarget.scrollTop || 0)
}, [virtualizacaoAtiva])
const onToggleSort = React.useCallback((column) => {
setSortConfig((prev) => {
if (!prev || prev.column !== column) {
return { column, direction: 'asc' }
}
if (prev.direction === 'asc') {
return { column, direction: 'desc' }
}
return null
})
}, [])
const alturaViewport = Math.max(1, Number(viewportHeight) || Number(maxHeight) || 320)
const linhasVisiveis = Math.max(1, Math.ceil(alturaViewport / ESTIMATIVA_ALTURA_LINHA_PX))
const janelaLinhas = Math.max(MIN_JANELA_LINHAS, linhasVisiveis + (OVERSCAN_LINHAS * 2))
let startIndex = 0
let endIndex = totalRows
if (virtualizacaoAtiva) {
const primeiraLinhaVisivel = Math.max(0, Math.floor(scrollTop / ESTIMATIVA_ALTURA_LINHA_PX))
startIndex = Math.max(0, primeiraLinhaVisivel - OVERSCAN_LINHAS)
endIndex = Math.min(totalRows, startIndex + janelaLinhas)
if (endIndex - startIndex < janelaLinhas && startIndex > 0) {
startIndex = Math.max(0, endIndex - janelaLinhas)
}
}
const rowsToRender = virtualizacaoAtiva
? sortedRows.slice(startIndex, endIndex)
: sortedRows
const topSpacerHeight = virtualizacaoAtiva ? startIndex * ESTIMATIVA_ALTURA_LINHA_PX : 0
const bottomSpacerHeight = virtualizacaoAtiva ? Math.max(0, (totalRows - endIndex) * ESTIMATIVA_ALTURA_LINHA_PX) : 0
const highlightedSet = Array.isArray(highlightedRowIndices) && highlightedRowIndices.length > 0
? new Set(highlightedRowIndices.map((item) => String(item)))
: null
return (
<div ref={wrapperRef} className="table-wrapper" style={{ maxHeight }} onScroll={onWrapperScroll}>
<table>
<thead>
<tr>
{columns.map((col) => {
const isActiveSort = sortConfig?.column === col
const direction = isActiveSort ? sortConfig.direction : null
const sortIndicator = !direction ? '-' : (direction === 'asc' ? '^' : 'v')
const ariaSort = direction === 'asc' ? 'ascending' : direction === 'desc' ? 'descending' : 'none'
return (
<th key={col} aria-sort={ariaSort}>
<button
type="button"
className={`table-sort-trigger${isActiveSort ? ' is-active' : ''}`}
onClick={() => onToggleSort(col)}
title={isActiveSort ? `Ordenado por ${col} (${direction === 'asc' ? 'crescente' : 'decrescente'})` : `Ordenar por ${col}`}
>
<span className="table-sort-label">{col}</span>
<span className="table-sort-indicator" aria-hidden="true">{sortIndicator}</span>
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{virtualizacaoAtiva && topSpacerHeight > 0 ? (
<tr className="table-virtual-spacer" aria-hidden="true">
<td colSpan={colSpan} style={{ height: `${topSpacerHeight}px` }} />
</tr>
) : null}
{rowsToRender.map((row, i) => {
const absoluteIndex = virtualizacaoAtiva ? startIndex + i : i
const rowIndex = row?.[highlightIndexColumn]
const rowClassName = highlightedSet && rowIndex != null && highlightedSet.has(String(rowIndex))
? highlightClassName
: ''
return (
<tr key={absoluteIndex} className={rowClassName}>
{columns.map((col) => (
<td key={`${absoluteIndex}-${col}`}>{String(row[col] ?? '')}</td>
))}
</tr>
)
})}
{virtualizacaoAtiva && bottomSpacerHeight > 0 ? (
<tr className="table-virtual-spacer" aria-hidden="true">
<td colSpan={colSpan} style={{ height: `${bottomSpacerHeight}px` }} />
</tr>
) : null}
</tbody>
</table>
{virtualizacaoAtiva ? (
<div className="table-hint">
Exibindo janela virtual com {rowsToRender.length} linhas de {totalRows} (ativada acima de {LIMIAR_RENDERIZACAO_VIRTUAL} linhas, sem supressão de dados).
</div>
) : null}
{table.truncated ? (
<div className="table-hint">Mostrando {table.returned_rows} de {table.total_rows} linhas.</div>
) : null}
</div>
)
}
export default React.memo(DataTable)
|