@snack-uikit/tree 0.1.8-preview-85c5f47b.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -0
- package/LICENSE +201 -0
- package/README.md +58 -0
- package/dist/components/Tree/Tree.d.ts +8 -0
- package/dist/components/Tree/Tree.js +35 -0
- package/dist/components/Tree/index.d.ts +1 -0
- package/dist/components/Tree/index.js +1 -0
- package/dist/components/Tree/styles.module.css +4 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/constants.d.ts +20 -0
- package/dist/constants.js +21 -0
- package/dist/contexts/TreeContext.d.ts +23 -0
- package/dist/contexts/TreeContext.js +99 -0
- package/dist/helperComponents/ExpandableTreeNode/ExpandableTreeNode.d.ts +11 -0
- package/dist/helperComponents/ExpandableTreeNode/ExpandableTreeNode.js +90 -0
- package/dist/helperComponents/ExpandableTreeNode/index.d.ts +1 -0
- package/dist/helperComponents/ExpandableTreeNode/index.js +1 -0
- package/dist/helperComponents/ExpandableTreeNode/styles.module.css +34 -0
- package/dist/helperComponents/TreeItem/TreeItem.d.ts +8 -0
- package/dist/helperComponents/TreeItem/TreeItem.js +12 -0
- package/dist/helperComponents/TreeItem/index.d.ts +1 -0
- package/dist/helperComponents/TreeItem/index.js +1 -0
- package/dist/helperComponents/TreeLine/TreeLine.d.ts +7 -0
- package/dist/helperComponents/TreeLine/TreeLine.js +6 -0
- package/dist/helperComponents/TreeLine/index.d.ts +1 -0
- package/dist/helperComponents/TreeLine/index.js +1 -0
- package/dist/helperComponents/TreeLine/styles.module.css +37 -0
- package/dist/helperComponents/TreeNode/TreeNode.d.ts +11 -0
- package/dist/helperComponents/TreeNode/TreeNode.js +146 -0
- package/dist/helperComponents/TreeNode/components/TreeNodeActions.d.ts +14 -0
- package/dist/helperComponents/TreeNode/components/TreeNodeActions.js +39 -0
- package/dist/helperComponents/TreeNode/components/index.d.ts +1 -0
- package/dist/helperComponents/TreeNode/components/index.js +1 -0
- package/dist/helperComponents/TreeNode/index.d.ts +1 -0
- package/dist/helperComponents/TreeNode/index.js +1 -0
- package/dist/helperComponents/TreeNode/styles.module.css +114 -0
- package/dist/helperComponents/TreeNode/utils.d.ts +2 -0
- package/dist/helperComponents/TreeNode/utils.js +3 -0
- package/dist/helperComponents/index.d.ts +4 -0
- package/dist/helperComponents/index.js +4 -0
- package/dist/helpers.d.ts +11 -0
- package/dist/helpers.js +78 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +1 -0
- package/package.json +47 -0
- package/src/components/Tree/Tree.tsx +55 -0
- package/src/components/Tree/index.ts +1 -0
- package/src/components/Tree/styles.module.scss +4 -0
- package/src/components/index.ts +1 -0
- package/src/constants.ts +22 -0
- package/src/contexts/TreeContext.tsx +165 -0
- package/src/helperComponents/ExpandableTreeNode/ExpandableTreeNode.tsx +126 -0
- package/src/helperComponents/ExpandableTreeNode/index.ts +1 -0
- package/src/helperComponents/ExpandableTreeNode/styles.module.scss +43 -0
- package/src/helperComponents/TreeItem/TreeItem.tsx +22 -0
- package/src/helperComponents/TreeItem/index.ts +1 -0
- package/src/helperComponents/TreeLine/TreeLine.tsx +22 -0
- package/src/helperComponents/TreeLine/index.ts +1 -0
- package/src/helperComponents/TreeLine/styles.module.scss +58 -0
- package/src/helperComponents/TreeNode/TreeNode.tsx +288 -0
- package/src/helperComponents/TreeNode/components/TreeNodeActions.tsx +109 -0
- package/src/helperComponents/TreeNode/components/index.ts +1 -0
- package/src/helperComponents/TreeNode/index.ts +1 -0
- package/src/helperComponents/TreeNode/styles.module.scss +144 -0
- package/src/helperComponents/TreeNode/utils.ts +5 -0
- package/src/helperComponents/index.ts +4 -0
- package/src/helpers.ts +107 -0
- package/src/index.ts +2 -0
- package/src/types.ts +97 -0
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function findAllChildNodeIds(nodes) {
|
|
2
|
+
var _a;
|
|
3
|
+
const stack = [...nodes];
|
|
4
|
+
const ids = [];
|
|
5
|
+
let node;
|
|
6
|
+
while ((node = stack.pop())) {
|
|
7
|
+
if (!node.disabled) {
|
|
8
|
+
ids.push(node.id);
|
|
9
|
+
if ((_a = node.nested) === null || _a === void 0 ? void 0 : _a.length) {
|
|
10
|
+
stack.push(...node.nested);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return ids;
|
|
15
|
+
}
|
|
16
|
+
export function checkNestedNodesSelection(nodes, selectedKeys) {
|
|
17
|
+
const allIds = findAllChildNodeIds(nodes);
|
|
18
|
+
const selected = allIds.filter(id => selectedKeys.includes(id));
|
|
19
|
+
const someSelected = selected.length > 0;
|
|
20
|
+
const allSelected = someSelected && allIds.length === selected.length;
|
|
21
|
+
return {
|
|
22
|
+
someSelected: !allSelected && someSelected,
|
|
23
|
+
allSelected,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function findAllExpandedChildNodeIds(nodes, expandedNodes) {
|
|
27
|
+
var _a;
|
|
28
|
+
const stack = [...nodes];
|
|
29
|
+
const ids = [];
|
|
30
|
+
let node;
|
|
31
|
+
while ((node = stack.shift())) {
|
|
32
|
+
ids.push(node.id);
|
|
33
|
+
if (((_a = node.nested) === null || _a === void 0 ? void 0 : _a.length) && expandedNodes.includes(node.id)) {
|
|
34
|
+
stack.unshift(...node.nested);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return ids;
|
|
38
|
+
}
|
|
39
|
+
export function lookupTreeForSelectedNodes({ node, selectedNodes, parentNode, }) {
|
|
40
|
+
var _a;
|
|
41
|
+
const { nested } = node;
|
|
42
|
+
const childSelection = (nested === null || nested === void 0 ? void 0 : nested.length) ? checkNestedNodesSelection(nested, selectedNodes) : undefined;
|
|
43
|
+
const isSelected = childSelection
|
|
44
|
+
? childSelection.someSelected || childSelection.allSelected
|
|
45
|
+
: selectedNodes.includes(node.id);
|
|
46
|
+
let updatedSelectedNodes = [];
|
|
47
|
+
const allIdsFromNode = [node.id];
|
|
48
|
+
if (nested === null || nested === void 0 ? void 0 : nested.length) {
|
|
49
|
+
allIdsFromNode.push(...findAllChildNodeIds(nested));
|
|
50
|
+
}
|
|
51
|
+
if (isSelected) {
|
|
52
|
+
updatedSelectedNodes = selectedNodes.filter(id => !allIdsFromNode.includes(id));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
updatedSelectedNodes = selectedNodes.concat(allIdsFromNode);
|
|
56
|
+
}
|
|
57
|
+
if (parentNode) {
|
|
58
|
+
let parent = parentNode;
|
|
59
|
+
while (parent) {
|
|
60
|
+
if ((_a = parent.nested) === null || _a === void 0 ? void 0 : _a.length) {
|
|
61
|
+
const parentNestedSelection = checkNestedNodesSelection(parent.nested, updatedSelectedNodes);
|
|
62
|
+
if (isSelected) {
|
|
63
|
+
if (parentNestedSelection.allSelected || parentNestedSelection.someSelected) {
|
|
64
|
+
const parentIdIndex = updatedSelectedNodes.findIndex(id => id === (parent === null || parent === void 0 ? void 0 : parent.id));
|
|
65
|
+
if (parentIdIndex > -1) {
|
|
66
|
+
updatedSelectedNodes.splice(parentIdIndex, 1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (!isSelected && parentNestedSelection.allSelected) {
|
|
71
|
+
updatedSelectedNodes.push(parent.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
parent = parent.parentNode;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return updatedSelectedNodes;
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './components';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { MouseEvent, MouseEventHandler, ReactNode } from 'react';
|
|
2
|
+
import { ItemSingleProps } from '@snack-uikit/droplist';
|
|
3
|
+
import { WithSupportProps } from '@snack-uikit/utils';
|
|
4
|
+
import { SelectionMode } from './constants';
|
|
5
|
+
export type TreeNodeId = string;
|
|
6
|
+
export type BaseTreeNode = WithSupportProps<{
|
|
7
|
+
/** Идентификатор элемента */
|
|
8
|
+
id: TreeNodeId;
|
|
9
|
+
/** Имя элемента */
|
|
10
|
+
title: string;
|
|
11
|
+
/** Является ли элемент деактивированным */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Обработчик клика по элементу */
|
|
14
|
+
onClick?: MouseEventHandler;
|
|
15
|
+
className?: string;
|
|
16
|
+
}>;
|
|
17
|
+
export type ChildTreeNode = BaseTreeNode & {
|
|
18
|
+
/** Иконка дочернего элемента */
|
|
19
|
+
icon?: ReactNode;
|
|
20
|
+
nested?: never;
|
|
21
|
+
expandedIcon?: never;
|
|
22
|
+
collapsedIcon?: never;
|
|
23
|
+
};
|
|
24
|
+
export type ParentTreeNode = BaseTreeNode & {
|
|
25
|
+
icon?: never;
|
|
26
|
+
/** Иконка элемента-родителя в открытом состоянии */
|
|
27
|
+
expandedIcon?: ReactNode;
|
|
28
|
+
/** Иконка элемента-родителя в закрытом состоянии */
|
|
29
|
+
collapsedIcon?: ReactNode;
|
|
30
|
+
/** Вложенные элементы дерева */
|
|
31
|
+
nested: (ChildTreeNode | ParentTreeNode)[];
|
|
32
|
+
};
|
|
33
|
+
export type TreeNodeProps = ChildTreeNode | ParentTreeNode;
|
|
34
|
+
export type ParentNode = Pick<TreeNodeProps, 'id' | 'nested'> & {
|
|
35
|
+
parentNode?: ParentNode;
|
|
36
|
+
};
|
|
37
|
+
export type OnNodeClick = (node: TreeNodeProps, e: MouseEvent) => void;
|
|
38
|
+
export type TreeCommonProps = {
|
|
39
|
+
/** Данные для отрисовки */
|
|
40
|
+
data: TreeNodeProps[];
|
|
41
|
+
/**
|
|
42
|
+
* Режим выбора элементов:
|
|
43
|
+
* <br> - `Single` - одиночный выбор
|
|
44
|
+
* <br> - `Multi` - множественный выбор
|
|
45
|
+
*/
|
|
46
|
+
selectionMode?: SelectionMode;
|
|
47
|
+
/** Обработчик клика по элементу дерева */
|
|
48
|
+
onNodeClick?: OnNodeClick;
|
|
49
|
+
/** Состояние для раскрытых элементов */
|
|
50
|
+
expandedNodes?: TreeNodeId[];
|
|
51
|
+
/** Колбэк при раскрытии/закрытии элементов */
|
|
52
|
+
onExpand?(expandedKeys: TreeNodeId[], nodeId: TreeNodeId): void;
|
|
53
|
+
/** Состояние для выбранных элементов */
|
|
54
|
+
selected?: TreeNodeId[] | TreeNodeId;
|
|
55
|
+
/** Колбэк для асинхронной загрузки данных при раскрытии дерева */
|
|
56
|
+
onDataLoad?(node: TreeNodeProps): Promise<boolean | unknown>;
|
|
57
|
+
/** Дополнительные действия для элемента-родителя */
|
|
58
|
+
parentActions?(node: TreeNodeProps): ItemSingleProps[];
|
|
59
|
+
/** Дополнительные действия для элемента-потомка */
|
|
60
|
+
nodeActions?(node: TreeNodeProps): ItemSingleProps[];
|
|
61
|
+
/** Флаг отвечающий за отображение линий вложенности */
|
|
62
|
+
showLines?: boolean;
|
|
63
|
+
className?: string;
|
|
64
|
+
};
|
|
65
|
+
export type TreeSingleSelect = Omit<TreeCommonProps, 'selected'> & {
|
|
66
|
+
selectionMode: SelectionMode.Single;
|
|
67
|
+
/** Состояние для выбраного элемента */
|
|
68
|
+
/** <br> - При <strong>selectionMode</strong>=`Single` - принимает строку */
|
|
69
|
+
selected?: TreeNodeId;
|
|
70
|
+
/** <br> - При <strong>selectionMode</strong>=`Single` - возвращает строку */
|
|
71
|
+
onSelect?(selectedKey: TreeNodeId, node: TreeNodeProps): void;
|
|
72
|
+
};
|
|
73
|
+
export type TreeMultiSelect = Omit<TreeCommonProps, 'selected'> & {
|
|
74
|
+
selectionMode: SelectionMode.Multi;
|
|
75
|
+
/**
|
|
76
|
+
* Состояние для выбраных элементов:
|
|
77
|
+
* <br> - При <strong>selectionMode</strong>=`Multi` - принимает массив строк
|
|
78
|
+
*/
|
|
79
|
+
selected?: TreeNodeId[];
|
|
80
|
+
/**
|
|
81
|
+
* Колбэк при изменении выбраных элементов:
|
|
82
|
+
* <br> - При <strong>selectionMode</strong>=`Multi` - возвращает массив строк
|
|
83
|
+
*/
|
|
84
|
+
onSelect?(selectedKeys: TreeNodeId[], node: TreeNodeProps): void;
|
|
85
|
+
};
|
|
86
|
+
export type TreeBaseProps = TreeMultiSelect | TreeSingleSelect;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@snack-uikit/tree",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"title": "Tree",
|
|
7
|
+
"version": "0.1.8-preview-85c5f47b.0",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"*.css",
|
|
10
|
+
"*.woff",
|
|
11
|
+
"*.woff2"
|
|
12
|
+
],
|
|
13
|
+
"description": "",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"homepage": "https://github.com/cloud-ru-tech/snack-uikit/tree/master/packages/tree",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/cloud-ru-tech/snack-uikit.git",
|
|
20
|
+
"directory": "packages/tree"
|
|
21
|
+
},
|
|
22
|
+
"author": "Белов Алексей <anbelov@cloud.ru>",
|
|
23
|
+
"contributors": [
|
|
24
|
+
"Белов Алексей <anbelov@cloud.ru>"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"./CHANGELOG.md",
|
|
30
|
+
"./LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"license": "Apache-2.0",
|
|
33
|
+
"scripts": {},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@snack-uikit/button": "0.13.7-preview-85c5f47b.0",
|
|
36
|
+
"@snack-uikit/droplist": "0.10.13-preview-85c5f47b.0",
|
|
37
|
+
"@snack-uikit/icons": "0.18.2-preview-85c5f47b.0",
|
|
38
|
+
"@snack-uikit/toggles": "0.7.1-preview-85c5f47b.0",
|
|
39
|
+
"@snack-uikit/truncate-string": "0.2.17-preview-85c5f47b.0",
|
|
40
|
+
"@snack-uikit/typography": "0.4.5-preview-85c5f47b.0",
|
|
41
|
+
"@snack-uikit/utils": "2.0.2-preview-85c5f47b.0",
|
|
42
|
+
"classnames": "2.3.2",
|
|
43
|
+
"react-transition-state": "2.1.1",
|
|
44
|
+
"uncontrollable": "8.0.4"
|
|
45
|
+
},
|
|
46
|
+
"gitHead": "6a7eef41d780d945f64f791448eaa72b9e0ae72d"
|
|
47
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import cn from 'classnames';
|
|
2
|
+
|
|
3
|
+
import { extractSupportProps, WithSupportProps } from '@snack-uikit/utils';
|
|
4
|
+
|
|
5
|
+
import { SelectionMode } from '../../constants';
|
|
6
|
+
import { TreeContextProvider } from '../../contexts/TreeContext';
|
|
7
|
+
import { TreeItem } from '../../helperComponents';
|
|
8
|
+
import { TreeBaseProps, TreeMultiSelect, TreeNodeId, TreeSingleSelect } from '../../types';
|
|
9
|
+
import styles from './styles.module.scss';
|
|
10
|
+
|
|
11
|
+
export type TreeProps = WithSupportProps<TreeBaseProps>;
|
|
12
|
+
|
|
13
|
+
export function Tree({
|
|
14
|
+
data,
|
|
15
|
+
selected,
|
|
16
|
+
selectionMode,
|
|
17
|
+
onSelect,
|
|
18
|
+
onNodeClick,
|
|
19
|
+
onExpand,
|
|
20
|
+
expandedNodes,
|
|
21
|
+
nodeActions,
|
|
22
|
+
parentActions,
|
|
23
|
+
onDataLoad,
|
|
24
|
+
showLines = true,
|
|
25
|
+
className,
|
|
26
|
+
...rest
|
|
27
|
+
}: TreeProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn(styles.tree, className)} role='tree' {...extractSupportProps(rest)}>
|
|
30
|
+
<TreeContextProvider
|
|
31
|
+
value={{
|
|
32
|
+
data,
|
|
33
|
+
selected: selected as typeof SelectionMode extends SelectionMode.Single ? TreeNodeId : TreeNodeId[],
|
|
34
|
+
selectionMode: selectionMode as typeof selected extends string ? SelectionMode.Single : SelectionMode.Multi,
|
|
35
|
+
onSelect: onSelect as typeof SelectionMode extends SelectionMode.Single
|
|
36
|
+
? TreeSingleSelect['onSelect']
|
|
37
|
+
: TreeMultiSelect['onSelect'],
|
|
38
|
+
expandedNodes,
|
|
39
|
+
onNodeClick,
|
|
40
|
+
onExpand,
|
|
41
|
+
nodeActions,
|
|
42
|
+
parentActions,
|
|
43
|
+
onDataLoad,
|
|
44
|
+
showLines,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{data.map((node, index) => (
|
|
48
|
+
<TreeItem key={node.id} node={node} tabIndexAvailable={index === 0 || index === data.length - 1} />
|
|
49
|
+
))}
|
|
50
|
+
</TreeContextProvider>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Tree.selectionModes = SelectionMode;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Tree';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Tree';
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export enum SelectionMode {
|
|
2
|
+
Single = 'single',
|
|
3
|
+
Multi = 'multi',
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const TRANSITION_TIMING = {
|
|
7
|
+
accordionFolding: 200,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const TEST_IDS = {
|
|
11
|
+
node: 'tree__node',
|
|
12
|
+
line: 'tree__node__line',
|
|
13
|
+
item: 'tree__node__item',
|
|
14
|
+
chevron: 'tree__node__chevron',
|
|
15
|
+
checkbox: 'tree__node__checkbox',
|
|
16
|
+
icon: 'tree__node__icon',
|
|
17
|
+
title: 'tree__node__title',
|
|
18
|
+
droplistTrigger: 'tree__node__droplist-trigger',
|
|
19
|
+
droplistAction: 'tree__node__droplist-action',
|
|
20
|
+
expandable: 'tree__node__expandable',
|
|
21
|
+
expandableContent: 'tree__node__expandable-content',
|
|
22
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useMemo, useState } from 'react';
|
|
2
|
+
import { useUncontrolledProp } from 'uncontrollable';
|
|
3
|
+
|
|
4
|
+
import { SelectionMode } from '../constants';
|
|
5
|
+
import { findAllExpandedChildNodeIds, lookupTreeForSelectedNodes } from '../helpers';
|
|
6
|
+
import { OnNodeClick, ParentNode, TreeBaseProps, TreeNodeId, TreeNodeProps } from '../types';
|
|
7
|
+
|
|
8
|
+
type TreeContextProps = Omit<TreeBaseProps, 'onSelect' | 'onExpand' | 'onNodeClick' | 'selected' | 'selectionMode'> & {
|
|
9
|
+
onExpand(node: TreeNodeProps): void;
|
|
10
|
+
onSelect(node: Pick<TreeNodeProps, 'id' | 'nested' | 'disabled'>, parentNode?: ParentNode): void;
|
|
11
|
+
selected?: TreeNodeId[] | TreeNodeId;
|
|
12
|
+
onNodeClick: OnNodeClick;
|
|
13
|
+
isMultiSelect: boolean;
|
|
14
|
+
isSingleSelect: boolean;
|
|
15
|
+
focusedNodeId?: TreeNodeId;
|
|
16
|
+
setFocusPosition(id: TreeNodeId): void;
|
|
17
|
+
resetFocusPosition(): void;
|
|
18
|
+
setFocusIndex: Dispatch<SetStateAction<number | undefined>>;
|
|
19
|
+
focusableNodeIds: TreeNodeId[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type TreeContextProviderProps = {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
value: TreeBaseProps;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const TreeContext = createContext<TreeContextProps>({
|
|
28
|
+
data: [],
|
|
29
|
+
selected: undefined,
|
|
30
|
+
onSelect() {},
|
|
31
|
+
expandedNodes: [],
|
|
32
|
+
onExpand() {
|
|
33
|
+
return new Promise(resolve => resolve);
|
|
34
|
+
},
|
|
35
|
+
onNodeClick() {},
|
|
36
|
+
setFocusPosition() {},
|
|
37
|
+
setFocusIndex() {},
|
|
38
|
+
resetFocusPosition() {},
|
|
39
|
+
focusableNodeIds: [],
|
|
40
|
+
isMultiSelect: false,
|
|
41
|
+
isSingleSelect: false,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function TreeContextProvider({ children, value }: TreeContextProviderProps) {
|
|
45
|
+
const {
|
|
46
|
+
onNodeClick: onNodeClickProp,
|
|
47
|
+
onExpand: onExpandProp,
|
|
48
|
+
onSelect: onSelectProp,
|
|
49
|
+
selectionMode,
|
|
50
|
+
data,
|
|
51
|
+
...props
|
|
52
|
+
} = value;
|
|
53
|
+
|
|
54
|
+
const isMultiSelect = selectionMode === SelectionMode.Multi;
|
|
55
|
+
const isSingleSelect = selectionMode === SelectionMode.Single;
|
|
56
|
+
|
|
57
|
+
const [expandedNodes, onExpandHandler] = useUncontrolledProp<TreeNodeId[]>(
|
|
58
|
+
value.expandedNodes,
|
|
59
|
+
value.expandedNodes || [],
|
|
60
|
+
onExpandProp,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const onExpand = useCallback<TreeContextProps['onExpand']>(
|
|
64
|
+
node => {
|
|
65
|
+
const isExpanded = expandedNodes.includes(node.id);
|
|
66
|
+
|
|
67
|
+
const nodes = isExpanded ? expandedNodes.filter(id => id !== node.id) : expandedNodes.concat(node.id);
|
|
68
|
+
|
|
69
|
+
onExpandHandler(nodes, node);
|
|
70
|
+
},
|
|
71
|
+
[expandedNodes, onExpandHandler],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const [selectedNodes, onSelectHandler] = useUncontrolledProp<typeof value.selected>(value.selected, [], onSelectProp);
|
|
75
|
+
|
|
76
|
+
const onSelect = useCallback<TreeContextProps['onSelect']>(
|
|
77
|
+
(node, parentNode) => {
|
|
78
|
+
if (node.disabled) return;
|
|
79
|
+
|
|
80
|
+
if (isSingleSelect) {
|
|
81
|
+
onSelectHandler(node.id, node);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(selectedNodes)) {
|
|
86
|
+
const updatedSelectedNodes = lookupTreeForSelectedNodes({ node, parentNode, selectedNodes });
|
|
87
|
+
|
|
88
|
+
onSelectHandler(updatedSelectedNodes, node);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[isSingleSelect, onSelectHandler, selectedNodes],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const focusableNodeIds = useMemo(() => {
|
|
95
|
+
if (!expandedNodes?.length) {
|
|
96
|
+
return data.map(node => node.id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return data.reduce<string[]>((acc, cur) => {
|
|
100
|
+
acc.push(cur.id);
|
|
101
|
+
|
|
102
|
+
const isExpanded = expandedNodes.includes(cur.id);
|
|
103
|
+
|
|
104
|
+
if (isExpanded && cur.nested?.length) {
|
|
105
|
+
acc.push(...findAllExpandedChildNodeIds(cur.nested, expandedNodes));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return acc;
|
|
109
|
+
}, []);
|
|
110
|
+
}, [data, expandedNodes]);
|
|
111
|
+
|
|
112
|
+
const [focusIndex, setFocusIndex] = useState<number>();
|
|
113
|
+
|
|
114
|
+
const focusedNodeId = focusIndex !== undefined ? focusableNodeIds[focusIndex] : undefined;
|
|
115
|
+
|
|
116
|
+
const setFocusPosition = useCallback(
|
|
117
|
+
(nodeId: TreeNodeId) => {
|
|
118
|
+
const newFocusIndex = focusableNodeIds.indexOf(nodeId);
|
|
119
|
+
setFocusIndex(newFocusIndex >= 0 ? newFocusIndex : undefined);
|
|
120
|
+
},
|
|
121
|
+
[focusableNodeIds],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const resetFocusPosition = useCallback(() => setFocusIndex(undefined), []);
|
|
125
|
+
|
|
126
|
+
const onNodeClick = useCallback<OnNodeClick>(
|
|
127
|
+
(node, e) => {
|
|
128
|
+
if (node.disabled) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isSingleSelect) {
|
|
133
|
+
onSelect(node);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
node.onClick?.(e) || onNodeClickProp?.(node, e);
|
|
137
|
+
},
|
|
138
|
+
[isSingleSelect, onSelect, onNodeClickProp],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<TreeContext.Provider
|
|
143
|
+
value={{
|
|
144
|
+
...props,
|
|
145
|
+
data,
|
|
146
|
+
selected: selectedNodes,
|
|
147
|
+
isSingleSelect,
|
|
148
|
+
isMultiSelect,
|
|
149
|
+
expandedNodes,
|
|
150
|
+
onExpand,
|
|
151
|
+
onSelect,
|
|
152
|
+
onNodeClick,
|
|
153
|
+
focusedNodeId,
|
|
154
|
+
setFocusPosition,
|
|
155
|
+
resetFocusPosition,
|
|
156
|
+
setFocusIndex,
|
|
157
|
+
focusableNodeIds,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{children}
|
|
161
|
+
</TreeContext.Provider>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const useTreeContext = () => useContext(TreeContext);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { KeyboardEventHandler, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import useTransition from 'react-transition-state';
|
|
3
|
+
|
|
4
|
+
import { TEST_IDS, TRANSITION_TIMING } from '../../constants';
|
|
5
|
+
import { useTreeContext } from '../../contexts/TreeContext';
|
|
6
|
+
import { ParentNode } from '../../types';
|
|
7
|
+
import { TreeItem } from '../TreeItem';
|
|
8
|
+
import { TreeLine } from '../TreeLine';
|
|
9
|
+
import { TreeNode, TreeNodeProps } from '../TreeNode';
|
|
10
|
+
import styles from './styles.module.scss';
|
|
11
|
+
|
|
12
|
+
type ExpandableTreeNodeProps = {
|
|
13
|
+
node: TreeNodeProps;
|
|
14
|
+
parentNode?: ParentNode & { parentNode?: ParentNode };
|
|
15
|
+
tabIndexAvailable?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function ExpandableTreeNode({
|
|
19
|
+
node: { 'data-test-id': dataTestId, ...node },
|
|
20
|
+
parentNode,
|
|
21
|
+
tabIndexAvailable,
|
|
22
|
+
}: ExpandableTreeNodeProps) {
|
|
23
|
+
const { expandedNodes, onExpand, onDataLoad, showLines } = useTreeContext();
|
|
24
|
+
|
|
25
|
+
const isExpandable = Boolean(node.nested);
|
|
26
|
+
const isExpanded = expandedNodes?.includes(node.id) || false;
|
|
27
|
+
|
|
28
|
+
const [isLoading, setLoading] = useState(false);
|
|
29
|
+
|
|
30
|
+
const [state, toggle] = useTransition({
|
|
31
|
+
timeout: TRANSITION_TIMING.accordionFolding,
|
|
32
|
+
initialEntered: isExpanded || undefined,
|
|
33
|
+
enter: isExpanded,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const showContent = node.nested && node.nested.length > 0 && state.status !== 'exited';
|
|
37
|
+
|
|
38
|
+
const isLineHalfHeight = useMemo(() => {
|
|
39
|
+
if (!node.nested?.length) return false;
|
|
40
|
+
|
|
41
|
+
return !node.nested.at(-1)?.nested;
|
|
42
|
+
}, [node.nested]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (node.nested?.length && isExpanded && state.status === 'exited') {
|
|
46
|
+
toggle(true);
|
|
47
|
+
}
|
|
48
|
+
}, [isExpanded, node.nested, state.status]);
|
|
49
|
+
|
|
50
|
+
const toggleExpand = async () => {
|
|
51
|
+
if (node.disabled) return;
|
|
52
|
+
|
|
53
|
+
if (node.nested && !node.nested.length && !isExpanded && onDataLoad) {
|
|
54
|
+
setLoading(true);
|
|
55
|
+
|
|
56
|
+
await onDataLoad(node).finally(() => {
|
|
57
|
+
setLoading(false);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onExpand(node);
|
|
62
|
+
|
|
63
|
+
toggle();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleKeyDown: KeyboardEventHandler<HTMLElement> = e => {
|
|
67
|
+
if (!isExpandable) return;
|
|
68
|
+
|
|
69
|
+
switch (e.key) {
|
|
70
|
+
case 'ArrowRight': {
|
|
71
|
+
if (!isExpanded) {
|
|
72
|
+
toggleExpand();
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
case 'ArrowLeft': {
|
|
77
|
+
if (isExpanded) {
|
|
78
|
+
toggleExpand();
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className={styles.expandableTreeNode}
|
|
90
|
+
data-test-id={dataTestId}
|
|
91
|
+
data-expandable={Boolean(node.nested) || undefined}
|
|
92
|
+
data-disabled={node.disabled || undefined}
|
|
93
|
+
>
|
|
94
|
+
<TreeNode
|
|
95
|
+
{...node}
|
|
96
|
+
isLoading={isLoading}
|
|
97
|
+
parentNode={parentNode}
|
|
98
|
+
onChevronClick={toggleExpand}
|
|
99
|
+
onKeyDown={handleKeyDown}
|
|
100
|
+
tabIndexAvailable={tabIndexAvailable}
|
|
101
|
+
/>
|
|
102
|
+
|
|
103
|
+
{node.nested && (
|
|
104
|
+
<div
|
|
105
|
+
className={styles.expandableWrap}
|
|
106
|
+
data-expanded={isExpanded || undefined}
|
|
107
|
+
data-test-id={TEST_IDS.expandable}
|
|
108
|
+
>
|
|
109
|
+
{showContent && (
|
|
110
|
+
<div className={styles.expandableContent} data-test-id={TEST_IDS.expandableContent}>
|
|
111
|
+
<TreeLine visible={showLines} halfHeight={isLineHalfHeight} data-test-id={TEST_IDS.line} />
|
|
112
|
+
|
|
113
|
+
<div className={styles.expandableNested}>
|
|
114
|
+
{node.nested.map(nestedNode => {
|
|
115
|
+
const parent: ParentNode = { id: node.id, nested: node.nested, parentNode };
|
|
116
|
+
|
|
117
|
+
return <TreeItem key={nestedNode.id} node={nestedNode} parentNode={parent} />;
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ExpandableTreeNode';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@import '@snack-uikit/figma-tokens/build/scss/components/styles-tokens-tree';
|
|
2
|
+
|
|
3
|
+
.expandableTreeNode {
|
|
4
|
+
position: relative;
|
|
5
|
+
width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.expandableContent {
|
|
9
|
+
overflow: hidden;
|
|
10
|
+
display: flex;
|
|
11
|
+
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
min-height: 0;
|
|
14
|
+
|
|
15
|
+
visibility: hidden;
|
|
16
|
+
|
|
17
|
+
transition: visibility 200ms;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.expandableWrap {
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
display: grid;
|
|
23
|
+
grid-template-rows: 0fr;
|
|
24
|
+
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
width: 100%;
|
|
27
|
+
|
|
28
|
+
transition: grid-template-rows 200ms;
|
|
29
|
+
|
|
30
|
+
&[data-expanded='true'] {
|
|
31
|
+
overflow: unset;
|
|
32
|
+
grid-template-rows: 1fr;
|
|
33
|
+
|
|
34
|
+
.expandableContent {
|
|
35
|
+
visibility: visible;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.expandableNested {
|
|
41
|
+
width: 100%;
|
|
42
|
+
min-width: 0;
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { TEST_IDS } from '../../constants';
|
|
2
|
+
import { ParentNode, TreeNodeProps } from '../../types';
|
|
3
|
+
import { ExpandableTreeNode } from '../ExpandableTreeNode';
|
|
4
|
+
import { TreeNode } from '../TreeNode';
|
|
5
|
+
|
|
6
|
+
type TreeItemProps = {
|
|
7
|
+
node: TreeNodeProps;
|
|
8
|
+
parentNode?: ParentNode;
|
|
9
|
+
tabIndexAvailable?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function TreeItem({ node, tabIndexAvailable, parentNode }: TreeItemProps) {
|
|
13
|
+
const dataTestId = node['data-test-id'] || TEST_IDS.node;
|
|
14
|
+
|
|
15
|
+
const nodeProps: TreeNodeProps = { ...node, 'data-test-id': dataTestId };
|
|
16
|
+
|
|
17
|
+
if (node.nested) {
|
|
18
|
+
return <ExpandableTreeNode node={nodeProps} tabIndexAvailable={tabIndexAvailable} parentNode={parentNode} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return <TreeNode {...nodeProps} tabIndexAvailable={tabIndexAvailable} parentNode={parentNode} />;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './TreeItem';
|