diff --git a/web/src/components/RealtimeTable/utils/virtualizerOptions.js b/web/src/components/RealtimeTable/utils/virtualizerOptions.js new file mode 100644 index 0000000..25602f0 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/virtualizerOptions.js @@ -0,0 +1,27 @@ +/** + * Опции виртуализатора строк с фиксированной высотой. + * + * measureElement по умолчанию читает layout (getBoundingClientRect/offsetHeight) на каждой строке, + * что вызывает синхронный пересчёт раскладки. Здесь он возвращает константу, элемент не читается. + * Поэтому высоту строки нужно задавать явно в стилях (строке и ячейке) тем же значением. + * + * rowHeight: число (px) или функция, возвращающая px (когда высота задана в rem и зависит от ширины окна). + */ +export const createFixedRowVirtualizerOptions = ({ rowHeight, overscan, extra = {} }) => { + const getHeight = typeof rowHeight === 'function' ? rowHeight : () => rowHeight; + return { + ...extra, + overscan, + estimateSize: () => getHeight(), + measureElement: () => getHeight(), + }; +}; + +// Размер 1rem в px по правилу App.css: .83vw при ширине окна <= 1920px, иначе 16px. +// Считается по window.innerWidth, чтобы не читать layout и не вызывать пересчёт стилей. +const REM_BASE_PX = 16; +const REM_VW_RATIO = 0.0083; +const REM_MAX_VIEWPORT = 1920; + +export const getRootFontSizePx = (viewportWidth = typeof window === 'undefined' ? undefined : window.innerWidth) => + viewportWidth && viewportWidth <= REM_MAX_VIEWPORT ? viewportWidth * REM_VW_RATIO : REM_BASE_PX; diff --git a/web/src/components/RealtimeTable/utils/virtualizerOptions.test.js b/web/src/components/RealtimeTable/utils/virtualizerOptions.test.js new file mode 100644 index 0000000..4ec6735 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/virtualizerOptions.test.js @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFixedRowVirtualizerOptions, getRootFontSizePx } from './virtualizerOptions.js'; + +const throwingElement = new Proxy( + {}, + { + get(_target, prop) { + throw new Error(`Чтение свойства элемента запрещено: ${String(prop)}`); + }, + }, +); + +test('measureElement не обращается к элементу и возвращает фиксированную высоту', () => { + const { measureElement } = createFixedRowVirtualizerOptions({ rowHeight: 40, overscan: 3 }); + assert.equal(measureElement(throwingElement), 40); + assert.equal(measureElement(null), 40); +}); + +test('estimateSize возвращает высоту для любого индекса, overscan пробрасывается', () => { + const options = createFixedRowVirtualizerOptions({ rowHeight: 40, overscan: 3 }); + assert.equal(options.estimateSize(0), 40); + assert.equal(options.estimateSize(999), 40); + assert.equal(options.overscan, 3); +}); + +test('rowHeight-функция вычисляется при каждом вызове (rem зависит от ширины окна)', () => { + let height = 40; + const options = createFixedRowVirtualizerOptions({ rowHeight: () => height, overscan: 5 }); + assert.equal(options.estimateSize(0), 40); + height = 48; + assert.equal(options.estimateSize(0), 48); + assert.equal(options.measureElement(throwingElement), 48); +}); + +test('дополнительные опции добавляются и не перекрывают основные', () => { + const options = createFixedRowVirtualizerOptions({ + rowHeight: 40, + overscan: 5, + extra: { scrollPaddingStart: 0, estimateSize: () => 1 }, + }); + assert.equal(options.scrollPaddingStart, 0); + assert.equal(options.estimateSize(0), 40); +}); + +test('getRootFontSizePx повторяет правило App.css', () => { + assert.equal(getRootFontSizePx(2560), 16); + assert.equal(getRootFontSizePx(1920), 1920 * 0.0083); + assert.ok(Math.abs(getRootFontSizePx(1600) * 3 - 39.84) < 0.01); + assert.equal(getRootFontSizePx(undefined), 16); +});