@cloud-ru/ds-tree 1.0.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.
Files changed (231) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +201 -0
  3. package/README.md +341 -0
  4. package/dist/cjs/components/Tree/Tree.d.ts +4 -0
  5. package/dist/cjs/components/Tree/Tree.js +31 -0
  6. package/dist/cjs/components/Tree/index.d.ts +1 -0
  7. package/dist/cjs/components/Tree/index.js +17 -0
  8. package/dist/cjs/components/Tree/styles.module.css +17 -0
  9. package/dist/cjs/components/Tree/utils.d.ts +4 -0
  10. package/dist/cjs/components/Tree/utils.js +15 -0
  11. package/dist/cjs/components/index.d.ts +1 -0
  12. package/dist/cjs/components/index.js +17 -0
  13. package/dist/cjs/constants.d.ts +26 -0
  14. package/dist/cjs/constants.js +29 -0
  15. package/dist/cjs/contexts/TreeContext.d.ts +30 -0
  16. package/dist/cjs/contexts/TreeContext.js +108 -0
  17. package/dist/cjs/helperComponents/ExpandableTreeNode/ExpandableTreeNode.d.ts +11 -0
  18. package/dist/cjs/helperComponents/ExpandableTreeNode/ExpandableTreeNode.js +87 -0
  19. package/dist/cjs/helperComponents/ExpandableTreeNode/index.d.ts +1 -0
  20. package/dist/cjs/helperComponents/ExpandableTreeNode/index.js +17 -0
  21. package/dist/cjs/helperComponents/ExpandableTreeNode/styles.module.css +37 -0
  22. package/dist/cjs/helperComponents/TreeItem/TreeItem.d.ts +8 -0
  23. package/dist/cjs/helperComponents/TreeItem/TreeItem.js +16 -0
  24. package/dist/cjs/helperComponents/TreeItem/index.d.ts +1 -0
  25. package/dist/cjs/helperComponents/TreeItem/index.js +17 -0
  26. package/dist/cjs/helperComponents/TreeLine/TreeLine.d.ts +10 -0
  27. package/dist/cjs/helperComponents/TreeLine/TreeLine.js +13 -0
  28. package/dist/cjs/helperComponents/TreeLine/index.d.ts +1 -0
  29. package/dist/cjs/helperComponents/TreeLine/index.js +17 -0
  30. package/dist/cjs/helperComponents/TreeLine/styles.module.css +37 -0
  31. package/dist/cjs/helperComponents/TreeNode/TreeNode.d.ts +4 -0
  32. package/dist/cjs/helperComponents/TreeNode/TreeNode.js +187 -0
  33. package/dist/cjs/helperComponents/TreeNode/components/TreeNodeActions.d.ts +15 -0
  34. package/dist/cjs/helperComponents/TreeNode/components/TreeNodeActions.js +76 -0
  35. package/dist/cjs/helperComponents/TreeNode/components/TreeNodeHref.d.ts +7 -0
  36. package/dist/cjs/helperComponents/TreeNode/components/TreeNodeHref.js +12 -0
  37. package/dist/cjs/helperComponents/TreeNode/components/index.d.ts +2 -0
  38. package/dist/cjs/helperComponents/TreeNode/components/index.js +18 -0
  39. package/dist/cjs/helperComponents/TreeNode/constants.d.ts +5 -0
  40. package/dist/cjs/helperComponents/TreeNode/constants.js +10 -0
  41. package/dist/cjs/helperComponents/TreeNode/index.d.ts +1 -0
  42. package/dist/cjs/helperComponents/TreeNode/index.js +17 -0
  43. package/dist/cjs/helperComponents/TreeNode/styles.module.css +474 -0
  44. package/dist/cjs/helperComponents/TreeNode/types.d.ts +9 -0
  45. package/dist/cjs/helperComponents/TreeNode/types.js +2 -0
  46. package/dist/cjs/helperComponents/TreeNode/utils.d.ts +3 -0
  47. package/dist/cjs/helperComponents/TreeNode/utils.js +10 -0
  48. package/dist/cjs/helperComponents/index.d.ts +4 -0
  49. package/dist/cjs/helperComponents/index.js +20 -0
  50. package/dist/cjs/helpers/checkNestedNodesSelection.d.ts +9 -0
  51. package/dist/cjs/helpers/checkNestedNodesSelection.js +15 -0
  52. package/dist/cjs/helpers/collectEmptyNestedNodesInExpanded.d.ts +9 -0
  53. package/dist/cjs/helpers/collectEmptyNestedNodesInExpanded.js +21 -0
  54. package/dist/cjs/helpers/collectHierarchicalAncestors.d.ts +3 -0
  55. package/dist/cjs/helpers/collectHierarchicalAncestors.js +18 -0
  56. package/dist/cjs/helpers/collectIds.d.ts +9 -0
  57. package/dist/cjs/helpers/collectIds.js +16 -0
  58. package/dist/cjs/helpers/extractTreeNodeTitle.d.ts +6 -0
  59. package/dist/cjs/helpers/extractTreeNodeTitle.js +9 -0
  60. package/dist/cjs/helpers/findAllChildNodeIds.d.ts +9 -0
  61. package/dist/cjs/helpers/findAllChildNodeIds.js +24 -0
  62. package/dist/cjs/helpers/findAllExpandedChildNodeIds.d.ts +8 -0
  63. package/dist/cjs/helpers/findAllExpandedChildNodeIds.js +21 -0
  64. package/dist/cjs/helpers/getSearchedTreeItems.d.ts +23 -0
  65. package/dist/cjs/helpers/getSearchedTreeItems.js +41 -0
  66. package/dist/cjs/helpers/getSearchedTreeNodeById.d.ts +19 -0
  67. package/dist/cjs/helpers/getSearchedTreeNodeById.js +35 -0
  68. package/dist/cjs/helpers/index.d.ts +13 -0
  69. package/dist/cjs/helpers/index.js +29 -0
  70. package/dist/cjs/helpers/lookupTreeForSelectedNodes.d.ts +15 -0
  71. package/dist/cjs/helpers/lookupTreeForSelectedNodes.js +21 -0
  72. package/dist/cjs/helpers/sortTreeItemsByTitle.d.ts +13 -0
  73. package/dist/cjs/helpers/sortTreeItemsByTitle.js +14 -0
  74. package/dist/cjs/helpers/traverse.d.ts +11 -0
  75. package/dist/cjs/helpers/traverse.js +25 -0
  76. package/dist/cjs/helpers/traverseWithTarget.d.ts +14 -0
  77. package/dist/cjs/helpers/traverseWithTarget.js +30 -0
  78. package/dist/cjs/helpers/updateTreeNode.d.ts +13 -0
  79. package/dist/cjs/helpers/updateTreeNode.js +34 -0
  80. package/dist/cjs/hooks/index.d.ts +2 -0
  81. package/dist/cjs/hooks/index.js +18 -0
  82. package/dist/cjs/hooks/internalHooks.d.ts +4 -0
  83. package/dist/cjs/hooks/internalHooks.js +29 -0
  84. package/dist/cjs/hooks/useSearchableTree.d.ts +52 -0
  85. package/dist/cjs/hooks/useSearchableTree.js +113 -0
  86. package/dist/cjs/hooks/useTreeMultiSelection.d.ts +33 -0
  87. package/dist/cjs/hooks/useTreeMultiSelection.js +37 -0
  88. package/dist/cjs/index.d.ts +17 -0
  89. package/dist/cjs/index.js +47 -0
  90. package/dist/cjs/package.json +3 -0
  91. package/dist/cjs/style.css +565 -0
  92. package/dist/cjs/types.d.ts +151 -0
  93. package/dist/cjs/types.js +2 -0
  94. package/dist/esm/components/Tree/Tree.d.ts +4 -0
  95. package/dist/esm/components/Tree/Tree.js +25 -0
  96. package/dist/esm/components/Tree/index.d.ts +1 -0
  97. package/dist/esm/components/Tree/index.js +1 -0
  98. package/dist/esm/components/Tree/styles.module.css +17 -0
  99. package/dist/esm/components/Tree/utils.d.ts +4 -0
  100. package/dist/esm/components/Tree/utils.js +12 -0
  101. package/dist/esm/components/index.d.ts +1 -0
  102. package/dist/esm/components/index.js +1 -0
  103. package/dist/esm/constants.d.ts +26 -0
  104. package/dist/esm/constants.js +26 -0
  105. package/dist/esm/contexts/TreeContext.d.ts +30 -0
  106. package/dist/esm/contexts/TreeContext.js +103 -0
  107. package/dist/esm/helperComponents/ExpandableTreeNode/ExpandableTreeNode.d.ts +11 -0
  108. package/dist/esm/helperComponents/ExpandableTreeNode/ExpandableTreeNode.js +81 -0
  109. package/dist/esm/helperComponents/ExpandableTreeNode/index.d.ts +1 -0
  110. package/dist/esm/helperComponents/ExpandableTreeNode/index.js +1 -0
  111. package/dist/esm/helperComponents/ExpandableTreeNode/styles.module.css +37 -0
  112. package/dist/esm/helperComponents/TreeItem/TreeItem.d.ts +8 -0
  113. package/dist/esm/helperComponents/TreeItem/TreeItem.js +13 -0
  114. package/dist/esm/helperComponents/TreeItem/index.d.ts +1 -0
  115. package/dist/esm/helperComponents/TreeItem/index.js +1 -0
  116. package/dist/esm/helperComponents/TreeLine/TreeLine.d.ts +10 -0
  117. package/dist/esm/helperComponents/TreeLine/TreeLine.js +7 -0
  118. package/dist/esm/helperComponents/TreeLine/index.d.ts +1 -0
  119. package/dist/esm/helperComponents/TreeLine/index.js +1 -0
  120. package/dist/esm/helperComponents/TreeLine/styles.module.css +37 -0
  121. package/dist/esm/helperComponents/TreeNode/TreeNode.d.ts +4 -0
  122. package/dist/esm/helperComponents/TreeNode/TreeNode.js +181 -0
  123. package/dist/esm/helperComponents/TreeNode/components/TreeNodeActions.d.ts +15 -0
  124. package/dist/esm/helperComponents/TreeNode/components/TreeNodeActions.js +70 -0
  125. package/dist/esm/helperComponents/TreeNode/components/TreeNodeHref.d.ts +7 -0
  126. package/dist/esm/helperComponents/TreeNode/components/TreeNodeHref.js +6 -0
  127. package/dist/esm/helperComponents/TreeNode/components/index.d.ts +2 -0
  128. package/dist/esm/helperComponents/TreeNode/components/index.js +2 -0
  129. package/dist/esm/helperComponents/TreeNode/constants.d.ts +5 -0
  130. package/dist/esm/helperComponents/TreeNode/constants.js +7 -0
  131. package/dist/esm/helperComponents/TreeNode/index.d.ts +1 -0
  132. package/dist/esm/helperComponents/TreeNode/index.js +1 -0
  133. package/dist/esm/helperComponents/TreeNode/styles.module.css +474 -0
  134. package/dist/esm/helperComponents/TreeNode/types.d.ts +9 -0
  135. package/dist/esm/helperComponents/TreeNode/types.js +1 -0
  136. package/dist/esm/helperComponents/TreeNode/utils.d.ts +3 -0
  137. package/dist/esm/helperComponents/TreeNode/utils.js +6 -0
  138. package/dist/esm/helperComponents/index.d.ts +4 -0
  139. package/dist/esm/helperComponents/index.js +4 -0
  140. package/dist/esm/helpers/checkNestedNodesSelection.d.ts +9 -0
  141. package/dist/esm/helpers/checkNestedNodesSelection.js +12 -0
  142. package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.d.ts +9 -0
  143. package/dist/esm/helpers/collectEmptyNestedNodesInExpanded.js +18 -0
  144. package/dist/esm/helpers/collectHierarchicalAncestors.d.ts +3 -0
  145. package/dist/esm/helpers/collectHierarchicalAncestors.js +15 -0
  146. package/dist/esm/helpers/collectIds.d.ts +9 -0
  147. package/dist/esm/helpers/collectIds.js +13 -0
  148. package/dist/esm/helpers/extractTreeNodeTitle.d.ts +6 -0
  149. package/dist/esm/helpers/extractTreeNodeTitle.js +5 -0
  150. package/dist/esm/helpers/findAllChildNodeIds.d.ts +9 -0
  151. package/dist/esm/helpers/findAllChildNodeIds.js +21 -0
  152. package/dist/esm/helpers/findAllExpandedChildNodeIds.d.ts +8 -0
  153. package/dist/esm/helpers/findAllExpandedChildNodeIds.js +18 -0
  154. package/dist/esm/helpers/getSearchedTreeItems.d.ts +23 -0
  155. package/dist/esm/helpers/getSearchedTreeItems.js +37 -0
  156. package/dist/esm/helpers/getSearchedTreeNodeById.d.ts +19 -0
  157. package/dist/esm/helpers/getSearchedTreeNodeById.js +31 -0
  158. package/dist/esm/helpers/index.d.ts +13 -0
  159. package/dist/esm/helpers/index.js +13 -0
  160. package/dist/esm/helpers/lookupTreeForSelectedNodes.d.ts +15 -0
  161. package/dist/esm/helpers/lookupTreeForSelectedNodes.js +18 -0
  162. package/dist/esm/helpers/sortTreeItemsByTitle.d.ts +13 -0
  163. package/dist/esm/helpers/sortTreeItemsByTitle.js +10 -0
  164. package/dist/esm/helpers/traverse.d.ts +11 -0
  165. package/dist/esm/helpers/traverse.js +21 -0
  166. package/dist/esm/helpers/traverseWithTarget.d.ts +14 -0
  167. package/dist/esm/helpers/traverseWithTarget.js +26 -0
  168. package/dist/esm/helpers/updateTreeNode.d.ts +13 -0
  169. package/dist/esm/helpers/updateTreeNode.js +30 -0
  170. package/dist/esm/hooks/index.d.ts +2 -0
  171. package/dist/esm/hooks/index.js +2 -0
  172. package/dist/esm/hooks/internalHooks.d.ts +4 -0
  173. package/dist/esm/hooks/internalHooks.js +24 -0
  174. package/dist/esm/hooks/useSearchableTree.d.ts +52 -0
  175. package/dist/esm/hooks/useSearchableTree.js +110 -0
  176. package/dist/esm/hooks/useTreeMultiSelection.d.ts +33 -0
  177. package/dist/esm/hooks/useTreeMultiSelection.js +34 -0
  178. package/dist/esm/index.d.ts +17 -0
  179. package/dist/esm/index.js +17 -0
  180. package/dist/esm/style.css +565 -0
  181. package/dist/esm/types.d.ts +151 -0
  182. package/dist/esm/types.js +1 -0
  183. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  184. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  185. package/package.json +51 -0
  186. package/src/components/Tree/Tree.tsx +53 -0
  187. package/src/components/Tree/index.ts +1 -0
  188. package/src/components/Tree/styles.module.scss +22 -0
  189. package/src/components/Tree/utils.ts +16 -0
  190. package/src/components/index.ts +1 -0
  191. package/src/constants.ts +29 -0
  192. package/src/contexts/TreeContext.tsx +189 -0
  193. package/src/helperComponents/ExpandableTreeNode/ExpandableTreeNode.tsx +156 -0
  194. package/src/helperComponents/ExpandableTreeNode/index.ts +1 -0
  195. package/src/helperComponents/ExpandableTreeNode/styles.module.scss +55 -0
  196. package/src/helperComponents/TreeItem/TreeItem.tsx +26 -0
  197. package/src/helperComponents/TreeItem/index.ts +1 -0
  198. package/src/helperComponents/TreeLine/TreeLine.tsx +27 -0
  199. package/src/helperComponents/TreeLine/index.ts +1 -0
  200. package/src/helperComponents/TreeLine/styles.module.scss +58 -0
  201. package/src/helperComponents/TreeNode/TreeNode.tsx +389 -0
  202. package/src/helperComponents/TreeNode/components/TreeNodeActions.tsx +129 -0
  203. package/src/helperComponents/TreeNode/components/TreeNodeHref.tsx +29 -0
  204. package/src/helperComponents/TreeNode/components/index.ts +2 -0
  205. package/src/helperComponents/TreeNode/constants.ts +7 -0
  206. package/src/helperComponents/TreeNode/index.ts +1 -0
  207. package/src/helperComponents/TreeNode/styles.module.scss +300 -0
  208. package/src/helperComponents/TreeNode/types.ts +11 -0
  209. package/src/helperComponents/TreeNode/utils.ts +9 -0
  210. package/src/helperComponents/index.ts +4 -0
  211. package/src/helpers/checkNestedNodesSelection.ts +15 -0
  212. package/src/helpers/collectEmptyNestedNodesInExpanded.ts +23 -0
  213. package/src/helpers/collectHierarchicalAncestors.ts +22 -0
  214. package/src/helpers/collectIds.ts +17 -0
  215. package/src/helpers/extractTreeNodeTitle.ts +8 -0
  216. package/src/helpers/findAllChildNodeIds.ts +27 -0
  217. package/src/helpers/findAllExpandedChildNodeIds.ts +24 -0
  218. package/src/helpers/getSearchedTreeItems.ts +63 -0
  219. package/src/helpers/getSearchedTreeNodeById.ts +54 -0
  220. package/src/helpers/index.ts +13 -0
  221. package/src/helpers/lookupTreeForSelectedNodes.ts +33 -0
  222. package/src/helpers/sortTreeItemsByTitle.ts +26 -0
  223. package/src/helpers/traverse.ts +28 -0
  224. package/src/helpers/traverseWithTarget.ts +41 -0
  225. package/src/helpers/updateTreeNode.ts +46 -0
  226. package/src/hooks/index.ts +2 -0
  227. package/src/hooks/internalHooks.ts +30 -0
  228. package/src/hooks/useSearchableTree.ts +190 -0
  229. package/src/hooks/useTreeMultiSelection.ts +67 -0
  230. package/src/index.ts +17 -0
  231. package/src/types.ts +162 -0
@@ -0,0 +1,53 @@
1
+ import { extractSupportProps, WithSupportProps } from '@cloud-ru/ds-utils';
2
+ import cn from 'classnames';
3
+
4
+ import { SIZE } from '../../constants';
5
+ import { TreeContextProvider } from '../../contexts/TreeContext';
6
+ import { TreeItem } from '../../helperComponents';
7
+ import { TreeBaseProps } from '../../types';
8
+ import styles from './styles.module.scss';
9
+ import { extractSelectableProps } from './utils';
10
+
11
+ export type TreeProps = WithSupportProps<TreeBaseProps>;
12
+
13
+ export function Tree({
14
+ data,
15
+ onNodeClick,
16
+ onExpand,
17
+ expandedNodes,
18
+ nodeActions,
19
+ parentActions,
20
+ onDataLoad,
21
+ showLines = true,
22
+ showIcons = true,
23
+ size = SIZE.M,
24
+ titleMaxLines = 1,
25
+ className,
26
+ ...rest
27
+ }: TreeProps) {
28
+ return (
29
+ <div className={cn(styles.tree, className)} role='tree' data-size={size} {...extractSupportProps(rest)}>
30
+ <TreeContextProvider
31
+ value={{
32
+ showToggle: rest.selectionMode === 'single' && rest.showToggle,
33
+ data,
34
+ expandedNodes,
35
+ onNodeClick,
36
+ onExpand,
37
+ nodeActions,
38
+ parentActions,
39
+ onDataLoad,
40
+ showLines,
41
+ showIcons,
42
+ size,
43
+ titleMaxLines,
44
+ ...extractSelectableProps(rest),
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
+ }
@@ -0,0 +1 @@
1
+ export * from './Tree';
@@ -0,0 +1,22 @@
1
+ @use '@cloud-ru/ds-figma-variables/build/scss/styles/styles.module' as base;
2
+ @use '@cloud-ru/ds-figma-variables/build/scss/components/tree.module' as tree;
3
+
4
+ $sizes: ('s', 'm', 'l');
5
+
6
+ .tree {
7
+ // Корневого clip'а нет: он резал elevated-тень и `:focus-visible` outline
8
+ // у kebab'а на правом краю строки. Контроль ширины держится через
9
+ // `min-width: 0` + TruncateString на title — глубоко вложенные ряды
10
+ // обрезают заголовок с «…», kebab остаётся видимым на правом краю строки.
11
+ box-sizing: border-box;
12
+ min-width: 0;
13
+ height: 100%;
14
+
15
+ // --tree-line-width на корне: trunk и L-bend должны совпасть с центром шеврона.
16
+ @each $size in $sizes {
17
+ &[data-size='#{$size}'] {
18
+ --tree-line-width: #{base.simple-var(tree.$tree, 'anatomy', 'size', $size, 'treeItem', 'centeredWrapper', 'width')};
19
+ --tree-item-gap: #{base.simple-var(tree.$tree, 'anatomy', 'size', $size, 'treeItem', 'gap')};
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,16 @@
1
+ import { SELECTION_MODE } from '../../constants';
2
+ import { TreeBaseProps, TreeMultiSelect, TreeSingleSelect, TreeView } from '../../types';
3
+
4
+ type SelectableProps<T> = Pick<T, Extract<keyof T, 'selectionMode' | 'selected' | 'onSelect'>>;
5
+
6
+ // Сужение discriminated union к конкретному варианту: TS не выводит ветку из switch-case по
7
+ // дженерик-проп `selectionMode`, поэтому возвращаем строго типизированный Pick через `as`.
8
+ export function extractSelectableProps({ selectionMode, selected, onSelect }: SelectableProps<TreeBaseProps>) {
9
+ if (selectionMode === SELECTION_MODE.Single) {
10
+ return { selectionMode, selected, onSelect } as SelectableProps<TreeSingleSelect>;
11
+ }
12
+ if (selectionMode === SELECTION_MODE.Multiple) {
13
+ return { selectionMode, selected, onSelect } as SelectableProps<TreeMultiSelect>;
14
+ }
15
+ return {} as SelectableProps<TreeView>;
16
+ }
@@ -0,0 +1 @@
1
+ export * from './Tree';
@@ -0,0 +1,29 @@
1
+ export const SELECTION_MODE = {
2
+ Single: 'single',
3
+ Multiple: 'multiple',
4
+ } as const;
5
+
6
+ export const SIZE = {
7
+ S: 's',
8
+ M: 'm',
9
+ L: 'l',
10
+ } as const;
11
+
12
+ export const TRANSITION_TIMING = {
13
+ accordionFolding: 200,
14
+ } as const;
15
+
16
+ export const TEST_IDS = {
17
+ node: 'tree__node',
18
+ line: 'tree__node__line',
19
+ item: 'tree__node__item',
20
+ chevron: 'tree__node__chevron',
21
+ checkbox: 'tree__node__checkbox',
22
+ radio: 'tree__node__radio',
23
+ icon: 'tree__node__icon',
24
+ title: 'tree__node__title',
25
+ droplistTrigger: 'tree__node__droplist-trigger',
26
+ expandable: 'tree__node__expandable',
27
+ expandableContent: 'tree__node__expandable-content',
28
+ link: 'tree__node__link',
29
+ } as const;
@@ -0,0 +1,189 @@
1
+ import { HierarchicalSelectionHandlers, useHierarchicalSelection, useUncontrolledProp } from '@cloud-ru/ds-utils';
2
+ import { createContext, Dispatch, ReactNode, SetStateAction, useCallback, useContext, useMemo, useState } from 'react';
3
+
4
+ import { SELECTION_MODE } from '../constants';
5
+ import { findAllExpandedChildNodeIds, lookupTreeForSelectedNodes } from '../helpers';
6
+ import { OnNodeClick, ParentNode, TreeBaseProps, TreeNodeId, TreeNodeProps } from '../types';
7
+
8
+ const EMPTY_EXPANDED: TreeNodeId[] = [];
9
+
10
+ type TreeContextBaseProps = TreeBaseProps & {
11
+ showToggle?: boolean;
12
+ };
13
+
14
+ type TreeContextProps = Omit<
15
+ TreeContextBaseProps,
16
+ 'onSelect' | 'onExpand' | 'onNodeClick' | 'selected' | 'selectionMode'
17
+ > & {
18
+ onExpand(node: TreeNodeProps): void;
19
+ onSelect(node: Pick<TreeNodeProps, 'id' | 'nested' | 'disabled'>, parentNode?: ParentNode): void;
20
+ selected?: TreeNodeId[] | TreeNodeId;
21
+ onNodeClick: OnNodeClick;
22
+ isMultiSelect: boolean;
23
+ isSingleSelect: boolean;
24
+ isSelectable: boolean;
25
+ focusedNodeId?: TreeNodeId;
26
+ setFocusPosition(id: TreeNodeId): void;
27
+ resetFocusPosition(): void;
28
+ setFocusIndex: Dispatch<SetStateAction<number | undefined>>;
29
+ focusableNodeIds: TreeNodeId[];
30
+ getSelectionState: HierarchicalSelectionHandlers['getSelectionState'];
31
+ toggleSelection: HierarchicalSelectionHandlers['toggleSelection'];
32
+ };
33
+
34
+ type TreeContextProviderProps = {
35
+ children: ReactNode;
36
+ value: TreeContextBaseProps;
37
+ };
38
+
39
+ export const TreeContext = createContext<TreeContextProps>({
40
+ data: [],
41
+ selected: undefined,
42
+ onSelect() {},
43
+ expandedNodes: [],
44
+ onExpand() {},
45
+ onNodeClick() {},
46
+ setFocusPosition() {},
47
+ setFocusIndex() {},
48
+ resetFocusPosition() {},
49
+ focusableNodeIds: [],
50
+ isMultiSelect: false,
51
+ isSingleSelect: false,
52
+ isSelectable: false,
53
+ getSelectionState: () => ({ checked: false, indeterminate: false }),
54
+ toggleSelection: ({ selectedIds }) => selectedIds,
55
+ });
56
+
57
+ export function TreeContextProvider({ children, value }: TreeContextProviderProps) {
58
+ const {
59
+ onNodeClick: onNodeClickProp,
60
+ onExpand: onExpandProp,
61
+ onSelect: onSelectProp,
62
+ selectionMode,
63
+ data,
64
+ ...props
65
+ } = value;
66
+
67
+ const isMultiSelect = selectionMode === SELECTION_MODE.Multiple;
68
+ const isSingleSelect = selectionMode === SELECTION_MODE.Single;
69
+ const isSelectable = Boolean(selectionMode);
70
+
71
+ const { getSelectionState, toggleSelection } = useHierarchicalSelection({ includeParentsInValue: true });
72
+
73
+ const [expandedNodes, onExpandHandler] = useUncontrolledProp<TreeNodeId[], NonNullable<TreeBaseProps['onExpand']>>(
74
+ value.expandedNodes,
75
+ value.expandedNodes ?? EMPTY_EXPANDED,
76
+ onExpandProp,
77
+ );
78
+
79
+ const onExpand = useCallback<TreeContextProps['onExpand']>(
80
+ node => {
81
+ const isExpanded = expandedNodes.includes(node.id);
82
+
83
+ const nodes = isExpanded ? expandedNodes.filter(id => id !== node.id) : expandedNodes.concat(node.id);
84
+
85
+ onExpandHandler(nodes, node);
86
+ },
87
+ [expandedNodes, onExpandHandler],
88
+ );
89
+
90
+ const [selectedNodes, onSelectHandler] = useUncontrolledProp<typeof value.selected>(value.selected, [], onSelectProp);
91
+
92
+ const onSelect = useCallback<TreeContextProps['onSelect']>(
93
+ (node, parentNode) => {
94
+ if (!isSelectable || node.disabled) return;
95
+
96
+ if (isSingleSelect) {
97
+ onSelectHandler(node.id === selectedNodes ? undefined : node.id, node);
98
+ return;
99
+ }
100
+
101
+ if (Array.isArray(selectedNodes)) {
102
+ const updatedSelectedNodes = lookupTreeForSelectedNodes({
103
+ node,
104
+ parentNode,
105
+ selectedNodes,
106
+ toggleSelection,
107
+ });
108
+
109
+ onSelectHandler(updatedSelectedNodes, node);
110
+ }
111
+ },
112
+ [isSingleSelect, onSelectHandler, selectedNodes, isSelectable, toggleSelection],
113
+ );
114
+
115
+ const focusableNodeIds = useMemo(() => {
116
+ if (!expandedNodes?.length) {
117
+ return data.map(node => node.id);
118
+ }
119
+
120
+ return data.reduce<string[]>((acc, cur) => {
121
+ acc.push(cur.id);
122
+
123
+ const isExpanded = expandedNodes.includes(cur.id);
124
+
125
+ if (isExpanded && cur.nested?.length) {
126
+ acc.push(...findAllExpandedChildNodeIds(cur.nested, expandedNodes));
127
+ }
128
+
129
+ return acc;
130
+ }, []);
131
+ }, [data, expandedNodes]);
132
+
133
+ const [focusIndex, setFocusIndex] = useState<number>();
134
+
135
+ const focusedNodeId = focusIndex !== undefined ? focusableNodeIds[focusIndex] : undefined;
136
+
137
+ const setFocusPosition = useCallback(
138
+ (nodeId: TreeNodeId) => {
139
+ const newFocusIndex = focusableNodeIds.indexOf(nodeId);
140
+ setFocusIndex(newFocusIndex >= 0 ? newFocusIndex : undefined);
141
+ },
142
+ [focusableNodeIds],
143
+ );
144
+
145
+ const resetFocusPosition = useCallback(() => setFocusIndex(undefined), []);
146
+
147
+ const onNodeClick = useCallback<OnNodeClick>(
148
+ (node, e) => {
149
+ if (node.disabled) {
150
+ return;
151
+ }
152
+
153
+ if (isSingleSelect) {
154
+ onSelect(node);
155
+ }
156
+
157
+ node.onClick?.(e) || onNodeClickProp?.(node, e);
158
+ },
159
+ [isSingleSelect, onSelect, onNodeClickProp],
160
+ );
161
+
162
+ return (
163
+ <TreeContext.Provider
164
+ value={{
165
+ ...props,
166
+ data,
167
+ selected: selectedNodes,
168
+ isSingleSelect,
169
+ isMultiSelect,
170
+ isSelectable,
171
+ expandedNodes,
172
+ onExpand,
173
+ onSelect,
174
+ onNodeClick,
175
+ focusedNodeId,
176
+ setFocusPosition,
177
+ resetFocusPosition,
178
+ setFocusIndex,
179
+ focusableNodeIds,
180
+ getSelectionState,
181
+ toggleSelection,
182
+ }}
183
+ >
184
+ {children}
185
+ </TreeContext.Provider>
186
+ );
187
+ }
188
+
189
+ export const useTreeContext = () => useContext(TreeContext);
@@ -0,0 +1,156 @@
1
+ import { forwardRef, KeyboardEventHandler, useEffect, useRef, 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 const ExpandableTreeNode = forwardRef<HTMLDivElement, ExpandableTreeNodeProps>(
19
+ ({ node: { 'data-test-id': dataTestId, ...node }, parentNode, tabIndexAvailable }, ref) => {
20
+ const { expandedNodes, onExpand, onDataLoad, showLines } = useTreeContext();
21
+
22
+ const isExpandable = Boolean(node.nested);
23
+ const isExpanded = expandedNodes?.includes(node.id) || false;
24
+
25
+ const [loading, setLoading] = useState(false);
26
+
27
+ const [state, toggle] = useTransition({
28
+ timeout: TRANSITION_TIMING.accordionFolding,
29
+ initialEntered: isExpanded || undefined,
30
+ enter: isExpanded,
31
+ });
32
+
33
+ const showContent = node.nested && node.nested.length > 0 && state.status !== 'exited';
34
+
35
+ const childrenRefs = useRef<(HTMLDivElement | null)[]>([]);
36
+ const [lastItemHeight, setLastItemHeight] = useState(0);
37
+
38
+ useEffect(() => {
39
+ const lastItem = childrenRefs.current?.at(-1);
40
+
41
+ if (lastItem) {
42
+ // Trunk обрывается L-bend'ом на середине СТРОКИ последнего ребёнка, не его поддерева.
43
+ // Укорачиваем на (wrapperHeight - rowHeight/2); для листа == rowHeight/2.
44
+ const lastRow =
45
+ (lastItem.hasAttribute('data-node-id') ? lastItem : lastItem.querySelector('[data-node-id]')) ?? lastItem;
46
+
47
+ const recompute = () => {
48
+ const wrapperHeight = lastItem.getBoundingClientRect().height;
49
+ const rowHeight = lastRow.getBoundingClientRect().height;
50
+ setLastItemHeight(wrapperHeight - rowHeight / 2);
51
+ };
52
+
53
+ const observer = new ResizeObserver(() => recompute());
54
+ observer.observe(lastItem);
55
+ if (lastRow !== lastItem) observer.observe(lastRow);
56
+
57
+ recompute();
58
+
59
+ return () => observer.disconnect();
60
+ }
61
+ }, [state.status]);
62
+
63
+ useEffect(() => {
64
+ if (node.nested?.length && isExpanded && state.status === 'exited') {
65
+ toggle(true);
66
+ }
67
+ }, [isExpanded, node.nested, state.status]);
68
+
69
+ const toggleExpand = async () => {
70
+ if (node.nested && !node.nested.length && !isExpanded && onDataLoad) {
71
+ setLoading(true);
72
+
73
+ await onDataLoad(node).finally(() => {
74
+ setLoading(false);
75
+ });
76
+ }
77
+
78
+ onExpand(node);
79
+
80
+ toggle();
81
+ };
82
+
83
+ const handleKeyDown: KeyboardEventHandler<HTMLElement> = e => {
84
+ if (!isExpandable) return;
85
+
86
+ switch (e.key) {
87
+ case 'ArrowRight': {
88
+ if (!isExpanded) {
89
+ toggleExpand();
90
+ }
91
+ return;
92
+ }
93
+ case 'ArrowLeft': {
94
+ if (isExpanded) {
95
+ toggleExpand();
96
+ }
97
+ return;
98
+ }
99
+ default:
100
+ return;
101
+ }
102
+ };
103
+
104
+ return (
105
+ <div
106
+ className={styles.expandableTreeNode}
107
+ data-test-id={dataTestId}
108
+ data-expandable={Boolean(node.nested) || undefined}
109
+ data-disabled={node.disabled || undefined}
110
+ ref={ref}
111
+ >
112
+ <TreeNode
113
+ {...node}
114
+ loading={loading}
115
+ parentNode={parentNode}
116
+ onChevronClick={toggleExpand}
117
+ onKeyDown={handleKeyDown}
118
+ tabIndexAvailable={tabIndexAvailable}
119
+ />
120
+
121
+ {node.nested && (
122
+ <div
123
+ className={styles.expandableWrap}
124
+ data-expanded={isExpanded || undefined}
125
+ data-test-id={TEST_IDS.expandable}
126
+ >
127
+ {showContent && (
128
+ <div className={styles.expandableContent} data-test-id={TEST_IDS.expandableContent}>
129
+ <TreeLine
130
+ visible={showLines}
131
+ data-test-id={TEST_IDS.line}
132
+ style={{ height: `calc(100% - ${lastItemHeight}px)` }}
133
+ />
134
+
135
+ <div className={styles.expandableNested}>
136
+ {node.nested.map((nestedNode, index) => {
137
+ const parent: ParentNode = { id: node.id, nested: node.nested, parentNode };
138
+
139
+ return (
140
+ <TreeItem
141
+ ref={el => (childrenRefs.current[index] = el)}
142
+ key={nestedNode.id}
143
+ node={{ ...nestedNode, disabled: node.disabled || nestedNode.disabled }}
144
+ parentNode={parent}
145
+ />
146
+ );
147
+ })}
148
+ </div>
149
+ </div>
150
+ )}
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ },
156
+ );
@@ -0,0 +1 @@
1
+ export * from './ExpandableTreeNode';
@@ -0,0 +1,55 @@
1
+ .expandableTreeNode {
2
+ position: relative;
3
+ width: 100%;
4
+ }
5
+
6
+ .expandableContent {
7
+ // overflow-y клиппирует во время slide-анимации высоты; -x оставляем visible,
8
+ // иначе срезается тень elevated-кнопок (kebab-actions) по правому краю.
9
+ overflow: visible hidden;
10
+ display: flex;
11
+ gap: var(--tree-item-gap);
12
+
13
+ box-sizing: border-box;
14
+ min-width: 0;
15
+ min-height: 0;
16
+
17
+ visibility: hidden;
18
+
19
+ transition: visibility 200ms;
20
+ }
21
+
22
+ .expandableWrap {
23
+ overflow: visible hidden;
24
+ display: grid;
25
+ grid-template-rows: 0fr;
26
+
27
+ box-sizing: border-box;
28
+ width: 100%;
29
+
30
+ transition: grid-template-rows 200ms;
31
+
32
+ &[data-expanded='true'] {
33
+ overflow: unset;
34
+ grid-template-rows: 1fr;
35
+
36
+ .expandableContent {
37
+ // После раскрытия снимаем вертикальный клип — иначе режет focus-outline
38
+ // и shadow elevated-кнопок у вложенных строк (тень/outline выходит за bbox).
39
+ overflow: unset;
40
+ visibility: visible;
41
+ }
42
+ }
43
+ }
44
+
45
+ .expandableNested {
46
+ // Deep-leaf клипуется корневым `.tree { overflow-x: hidden }`. Локальный
47
+ // `overflow-x: clip` сюда ставить нельзя: горизонтальный L-bend каждой
48
+ // строки — `::after` псевдоэлемент, который выезжает влево на
49
+ // `--tree-line-width / 2 + gap`, чтобы дотянуться до trunk'а в соседнем
50
+ // `.expandableContent`-столбце. Клип режет эту левую часть → L-bend
51
+ // визуально не доходит до trunk'а.
52
+ width: 100%;
53
+ min-width: 0;
54
+ }
55
+
@@ -0,0 +1,26 @@
1
+ import { forwardRef } from 'react';
2
+
3
+ import { TEST_IDS } from '../../constants';
4
+ import { ParentNode, TreeNodeProps } from '../../types';
5
+ import { ExpandableTreeNode } from '../ExpandableTreeNode';
6
+ import { TreeNode } from '../TreeNode';
7
+
8
+ type TreeItemProps = {
9
+ node: TreeNodeProps;
10
+ parentNode?: ParentNode;
11
+ tabIndexAvailable?: boolean;
12
+ };
13
+
14
+ export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(({ node, tabIndexAvailable, parentNode }, ref) => {
15
+ const dataTestId = node['data-test-id'] || TEST_IDS.node;
16
+
17
+ const nodeProps: TreeNodeProps = { ...node, 'data-test-id': dataTestId };
18
+
19
+ if (node.nested) {
20
+ return (
21
+ <ExpandableTreeNode node={nodeProps} tabIndexAvailable={tabIndexAvailable} parentNode={parentNode} ref={ref} />
22
+ );
23
+ }
24
+
25
+ return <TreeNode {...nodeProps} tabIndexAvailable={tabIndexAvailable} parentNode={parentNode} ref={ref} />;
26
+ });
@@ -0,0 +1 @@
1
+ export * from './TreeItem';
@@ -0,0 +1,27 @@
1
+ import cn from 'classnames';
2
+ import { CSSProperties } from 'react';
3
+
4
+ import { TEST_IDS } from '../../constants';
5
+ import styles from './styles.module.scss';
6
+
7
+ export type TreeLineProps = {
8
+ className?: string;
9
+ /** Доп. инлайн-стили для контейнера (height/width передаются здесь, а не отдельными пропами). */
10
+ style?: CSSProperties;
11
+ halfWidth?: boolean;
12
+ visible?: boolean;
13
+ horizontal?: boolean;
14
+ };
15
+
16
+ export function TreeLine({ className, style, halfWidth, horizontal, visible }: TreeLineProps) {
17
+ return (
18
+ <div
19
+ className={cn(styles.treeLine, className)}
20
+ style={style}
21
+ data-horizontal={horizontal || undefined}
22
+ data-half-width={halfWidth || undefined}
23
+ data-visible={visible || undefined}
24
+ data-test-id={TEST_IDS.line}
25
+ />
26
+ );
27
+ }
@@ -0,0 +1 @@
1
+ export * from './TreeLine';
@@ -0,0 +1,58 @@
1
+ @use '@cloud-ru/ds-figma-variables/build/scss/styles/styles.module' as base;
2
+
3
+ // Ширина дорожки = ширина шевронной колонки; прокидывается через --tree-line-width от [data-size].
4
+ $line-width: var(--tree-line-width, #{base.$sn-density-size-icon-s});
5
+
6
+ .treeLine {
7
+ pointer-events: none;
8
+
9
+ position: relative;
10
+
11
+ flex-shrink: 0;
12
+
13
+ width: $line-width;
14
+
15
+ visibility: hidden;
16
+
17
+ &[data-visible] {
18
+ visibility: visible;
19
+
20
+ &::after {
21
+ content: '';
22
+
23
+ position: absolute;
24
+ top: 0;
25
+ left: 50%;
26
+ transform: translateX(-50%);
27
+
28
+ width: base.$sn-primitive-strokeWeight-strokeRegular;
29
+ height: 100%;
30
+
31
+ // Мастер levelLine/lineWrapper (7098:16549): #bcc3dc α0.35 = available-borderColor,
32
+ // как все линии-разделители DS; neutral-decor (#e3e5ed опак) — расхождение.
33
+ background: base.$sn-theme-color-available-borderColor;
34
+ }
35
+ }
36
+
37
+ &[data-horizontal] {
38
+ box-sizing: border-box;
39
+
40
+ &::after {
41
+ top: 50%;
42
+ left: calc(-50% - var(--tree-item-gap, 0px));
43
+ transform: translateY(-50%);
44
+
45
+ width: calc(150% + var(--tree-item-gap, 0px));
46
+ height: base.$sn-primitive-strokeWeight-strokeRegular;
47
+ }
48
+
49
+ &[data-half-width] {
50
+ width: 0;
51
+
52
+ &::after {
53
+ left: calc(0px - #{$line-width} / 2 - var(--tree-item-gap, 0px));
54
+ width: calc(#{$line-width} / 2 + var(--tree-item-gap, 0px));
55
+ }
56
+ }
57
+ }
58
+ }