Skip to content

Commit

Permalink
feat(Tree): The Tree component provides the scrollFilterNodeIntoView …
Browse files Browse the repository at this point in the history
…API (#4860)
  • Loading branch information
wangw11056 authored and eternalsky committed Jul 16, 2024
1 parent d7f0283 commit 9ffdfe8
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 28 deletions.
4 changes: 2 additions & 2 deletions components/tree/__docs__/demo/search-tree/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

# 实现搜索

组合 `Search` 组件,实现 `Tree` 组件的搜索。
组合 `Search` 组件,实现 `Tree` 组件的搜索。通过 `ref` 获取 `Tree` 实例,可调用 `scrollFilterNodeIntoView` 方法实现将匹配到的第一个节点滚动到可视区域。

# en-US order=8

# Searchable

Demos the searchable tree.
Demos the searchable tree. Use `ref` to get the tree instance, and call `scrollFilterNodeIntoView` to scroll to the first matched node.
77 changes: 55 additions & 22 deletions components/tree/__docs__/demo/search-tree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,56 @@ import ReactDOM from 'react-dom';
import { Search, Tree } from '@alifd/next';
import type { DataNode, NodeInstance } from '@alifd/next/types/tree';

function generateDataSource(count = 100) {
return new Array(count).fill(null).map((__, i) => {
return {
label: '服装',
key: `${i}`,
className: `k-${i}`,
children: [
{
label: '男装',
key: `${i}_${i}`,
className: `k-${i}-${i}`,
children: [
{
label: '外套',
key: `${i}_${i}_${i}`,
className: `k-${i}-${i}-${i}`,
},
{
label: '夹克',
key: `${i}_${i}_${i}_${i}`,
className: `k-${i}-${i}-${i}-${i}`,
},
],
},
],
};
});
}

const data: DataNode[] = [
...generateDataSource(),
{
label: 'Component',
key: '1',
label: '服装',
key: '100',
className: 'k-100',
children: [
{
label: 'Form',
key: '2',
label: '女装',
key: '100_100',
className: 'k-100-100',
children: [
{
label: 'Input',
key: '4',
label: '裙子',
key: '100_100_100',
className: 'k-100-100-100',
},
{
label: 'Select',
key: '5',
},
],
},
{
label: 'Display',
key: '3',
children: [
{
label: 'Table',
key: '6',
label: '毛衣',
key: '100_100_100_100',
className: 'k-100-100-100-100',
},
],
},
Expand All @@ -44,6 +68,7 @@ class Demo extends React.Component<
}
> {
matchedKeys: string[] | null;
treeRef: React.MutableRefObject<InstanceType<typeof Tree> | null>;
constructor(props: any) {
super(props);

Expand All @@ -56,6 +81,7 @@ class Demo extends React.Component<

this.handleSearch = this.handleSearch.bind(this);
this.handleExpand = this.handleExpand.bind(this);
this.treeRef = React.createRef();
}

handleSearch(value: string) {
Expand All @@ -77,10 +103,15 @@ class Demo extends React.Component<
});
loop(data);

this.setState({
expandedKeys: [...matchedKeys],
autoExpandParent: true,
});
this.setState(
{
expandedKeys: [...matchedKeys],
autoExpandParent: true,
},
() => {
this.treeRef.current?.getInstance?.().scrollFilterNodeIntoView();
}
);
this.matchedKeys = matchedKeys;
}

Expand All @@ -106,8 +137,10 @@ class Demo extends React.Component<
onChange={this.handleSearch}
/>
<Tree
ref={this.treeRef}
expandedKeys={expandedKeys}
autoExpandParent={autoExpandParent}
style={{ maxHeight: '300px', overflow: 'auto' }}
filterTreeNode={filterTreeNode}
onExpand={this.handleExpand}
dataSource={data}
Expand Down
136 changes: 136 additions & 0 deletions components/tree/__tests__/index-spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { Component, createRef, useState } from 'react';
import propTypes from 'prop-types';
import type { MountReturn } from 'cypress/react';
import { KEYCODE } from '../../util';
import Tree from '../index';
import Button from '../../button/index';
Expand Down Expand Up @@ -52,6 +53,35 @@ const dataSource: DataNode[] = [
},
];

function generateDataSource(count = 100) {
return new Array(count).fill(null).map((__, i) => {
return {
label: '服装',
key: `${i}`,
className: `k-${i}`,
children: [
{
label: '男装',
key: `${i}_${i}`,
className: `k-${i}-${i}`,
children: [
{
label: '外套',
key: `${i}_${i}_${i}`,
className: `k-${i}-${i}-${i}`,
},
{
label: '夹克',
key: `${i}_${i}_${i}_${i}`,
className: `k-${i}-${i}-${i}-${i}`,
},
],
},
],
};
});
}

function createMap(data: DataNode[]) {
const map: Record<Key, DataNode> = {};

Expand Down Expand Up @@ -278,6 +308,30 @@ function renderTreeNodeWithData(dataSource: DataNode[]) {
return drill(dataSource);
}

function shouldTreeNodeInViewport(targetSelector: string, targetIndex = 0) {
return cy.window().then(win => {
const { innerWidth: width, innerHeight: height } = win;
cy.get(targetSelector).its('length').should('be.at.least', 1);
return cy
.get(targetSelector)
.then($el => {
const itemNode = $el.get(targetIndex);
const elementRect = itemNode.getBoundingClientRect();
const isTopVisible = elementRect.top >= 0 && elementRect.top <= height;
const isBottomVisible = elementRect.bottom >= 0 && elementRect.bottom <= height;

if (isTopVisible || isBottomVisible) {
const isLeftVisible = elementRect.left >= 0 && elementRect.left <= width;
const isRightVisible = elementRect.right >= 0 && elementRect.right <= width;
return isTopVisible && isLeftVisible && isBottomVisible && isRightVisible;
}

return false;
})
.should('be.true');
});
}

class ExpandDemo extends Component {
state = {
expandedKeys: ['2'],
Expand Down Expand Up @@ -1458,4 +1512,86 @@ describe('Tree', () => {
expect(itemSizeGetter).to.equal(itemSizeGetter);
});
});

// fix: https://github.com/alibaba-fusion/next/issues/2930
it('The Tree component provides the scrollFilterNodeIntoView API, which scrolls the first matched node of a search into view', () => {
const dataSource = generateDataSource();
dataSource.push({
label: '服装',
key: '100',
className: 'k-100',
children: [
{
label: '女装',
key: '100_100',
className: 'k-100-100',
children: [
{
label: '裙子',
key: '100_100_100',
className: 'k-100-100-100',
},
{
label: '毛衣',
key: '100_100_100_100',
className: 'k-100-100-100-100',
},
],
},
],
});
let expandedKeys = ['100_100_100_100'];
let treeRef: InstanceType<typeof Tree> | null = null;

cy.mount(
<Tree
ref={ref => {
treeRef = ref;
}}
expandedKeys={expandedKeys}
autoExpandParent
dataSource={dataSource}
filterTreeNode={node => expandedKeys.indexOf(node.props.eventKey!) > -1}
/>
).as('wrapper');
// 1. 模拟搜索 "毛衣(.k-100-100-100-100)" 节点
findTreeNodeByKey('100-100-100-100').first().should('has.class', 'next-filtered');
cy.then(() => {
treeRef?.getInstance().scrollFilterNodeIntoView();
shouldTreeNodeInViewport('.k-100-100-100-100');
});

// 2. 清空搜索
cy.then(() => {
expandedKeys = [];
});
cy.get<MountReturn>('@wrapper').then(({ component, rerender }) => {
return rerender(
React.cloneElement(component as React.ReactElement, {
expandedKeys,
})
);
});
cy.get('.next-filtered').should('not.exist');
cy.then(() => {
treeRef?.getInstance().scrollFilterNodeIntoView();
shouldTreeNodeInViewport('.k-100');
});

// 3. 模拟搜索 "男装(.k-i-i)" 节点
cy.then(() => {
expandedKeys = new Array(100).fill(null).map((_, i) => `${i}_${i}`);
});
cy.get<MountReturn>('@wrapper').then(({ component, rerender }) => {
return rerender(
React.cloneElement(component as React.ReactElement, {
expandedKeys,
})
);
});
cy.then(() => {
treeRef?.getInstance().scrollFilterNodeIntoView();
shouldTreeNodeInViewport('.k-0-0');
});
});
});
37 changes: 33 additions & 4 deletions components/tree/view/tree.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { Component, Children, cloneElement, createRef } from 'react';
import { findDOMNode } from 'react-dom';
import PropTypes from 'prop-types';
import { cloneDeep } from 'lodash';
import { polyfill } from 'react-lifecycles-compat';
Expand Down Expand Up @@ -415,6 +416,7 @@ export class Tree extends Component<TreeProps, TreeState> {
dragNode: NodeInstance | null;
dragNodesKeys: Key[];

normalListRef: React.MutableRefObject<HTMLUListElement | null>;
virtualListRef: React.RefObject<VirtualList>;

constructor(props: TreeProps) {
Expand Down Expand Up @@ -466,8 +468,9 @@ export class Tree extends Component<TreeProps, TreeState> {
this.tabbableKey = this.getFirstAvaliablelChildKey('0');
}

bindCtx(this, ['handleExpand', 'handleSelect', 'handleCheck', 'handleBlur']);
bindCtx(this, ['handleExpand', 'handleSelect', 'handleCheck', 'handleBlur', 'setListRef']);

this.normalListRef = createRef();
this.virtualListRef = createRef();
}

Expand Down Expand Up @@ -531,6 +534,23 @@ export class Tree extends Component<TreeProps, TreeState> {
};
}

scrollFilterNodeIntoView(arg?: boolean) {
const { prefix } = this.props;
try {
const treeNode = findDOMNode(this.normalListRef.current) as HTMLElement;
const itemNode = treeNode.querySelector<
HTMLLIElement & { scrollIntoViewIfNeeded: (centerIfNeeded?: boolean) => void }
>(`.${prefix}tree-node.${prefix}filtered`);
if (!itemNode) return;
itemNode.scrollIntoViewIfNeeded
? itemNode.scrollIntoViewIfNeeded(arg)
: itemNode.scrollIntoView?.(arg);
} catch (ex) {
// eslint-disable-next-line no-console
console.warn(ex);
}
}

setFocusKey() {
const { selectedKeys = [] } = this.state;
this.setState({
Expand Down Expand Up @@ -1196,6 +1216,15 @@ export class Tree extends Component<TreeProps, TreeState> {
return loop(this.props.children);
}

setListRef(ref?: React.RefCallback<HTMLUListElement>): React.RefCallback<HTMLUListElement> {
return c => {
if (typeof ref === 'function') {
ref(c);
}
this.normalListRef.current = c;
};
}

render() {
const {
prefix,
Expand Down Expand Up @@ -1230,12 +1259,12 @@ export class Tree extends Component<TreeProps, TreeState> {

const treeRender = (
items: (React.ReactElement | React.ReactElement[])[],
ref?: React.RefObject<HTMLUListElement>
ref?: React.RefCallback<HTMLUListElement>
) => {
return (
<ul
role="tree"
ref={ref}
ref={this.setListRef(ref)}
aria-multiselectable={multiple}
onBlur={this.handleBlur}
className={newClassName}
Expand All @@ -1255,7 +1284,7 @@ export class Tree extends Component<TreeProps, TreeState> {
ref={this.virtualListRef}
itemsRenderer={(
items: React.ReactElement[],
ref: React.RefObject<HTMLUListElement>
ref: React.RefCallback<HTMLUListElement>
) => treeRender(items, ref)}
{...virtualListProps}
>
Expand Down

0 comments on commit 9ffdfe8

Please sign in to comment.