Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions packages/vtable/__tests__/listTable-arrow-key-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// @ts-nocheck
/**
* 测试键盘方向键导航时滚动和视图更新的正确性
* 对应 issue: https://github.com/VisActor/VTable/issues/5105
*/
import { ListTable } from '../src';
import { createDiv } from './dom';
global.__VERSION__ = 'none';

describe('arrow key scroll - issue #5105', () => {
const containerDom: HTMLElement = createDiv();
containerDom.style.position = 'relative';
containerDom.style.width = '800px';
containerDom.style.height = '600px';

// 生成足够多的列来触发水平虚拟滚动(同时覆盖远端跳转选择)
const colCount = 160;
const columns = Array.from({ length: colCount }, (_, i) => ({
field: `col_${i}`,
title: `Column ${i}`,
width: 100
}));

// 生成足够多的行来触发垂直虚拟滚动
const rowCount = 200;
// 这里不生成全量 cell 数据,避免测试在 CI 里因数据量过大变慢;
// 这些用例只依赖“可滚动的行列数”和“选中单元格可见性”。
const records = Array.from({ length: rowCount }, (_, rowIdx) => ({
col_0: `R${rowIdx}C0`,
col_15: `R${rowIdx}C15`,
col_50: `R${rowIdx}C50`,
col_150: `R${rowIdx}C150`
}));

const option = {
columns,
records,
defaultColWidth: 100,
defaultRowHeight: 40
};

const listTable = new ListTable(containerDom, option);

afterAll(() => {
// Prevent open handles (raf/timers) from keeping Jest running.
listTable.release();
});

test('selectCell 向右移动单元格时 scrollLeft 应正确更新', () => {
// 选中初始位置
listTable.selectCell(0, 1);
const initialScrollLeft = listTable.scrollLeft;
expect(initialScrollLeft).toBe(0);

// 逐步向右移动到超出可视区域的列
// 800px 宽度 / 100px 每列 ≈ 8 列可见
// 移动到第 10 列应该触发水平滚动
for (let col = 1; col <= 10; col++) {
listTable.selectCell(col, 1);
}
// 到第10列时应该已经触发了滚动
expect(listTable.scrollLeft).toBeGreaterThan(0);
});

test('selectCell 向右移动时目标列应保持可见', () => {
listTable.setScrollLeft(0);
listTable.selectCell(0, 1);

// 连续向右移动到第 15 列
for (let col = 1; col <= 15; col++) {
listTable.selectCell(col, 1);
}

const proxy = listTable.scenegraph.proxy;
expect(listTable.cellIsInVisualView(15, 1)).toBe(true);
expect(proxy.colStart).toBeLessThanOrEqual(15);
expect(proxy.colEnd).toBeGreaterThanOrEqual(15);
});

test('大幅度向右移动后视图状态应一致', () => {
listTable.setScrollLeft(0);
listTable.selectCell(0, 1);

// 直接跳到远处的列(模拟 Ctrl+ArrowRight 跳到很远的位置)
listTable.selectCell(150, 1);
const scrollLeft = listTable.scrollLeft;

// 滚动位置应该大于 0(因为第150列远超可视范围)
expect(scrollLeft).toBeGreaterThan(0);

// proxy 的 colStart/colEnd 应该包含当前可见列
const proxy = listTable.scenegraph.proxy;
expect(listTable.cellIsInVisualView(150, 1)).toBe(true);
expect(proxy.colStart).toBeLessThanOrEqual(150);
expect(proxy.colEnd).toBeGreaterThanOrEqual(150);
});

test('向右再向左移动时滚动位置应正确恢复', () => {
listTable.setScrollLeft(0);
listTable.selectCell(0, 1);

// 先向右移动
for (let col = 1; col <= 20; col++) {
listTable.selectCell(col, 1);
}
const scrollAfterRight = listTable.scrollLeft;
expect(scrollAfterRight).toBeGreaterThan(0);

// 再向左移动回来
for (let col = 19; col >= 0; col--) {
listTable.selectCell(col, 1);
}
// 回到第0列时 scrollLeft 应该回到 0
expect(listTable.scrollLeft).toBe(0);
});

test('向下再向上移动时 scrollTop 应正确更新', () => {
listTable.setScrollTop(0);
listTable.selectCell(0, 1);

// 600px 高度 / 40px 行高 ≈ 15 行可见(含表头)
// 移动到第 20 行应该触发垂直滚动
for (let row = 2; row <= 20; row++) {
listTable.selectCell(0, row);
}
expect(listTable.scrollTop).toBeGreaterThan(0);
});

test('setX 在首个 screenLeft 解析失败时应重试并保持目标列可见', () => {
listTable.setScrollLeft(0);
listTable.selectCell(0, 1);

const originalGetTargetColAt = listTable.getTargetColAt.bind(listTable);
let firstLookup = true;
const getTargetColAtSpy = jest.spyOn(listTable, 'getTargetColAt').mockImplementation((absoluteX: number) => {
if (firstLookup) {
firstLookup = false;
return null;
}
return originalGetTargetColAt(absoluteX);
});

listTable.selectCell(150, 1);

expect(getTargetColAtSpy.mock.calls.length).toBeGreaterThan(1);
expect(listTable.cellIsInVisualView(150, 1)).toBe(true);
expect(listTable.scenegraph.proxy.colStart).toBeLessThanOrEqual(150);
expect(listTable.scenegraph.proxy.colEnd).toBeGreaterThanOrEqual(150);

getTargetColAtSpy.mockRestore();
});

test('setBodyAndColHeaderX 应正确跳过 border 元素获取列组', () => {
const scenegraph = listTable.scenegraph;

// 验证 setBodyAndColHeaderX 不会因 border 元素导致异常
// 滚动到最右端
const maxScrollLeft = listTable.getAllColsWidth() - listTable.tableNoFrameWidth;
listTable.setScrollLeft(maxScrollLeft);

expect(scenegraph.bodyGroup.lastChild).toBeDefined();
expect(typeof scenegraph.bodyGroup.attribute.x).toBe('number');
expect(listTable.cellIsInVisualView(colCount - 1, 1)).toBe(true);
});

test('setY 在首个 screenTop 解析失败时应重试并保持目标行可见', () => {
listTable.setScrollTop(0);
listTable.selectCell(0, 1);

const originalGetTargetRowAt = listTable.getTargetRowAt.bind(listTable);
let firstLookup = true;
const getTargetRowAtSpy = jest.spyOn(listTable, 'getTargetRowAt').mockImplementation((absoluteY: number) => {
if (firstLookup) {
firstLookup = false;
return null;
}
return originalGetTargetRowAt(absoluteY);
});

listTable.selectCell(0, 120);

expect(getTargetRowAtSpy.mock.calls.length).toBeGreaterThan(1);
expect(listTable.cellIsInVisualView(0, 120)).toBe(true);
expect(listTable.scenegraph.proxy.rowStart).toBeLessThanOrEqual(120);
expect(listTable.scenegraph.proxy.rowEnd).toBeGreaterThanOrEqual(120);

getTargetRowAtSpy.mockRestore();
});

test('连续快速向右 selectCell 模拟快速按键', () => {
listTable.setScrollLeft(0);

// 模拟快速按住 ArrowRight 不放,连续选中 50 个单元格
for (let col = 0; col <= 50; col++) {
listTable.selectCell(col, 1);
}

const scrollLeft = listTable.scrollLeft;
const scenegraph = listTable.scenegraph;
const proxy = scenegraph.proxy;

// scrollLeft 应该合理增长
expect(scrollLeft).toBeGreaterThan(0);

// proxy 维护的列范围应该包含第50列
expect(proxy.colEnd).toBeGreaterThanOrEqual(50);
expect(proxy.colStart).toBeLessThanOrEqual(50);

// body group 位置应该合理
const bodyGroupX = scenegraph.bodyGroup.attribute.x;
expect(bodyGroupX).toBeDefined();
expect(typeof bodyGroupX).toBe('number');
});
});
122 changes: 122 additions & 0 deletions packages/vtable/examples/interactive/arrow-key-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as VTable from '../../src';
const ListTable = VTable.ListTable;
const CONTAINER_ID = 'vTable';

export function createTable() {
const colCount = 50;
const rowCount = 200;

const departments = ['Engineering', 'Marketing', 'Sales', 'Design', 'Finance', 'HR', 'Operations', 'Legal'];
const statuses = ['Active', 'On Leave', 'Remote', 'In Office'];
const levels = ['Junior', 'Mid', 'Senior', 'Lead', 'Principal'];
const cities = [
'Beijing',
'Shanghai',
'Shenzhen',
'Hangzhou',
'Guangzhou',
'Chengdu',
'Nanjing',
'Wuhan',
'Tokyo',
'Singapore'
];

const columns: VTable.ColumnsDefine = [
{ field: 'id', title: 'ID', width: 60 },
{ field: 'name', title: 'Name', width: 120 },
{ field: 'dept', title: 'Department', width: 110 },
{ field: 'level', title: 'Level', width: 90 },
{ field: 'city', title: 'City', width: 100 },
{ field: 'status', title: 'Status', width: 90 },
{ field: 'email', title: 'Email', width: 200 }
];

for (let i = 1; i <= colCount - 7; i++) {
const quarter = `Q${((i - 1) % 4) + 1}`;
const year = 2020 + Math.floor((i - 1) / 4);
columns.push({
field: `metric_${i}`,
title: `${quarter} ${year}`,
width: 100,
style: {
textAlign: 'right'
},
headerStyle: {
textAlign: 'center'
}
});
}

const fnames = ['Alex', 'Emma', 'Liam', 'Mia', 'Noah', 'Olivia', 'James', 'Sophia', 'Lucas', 'Ava'];
const lnames = ['Chen', 'Wang', 'Li', 'Zhang', 'Liu', 'Yang', 'Huang', 'Wu', 'Zhou', 'Xu'];

const records = Array.from({ length: rowCount }, (_, i) => {
const rec: Record<string, any> = {
id: i + 1,
name: `${fnames[i % fnames.length]} ${lnames[Math.floor(i / fnames.length) % lnames.length]}`,
dept: departments[i % departments.length],
level: levels[i % levels.length],
city: cities[i % cities.length],
status: statuses[i % statuses.length],
email:
`${fnames[i % fnames.length].toLowerCase()}.` +
`${lnames[Math.floor(i / fnames.length) % lnames.length].toLowerCase()}@company.com`
};
for (let j = 1; j <= colCount - 7; j++) {
rec[`metric_${j}`] = (Math.random() * 10000).toFixed(0);
}
return rec;
});

const option: VTable.ListTableConstructorOptions = {
container: document.getElementById(CONTAINER_ID),
columns,
records,
defaultRowHeight: 36,
widthMode: 'standard',
frozenColCount: 1,
keyboardOptions: {
moveSelectedCellOnArrowKeys: true
},
theme: VTable.themes.ARCO.extends({
scrollStyle: {
visible: 'always',
width: 8,
hoverOn: true
},
selectionStyle: {
cellBgColor: 'rgba(0, 100, 250, 0.12)',
cellBorderColor: '#0064FA',
cellBorderLineWidth: 2
}
}),
hover: {
highlightMode: 'cross',
disableHeaderHover: false
},
select: {
headerSelectMode: 'cell'
}
};

const instance = new ListTable(option);

instance.selectCell(1, 1);

const infoDiv = document.createElement('div');
infoDiv.style.cssText =
'position:fixed;top:12px;right:16px;padding:12px 20px;background:rgba(0,0,0,0.75);' +
'color:#fff;border-radius:8px;font:14px/1.6 system-ui,sans-serif;z-index:999;max-width:360px;' +
'box-shadow:0 4px 12px rgba(0,0,0,0.15)';
infoDiv.innerHTML =
'<b>Arrow Key Navigation Demo</b><br>' +
'Use <kbd style="background:#555;padding:1px 6px;border-radius:3px">←</kbd> ' +
'<kbd style="background:#555;padding:1px 6px;border-radius:3px">→</kbd> ' +
'<kbd style="background:#555;padding:1px 6px;border-radius:3px">↑</kbd> ' +
'<kbd style="background:#555;padding:1px 6px;border-radius:3px">↓</kbd> to navigate<br>' +
'<span style="color:#8cf">50 columns × 200 rows</span>';
document.body.appendChild(infoDiv);

window.tableInstance = instance;
}
4 changes: 4 additions & 0 deletions packages/vtable/examples/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,10 @@ export const menus = [
{
path: 'interactive',
name: 'custom-scroll'
},
{
path: 'interactive',
name: 'arrow-key-scroll'
}
]
},
Expand Down
26 changes: 24 additions & 2 deletions packages/vtable/src/scenegraph/group-creater/progress/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ export class SceneProxy {
this.table.getRowsHeight(this.bodyTopRow, this.bodyTopRow + (this.rowEnd - this.rowStart + 1)) / 2;
const yLimitBottom = this.table.getAllRowsHeight() - yLimitTop;

const screenTop = this.table.getTargetRowAt(y + this.table.scenegraph.colHeaderGroup.attribute.height);
const screenTop = this.resolveTargetRowInfo(y + this.table.scenegraph.colHeaderGroup.attribute.height);
if (screenTop) {
this.screenTopRow = screenTop.row;
}
Expand Down Expand Up @@ -526,7 +526,7 @@ export class SceneProxy {
this.table.getColsWidth(this.bodyLeftCol, this.bodyLeftCol + (this.colEnd - this.colStart + 1)) / 2;
const xLimitRight = this.table.getAllColsWidth() - xLimitLeft;

const screenLeft = this.table.getTargetColAt(
const screenLeft = this.resolveTargetColInfo(
x + this.table.scenegraph.rowHeaderGroup.attribute.width + (this.table.getFrozenColsOffset?.() ?? 0)
);
if (screenLeft) {
Expand Down Expand Up @@ -563,6 +563,28 @@ export class SceneProxy {
dynamicSetX(x, screenLeft, isEnd, this);
}

private resolveTargetColInfo(absoluteX: number): ColumnInfo | null {
const offsets = [0, -1, 1, -2, 2];
for (let i = 0; i < offsets.length; i++) {
const screenLeft = this.table.getTargetColAt(absoluteX + offsets[i]);
if (screenLeft) {
return screenLeft;
}
}
return null;
}

private resolveTargetRowInfo(absoluteY: number): RowInfo | null {
const offsets = [0, -1, 1, -2, 2];
for (let i = 0; i < offsets.length; i++) {
const screenTop = this.table.getTargetRowAt(absoluteY + offsets[i]);
if (screenTop) {
return screenTop;
}
}
return null;
}

updateBody(y: number) {
this.table.scenegraph.setBodyAndRowHeaderY(-y);
}
Expand Down
Loading
Loading