Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Tree): The Tree component provides the scrollFilterNodeIntoView API, which scrolls the first matched node of a search into view #4860

Merged
merged 1 commit into from
Jul 4, 2024
Merged
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
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 @@ -105,7 +106,7 @@
props: TreeProps,
willReceiveProps: boolean,
_k2n: KeyEntities,
_p2n: unknown

Check warning on line 109 in components/tree/view/tree.tsx

View workflow job for this annotation

GitHub Actions / changed

'_p2n' is defined but never used
) => {
let selectedKeys =
'selectedKeys' in props
Expand Down Expand Up @@ -415,6 +416,7 @@
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 @@
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 @@
};
}

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 @@
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 @@

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 @@
ref={this.virtualListRef}
itemsRenderer={(
items: React.ReactElement[],
ref: React.RefObject<HTMLUListElement>
ref: React.RefCallback<HTMLUListElement>
) => treeRender(items, ref)}
{...virtualListProps}
>
Expand Down
Loading