Skip to content

Commit

Permalink
Merge pull request #1061 from ksc-fe/virtual
Browse files Browse the repository at this point in the history
fix bugs
  • Loading branch information
warrior-bing authored Jan 24, 2025
2 parents 08ac71d + 8cfe539 commit 5ac6d1c
Show file tree
Hide file tree
Showing 12 changed files with 397 additions and 136 deletions.
13 changes: 9 additions & 4 deletions components/tree/useChecked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {useState} from '../../hooks/useState';
import {useReceive} from '../../hooks/useReceive';
import type {Tree} from './';
import type {Node, DataItem} from './useNodes';
import { isEqualArray } from '../utils';

export function useChecked(getNodes: () => Node<Key>[]) {
const instance = useInstance() as Tree;
Expand Down Expand Up @@ -38,9 +39,7 @@ export function useChecked(getNodes: () => Node<Key>[]) {
}

node.checked = checked;
if (checked) {
node.indeterminate = false;
}
node.indeterminate = false;

if (shouldUpdateCheckedKeys) {
updateCheckedKeys(node);
Expand All @@ -56,6 +55,12 @@ export function useChecked(getNodes: () => Node<Key>[]) {
needRecheckNodes.forEach(node => {
updateUpward(node);
});

const oldCheckedKeys = instance.get('checkedKeys');
const newCheckedKeys = Array.from(checkedKeys);
if (!isEqualArray(oldCheckedKeys, newCheckedKeys)) {
instance.set('checkedKeys', newCheckedKeys);
}
}

function updateCheckedKeys(node: Node<Key>) {
Expand All @@ -67,7 +72,7 @@ export function useChecked(getNodes: () => Node<Key>[]) {
}

function toggle(node: Node<Key>) {
const uncorrelated = instance.get('uncorrelated');
// const uncorrelated = instance.get('uncorrelated');
updateDownward(node, !node.checked);
updateUpward(node.parent);

Expand Down
47 changes: 47 additions & 0 deletions components/treeSelect/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,51 @@ describe('TreeSelect', () => {
await wait(500);
expect(element.querySelector('.k-form-error')).not.to.be.null;
});

it('should handle parent node correctly when all children are checked initially', async () => {
const [instance, element] = mount(CheckboxDemo);

// init values
instance.set('values', ['2.1.1', '2.1.2']);
await wait();

// because 2.2 is disabled, so it will select the outermost parent node 2
expect(instance.get('values')).to.eql(['2']);

element.click();
await wait();
const dropdown = getElement('.k-tree-select-menu')!;
// verify parent node is selected
const parentCheckboxes = dropdown.querySelectorAll('.k-checkbox');
expect(parentCheckboxes[3].classList.contains('k-checked')).to.be.true; // First floor-2
expect(parentCheckboxes[4].classList.contains('k-checked')).to.be.true; // Second floor-2.1
});

it('should clear parent node state when setting values to empty array', async () => {
const [instance, element] = mount(CheckboxDemo);

// select a parent node first
instance.set('values', ['2.1.1']);
await wait();

element.click();
await wait();
let dropdown = getElement('.k-tree-select-menu')!;
// verify related nodes are selected
const checkboxes = dropdown.querySelectorAll('.k-checkbox');
expect(checkboxes[3].classList.contains('k-indeterminate')).to.be.true; // First floor-2
expect(checkboxes[4].classList.contains('k-indeterminate')).to.be.true; // Second floor-2.1
expect(checkboxes[5].classList.contains('k-checkbox')).to.be.true; // Third floor-2.1.1
// expect(checkboxes[6].classList.contains('k-checked')).to.be.true; // Third floor-2.1.2

// set to empty array
instance.set('values', []);
await wait();

// verify all nodes are cleared selected state
checkboxes.forEach(checkbox => {
expect(checkbox.classList.contains('k-checked')).to.be.false;
expect(checkbox.classList.contains('k-indeterminate')).to.be.false;
});
});
});
11 changes: 7 additions & 4 deletions components/treeSelect/useValue.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {useInstance, Key, createRef} from 'intact';
import {useInstance, Key, createRef, nextTick} from 'intact';
import type {TreeSelect} from './';
import {useState} from '../../hooks/useState';
import {isNullOrUndefined} from 'intact-shared';
import type {Tree} from '../tree';
import type {Node} from '../tree/useNodes';
import { isEqualArray } from '../utils';

export function useValue() {
const instance = useInstance() as TreeSelect<Key, boolean, boolean>;
Expand All @@ -23,10 +24,12 @@ export function useValue() {
checkedKeys.set(v as Key[]);
}

function onChangeCheckedKeys() {
function onChangeCheckedKeys(allKeys: Key[]) {
checkedKeys.set(allKeys);
const keys = getAllCheckedKeys();
instance.set('value', keys);
checkedKeys.set(keys);
if (!isEqualArray(keys, instance.get('value'))) {
instance.set('value', keys);
}
}

function getAllCheckedKeys() {
Expand Down
161 changes: 161 additions & 0 deletions components/virtualList/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('VirtualList', () => {

// check basic structure
const container = element.querySelector('.k-virtual-container')!;
await wait();
expect(container.outerHTML).to.matchSnapshot();

const wrapper = element.querySelector('.k-virtual-wrapper')!;
Expand Down Expand Up @@ -259,5 +260,165 @@ describe('VirtualList', () => {
const isAtBottom = Math.abs((containerRect.bottom - lastItemRect.bottom)) <= 1;
expect(isAtBottom).to.be.true;
});

it('should handle async data correctly', async () => {
class AsyncDemo extends Component<{list: number[]}> {
static template = `
const VirtualList = this.VirtualList;
<VirtualList style="height: 300px">
<div v-for={this.get('list')} key={$value}>Item {$value}</div>
</VirtualList>
`;
static defaults() {
return {
list: []
}
}
private VirtualList = VirtualList;
}

const [instance] = mount(AsyncDemo);
await wait();

const container = getElement('.k-virtual-container')!;
const wrapper = getElement('.k-virtual-wrapper')!;
const phantom = getElement('.k-virtual-phantom')!;

// check initial state
expect(wrapper.children.length).to.equal(0);
expect(phantom.style.height).to.equal('0px');

// simulate async data loading
instance.set('list', Array.from({length: 100}, (_, i) => i));
await wait(50);

expect(wrapper.children.length).to.be.equal(23);
expect(phantom.style.height).to.be.equal('1800px');

// check render content
expect(wrapper.firstElementChild!.textContent).to.equal('Item 0');
});

it('should handle empty data and re-adding data correctly', async () => {
class EmptyDemo extends Component<{list: number[]}> {
static template = `
const VirtualList = this.VirtualList;
<VirtualList style="height: 300px">
<div v-for={this.get('list')} key={$value}>Item {$value}</div>
</VirtualList>
`;
static defaults() {
return {
list: Array.from({length: 100}, (_, i) => i)
}
}
private VirtualList = VirtualList;
}

const [instance] = mount(EmptyDemo);
await wait();

const container = getElement('.k-virtual-container')!;
const wrapper = getElement('.k-virtual-wrapper')!;
const phantom = getElement('.k-virtual-phantom')!;

// record initial state
const initialHeight = phantom.style.height;
const initialChildrenCount = wrapper.children.length;

// clear data
instance.set('list', []);
await wait(50);

// check empty state
expect(wrapper.children.length).to.equal(0);
expect(phantom.style.height).to.equal('0px');

// re-add data
instance.set('list', Array.from({length: 50}, (_, i) => i));
await wait(50);

// check re-add data state
expect(wrapper.children.length).to.be.equal(23);
expect(parseInt(phantom.style.height)).to.be.equal(900);
expect(wrapper.firstElementChild!.textContent).to.equal('Item 0');

// 滚动测试
// container.scrollTop = 100;
// await wait(50);

// // 检查滚动后的渲染是否正确
// expect(wrapper.firstElementChild!.textContent).to.not.equal('Item 0');
});

it('should handle extreme height differences correctly', async () => {
class ExtremeHeightDemo extends Component<{list: number[]}> {
static template = `
const VirtualList = this.VirtualList;
<VirtualList style="height: 300px">
<div v-for={this.get('list')}
key={$value}
style={$value < 5 ? 'height: 100px' : 'height: 30px'}
>
Item {$value}
</div>
</VirtualList>
`;
static defaults() {
return {
list: Array.from({length: 100}, (_, i) => i)
}
}
private VirtualList = VirtualList;
}

const [instance] = mount(ExtremeHeightDemo);
await wait();

const container = getElement('.k-virtual-container')!;
const wrapper = getElement('.k-virtual-wrapper')!;
const phantom = getElement('.k-virtual-phantom')!;

const initialItems = wrapper.children;
const initialLength = initialItems.length;

// check first 5 elements height
const firstItem = initialItems[0] as HTMLElement;
expect(firstItem.offsetHeight).to.equal(100);

// record initial average height (calculate by total height and render count)
const initialTotalHeight = parseInt(phantom.style.height);
const initialAvgHeight = initialTotalHeight / 100;
expect(initialAvgHeight).to.be.greaterThan(30);

// scroll to small height area
container.scrollTop = 800;
await wait(50);

// check new render elements
const newItems = wrapper.children;
const newLength = newItems.length;
const firstNewItem = newItems[0] as HTMLElement;

// check
// new render elements should be 30px height
expect(firstNewItem.offsetHeight).to.equal(30);

// because average height is smaller, render elements count should be more
expect(newLength).to.be.greaterThan(initialLength);

// check scroll position is correct
const firstVisibleIndex = parseInt(firstNewItem.textContent!.replace('Item ', ''));
expect(firstVisibleIndex).to.be.greaterThan(5);

container.scrollTop = 1200;
await wait();

const finalItems = wrapper.children;
const finalFirstItem = finalItems[0] as HTMLElement;
expect(finalFirstItem.offsetHeight).to.equal(30);
expect(finalItems.length).to.equal(newLength); // render count should be stable
});
});


3 changes: 2 additions & 1 deletion components/virtualList/rows.vdt
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ const rows = this.rows;
<VirtualRowsContext.Consumer>
{({ notifyRows, startIndex, length, disabled }) => {
if (!disabled) {
children = rows.value.slice(startIndex, startIndex + length);
notifyRows(rows.value);
children = rows.value.slice(startIndex, startIndex + length);
}

if (isNullOrUndefined(tagName)) {
if (!children.length) return;
return createFragment(children, 8 /* ChildrenTypes.HasKeyedChildren */);
}
return createVNode(tagName, null, children);
Expand Down
18 changes: 16 additions & 2 deletions components/virtualList/useRows.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useInstance, VNode, createRef, createFragment } from 'intact';
import { useInstance, VNode, createRef, createFragment, Children, isText } from 'intact';
import { VirtualListRows } from './rows';

export function useRows() {
Expand All @@ -12,11 +12,25 @@ export function useRows() {
// convert to array if it has only one child
const childrenType = vNode.childrenType;
if (childrenType & 2 /* ChildrenTypes.HasVNodeChildren */) {
rows.value = [vNode.children as unknown as VNode];
const children = vNode.children as unknown as VNode;
if (isText(children)) {
// ignore void and text vnode
rows.value = [];
} else {
rows.value = [children];
}
} else if (childrenType & 1 /* ChildrenTypes.HasInvalidChildren */) {
rows.value = [];
} else {
rows.value = vNode.children as VNode[];

if (process.env.NODE_ENV !== 'production') {
rows.value.forEach(row => {
if (isText(row)) {
console.warn('VirtualList: Text node can not been used as children.');
}
});
}
}
});

Expand Down
Loading

0 comments on commit 5ac6d1c

Please sign in to comment.