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
188 changes: 188 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,188 @@
// @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 = 200;
const columns = Array.from({ length: colCount }, (_, i) => ({
field: `col_${i}`,
title: `Column ${i}`,
width: 100
}));

// 生成足够多的行来触发垂直虚拟滚动
const rowCount = 500;
const records = Array.from({ length: rowCount }, (_, rowIdx) => {
const record: Record<string, string> = {};
for (let col = 0; col < colCount; col++) {
record[`col_${col}`] = `R${rowIdx}C${col}`;
}
return record;
});

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

const listTable = new ListTable(containerDom, option);

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 向右移动时 body group x 位置应正确(无白色空白)', () => {
listTable.setScrollLeft(0);
listTable.selectCell(0, 1);

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

const scenegraph = listTable.scenegraph;
const bodyGroupX = scenegraph.bodyGroup.attribute.x;
const frozenColsWidth = listTable.getFrozenColsWidth();
const scrollLeft = listTable.scrollLeft;

// body group 的 x 位置应该和 scrollLeft 对应
// bodyGroup.x = frozenColsWidth + offset, 其中 offset 是基于 scrollLeft 计算的
// 关键检查:body group 不应该留有右侧空白
const bodyGroupRight = bodyGroupX + scenegraph.bodyGroup.attribute.width;
const tableWidth = listTable.tableNoFrameWidth;

// body group 的右边缘应该至少覆盖到可视区域的右边缘
expect(bodyGroupRight).toBeGreaterThanOrEqual(tableWidth);
});

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(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('dynamicSetX 处理 screenLeft 为 null 时不应导致白色空白', () => {
// 滚动到表格中间区域
listTable.setScrollLeft(5000);

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

// 调用 proxy.setX 并确保即使 screenLeft 为 null 也不会崩溃
// 保存当前 body 位置
const bodyXBefore = scenegraph.bodyGroup.attribute.x;

// 正常滚动后 body group 位置应该已被更新
expect(bodyXBefore).toBeDefined();
expect(typeof bodyXBefore).toBe('number');
});

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

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

const bodyGroupX = scenegraph.bodyGroup.attribute.x;
const tableWidth = listTable.tableNoFrameWidth;

// 到最右端时,body 内容的右边缘应该对齐或超过可视区域右边缘
// 不应有白色空白
expect(bodyGroupX).toBeDefined();
expect(typeof bodyGroupX).toBe('number');
});

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 @@ -874,6 +874,10 @@ export const menus = [
{
path: 'interactive',
name: 'custom-scroll'
},
{
path: 'interactive',
name: 'arrow-key-scroll'
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { checkFirstColMerge, getFirstChild, getLastChild } from './util';

export async function dynamicSetX(x: number, screenLeft: ColumnInfo | null, isEnd: boolean, proxy: SceneProxy) {
if (!screenLeft) {
// screenLeft 为 null 时仍需更新 body 位置并触发渲染,避免滚动后出现空白区域
proxy.table.scenegraph.setBodyAndColHeaderX(-x + proxy.deltaX);
proxy.table.scenegraph.updateNextFrame();
return;
}
const screenLeftCol = screenLeft.col;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { getLastChild } from './util';

export async function dynamicSetY(y: number, screenTop: RowInfo | null, isEnd: boolean, proxy: SceneProxy) {
if (!screenTop) {
// screenTop 为 null 时仍需更新 body 位置并触发渲染,避免滚动后出现空白区域
proxy.updateBody(y - proxy.deltaY);
proxy.table.scenegraph.updateNextFrame();
return;
}
const screenTopRow = screenTop.row;
Expand Down
18 changes: 13 additions & 5 deletions packages/vtable/src/scenegraph/scenegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,10 +1554,14 @@ export class Scenegraph {
*/
setBodyAndRowHeaderY(y: number) {
// correct y, avoid scroll out of range
const firstBodyCell =
(this.bodyGroup.firstChild?.firstChild as Group) ?? (this.rowHeaderGroup.firstChild?.firstChild as Group);
const lastBodyCell =
(this.bodyGroup.firstChild?.lastChild as Group) ?? (this.rowHeaderGroup.firstChild?.lastChild as Group);
// border 始终作为最后一个子元素(addChild/appendChild),firstChild 无需过滤
const firstBodyColGroup = this.bodyGroup.firstChild as Group;
const firstRowHeaderColGroup = this.rowHeaderGroup.firstChild as Group;
const firstBodyCell = (firstBodyColGroup?.firstChild as Group) ?? (firstRowHeaderColGroup?.firstChild as Group);
let lastBodyCell = (firstBodyColGroup?.lastChild ?? firstRowHeaderColGroup?.lastChild) as Group;
if (lastBodyCell && lastBodyCell.type !== 'group') {
lastBodyCell = lastBodyCell._prev as Group;
}
if (
y === 0 &&
firstBodyCell &&
Expand Down Expand Up @@ -1612,8 +1616,12 @@ export class Scenegraph {
*/
setBodyAndColHeaderX(x: number) {
// correct x, avoid scroll out of range
// border 始终作为最后一个子元素(addChild/appendChild),firstChild 无需过滤
const firstBodyCol = this.bodyGroup.firstChild as Group;
const lastBodyCol = this.bodyGroup.lastChild as Group;
let lastBodyCol = this.bodyGroup.lastChild as Group;
if (lastBodyCol && lastBodyCol.type !== 'group') {
lastBodyCol = lastBodyCol._prev as Group;
}
if (x === 0 && firstBodyCol && firstBodyCol.col === this.table.frozenColCount && firstBodyCol.attribute.x + x < 0) {
x = -firstBodyCol.attribute.x;
} else if (
Expand Down
Loading