perf(table): статичные стили ячеек в CSS контейнера, overscan строк 2 и колонок 3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 18:14:38 +04:00
parent 12a315b04a
commit 21aded26cc
6 changed files with 113 additions and 53 deletions

View File

@ -16,8 +16,9 @@ import { CreateProgramModal } from './Modals/CreateProgramProjectModal';
import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
import { SelectVspModal } from './Modals/SelectVspModal';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
import { getActiveCellSx } from './utils/activeCellCss';
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useColumnSettings } from './hooks/useColumnSettings';
import { useHeaderPortal } from './hooks/useHeaderPortal';
@ -37,7 +38,7 @@ const CONFIG_CACHE = new Map();
const ROW_VIRTUALIZER_OPTIONS = createFixedRowVirtualizerOptions({
rowHeight: getTableRowHeightPx,
overscan: 5,
overscan: 2,
extra: { scrollPaddingStart: 0, scrollPaddingEnd: 0 },
});
@ -362,16 +363,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onClick: () => handleClickRowCell(row.id),
hover: false,
style: { height: TABLE_ROW_HEIGHT },
sx: {
'& > td::after': {
content: row.getIsSelected() ? '""' : 'none',
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.08)',
pointerEvents: 'none',
zIndex: 11,
},
},
'data-row-id': row.id,
}),
onColumnSizingChange: setColumnSizing,
onColumnPinningChange: setColumnPinning,
@ -394,21 +386,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
muiTableHeadCellProps: {
sx: { boxSizing: 'border-box' },
},
muiTableBodyCellProps: (props) => {
const cellProps = getTableBodyCellProps(props);
const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id;
return {
...cellProps,
sx: {
...cellProps.sx,
...(isActiveMatch && {
zIndex: 7,
boxShadow: 'inset 0 0 0 3px #1976d2',
}),
},
};
},
muiTableBodyCellProps: ({ column }) => ({ 'data-col-id': column.id }),
muiTableHeadProps: {
sx: {
display: 'table-header-group',
@ -427,12 +405,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
muiTablePaperProps: getTablePaperStyles(),
muiTableContainerProps: {
ref: containerRef,
sx: {
position: 'relative',
contain: 'layout',
minHeight: '100%',
'& .MuiTable-root': { position: 'relative' },
},
sx: { ...TABLE_CONTAINER_SX, ...getActiveCellSx(activeSearchMatch) },
},
muiTableHeadCellFilterTextFieldProps: {
placeholder: 'Поиск...',

View File

@ -40,32 +40,33 @@ export const BASE_TABLE_CONFIG = {
},
};
export const getTableHeadCellStyles = () => ({
sx: {
whiteSpace: 'auto',
// Статичное оформление ячеек одним правилом на контейнере вместо sx на каждой ячейке.
// Высота берётся из TABLE_ROW_HEIGHT (rem), чтобы совпадать с виртуализатором строк.
export const TABLE_CONTAINER_SX = {
position: 'relative',
contain: 'layout',
minHeight: '100%',
'& .MuiTable-root': { position: 'relative' },
'& tbody td': {
wordBreak: 'normal',
boxSizing: 'border-box',
minHeight: TABLE_ROW_HEIGHT,
maxHeight: TABLE_ROW_HEIGHT,
height: TABLE_ROW_HEIGHT,
border: '1px solid #E6E6E6',
fontSize: '11px',
fontSize: '1rem',
fontFamily: 'Calibri, Arial, sans-serif',
overflow: 'hidden',
},
'& tbody td[data-pinned="true"]': { zIndex: 5 },
'& tbody tr[data-selected="true"] > td::after': {
content: '""',
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.08)',
pointerEvents: 'none',
zIndex: 11,
},
});
export const getTableBodyCellProps = ({ column, table }) => {
const isPinned = (table.getState().columnPinning?.left || []).includes(column.id);
return {
'data-col-id': column.id,
sx: {
...getTableHeadCellStyles().sx,
fontSize: '1rem',
height: TABLE_ROW_HEIGHT,
overflow: 'hidden',
...(isPinned && { zIndex: 5 }),
},
};
};
export const getTablePaperStyles = () => ({

View File

@ -0,0 +1,51 @@
// Экранирование идентификатора по алгоритму CSS.escape (CSSOM). В браузере используется
// нативный CSS.escape, этот вариант нужен там, где его нет (node-тесты).
const escapeCssIdentFallback = (value) => {
const string = String(value);
let result = '';
for (let index = 0; index < string.length; index++) {
const char = string[index];
const code = string.charCodeAt(index);
if (code === 0) {
result += '<27>';
} else if (
(code >= 0x1 && code <= 0x1f) ||
code === 0x7f ||
(index === 0 && code >= 0x30 && code <= 0x39) ||
(index === 1 && code >= 0x30 && code <= 0x39 && string.charCodeAt(0) === 0x2d)
) {
result += `\\${code.toString(16)} `;
} else if (index === 0 && string.length === 1 && code === 0x2d) {
result += `\\${char}`;
} else if (
code >= 0x80 ||
code === 0x2d ||
code === 0x5f ||
(code >= 0x30 && code <= 0x39) ||
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a)
) {
result += char;
} else {
result += `\\${char}`;
}
}
return result;
};
const defaultEscape = (value) =>
typeof globalThis.CSS?.escape === 'function' ? globalThis.CSS.escape(value) : escapeCssIdentFallback(value);
export const ACTIVE_CELL_STYLE = { zIndex: 7, boxShadow: 'inset 0 0 0 3px #1976d2' };
// Правило sx для контейнера таблицы: подсветка текущего совпадения поиска.
// Вместо sx на каждой ячейке перерисовывается один селектор. null/неполное совпадение -> {}.
export const getActiveCellSx = (activeSearchMatch, escape = defaultEscape) => {
const rowId = activeSearchMatch?.rowId;
const columnId = activeSearchMatch?.columnId;
if (rowId == null || columnId == null) return {};
return {
[`& tbody tr[data-row-id="${escape(rowId)}"] > td[data-col-id="${escape(columnId)}"]`]: ACTIVE_CELL_STYLE,
};
};

View File

@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ACTIVE_CELL_STYLE, getActiveCellSx } from './activeCellCss.js';
test('null and incomplete matches produce no rule', () => {
assert.deepEqual(getActiveCellSx(null), {});
assert.deepEqual(getActiveCellSx(undefined), {});
assert.deepEqual(getActiveCellSx({ rowId: '1' }), {});
assert.deepEqual(getActiveCellSx({ columnId: 'a' }), {});
});
test('row ids like 0.3.2 are escaped', () => {
const sx = getActiveCellSx({ rowId: '0.3.2', columnId: 'amount' });
assert.deepEqual(Object.keys(sx), ['& tbody tr[data-row-id="\\30 \\.3\\.2"] > td[data-col-id="amount"]']);
assert.deepEqual(Object.values(sx), [ACTIVE_CELL_STYLE]);
});
test('row ids like ITEM:123 are escaped', () => {
const sx = getActiveCellSx({ rowId: 'ITEM:123', columnId: 'col.1' });
assert.deepEqual(Object.keys(sx), ['& tbody tr[data-row-id="ITEM\\:123"] > td[data-col-id="col\\.1"]']);
});
test('uses injected escape', () => {
const sx = getActiveCellSx({ rowId: 'r', columnId: 'c' }, (value) => `<${value}>`);
assert.deepEqual(Object.keys(sx), ['& tbody tr[data-row-id="<r>"] > td[data-col-id="<c>"]']);
});
test('highlight keeps 3px frame and z-index 7', () => {
assert.equal(ACTIVE_CELL_STYLE.zIndex, 7);
assert.equal(ACTIVE_CELL_STYLE.boxShadow, 'inset 0 0 0 3px #1976d2');
});

View File

@ -11,7 +11,7 @@ export const getColumnVirtualizerOptions = ({ table }) => {
const getColumnSize = (index) => columns[index]?.getSize() ?? 150;
return {
overscan: 10,
overscan: 3,
scrollPaddingStart: leftColumns.reduce((width, column) => width + column.getSize(), 0),
scrollPaddingEnd: rightColumns.reduce((width, column) => width + column.getSize(), 0),
estimateSize: getColumnSize,

View File

@ -60,3 +60,7 @@ test('scrolling to the far edge keeps both pinned sides rendered', () => {
assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 2, endIndex: 2, overscan: 0, count: 4 }), [0, 2, 3]);
assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 0, endIndex: 0, overscan: 0, count: 4 }), [0, 3]);
});
test('column overscan is 3', () => {
assert.equal(getColumnVirtualizerOptions({ table: createFixture() }).overscan, 3);
});