perf(table): статичные стили ячеек в CSS контейнера, overscan строк 2 и колонок 3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
12a315b04a
commit
21aded26cc
@ -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: 'Поиск...',
|
||||
|
||||
@ -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 = () => ({
|
||||
|
||||
51
web/src/components/RealtimeTable/utils/activeCellCss.js
Normal file
51
web/src/components/RealtimeTable/utils/activeCellCss.js
Normal 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,
|
||||
};
|
||||
};
|
||||
31
web/src/components/RealtimeTable/utils/activeCellCss.test.js
Normal file
31
web/src/components/RealtimeTable/utils/activeCellCss.test.js
Normal 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');
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user