@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,389 @@
1
+ import { ChevronRightSVG, FileSVG, FolderOpenSVG, FolderSVG } from '@cloud-ru/ds-icons/interface/system';
2
+ import { Spinner } from '@cloud-ru/ds-loader';
3
+ import { Checkbox, Radio } from '@cloud-ru/ds-toggles';
4
+ import { TruncateString } from '@cloud-ru/ds-truncate-string';
5
+ import { Typography } from '@cloud-ru/ds-typography';
6
+ import { extractSupportProps } from '@cloud-ru/ds-utils';
7
+ import cn from 'classnames';
8
+ import { FocusEvent, forwardRef, KeyboardEventHandler, MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
9
+
10
+ import { TEST_IDS } from '../../constants';
11
+ import { useTreeContext } from '../../contexts/TreeContext';
12
+ import { checkNestedNodesSelection, findAllChildNodeIds } from '../../helpers';
13
+ import { TreeNodeProps } from '../../types';
14
+ import { TreeLine } from '../TreeLine';
15
+ import { TreeNodeActions, TreeNodeHref } from './components';
16
+ import { CONTROL_SIZE_BY_TREE_SIZE } from './constants';
17
+ import styles from './styles.module.scss';
18
+ import { TreeNodeComponentProps } from './types';
19
+ import { stopPropagationClick } from './utils';
20
+
21
+ export type { TreeNodeProps };
22
+
23
+ export const TreeNode = forwardRef<HTMLDivElement, TreeNodeComponentProps>(
24
+ (
25
+ {
26
+ id,
27
+ title,
28
+ icon: iconProp,
29
+ expandedIcon: expandedIconProp,
30
+ collapsedIcon: collapsedIconProp,
31
+ disabled,
32
+ onClick,
33
+ nested,
34
+ className,
35
+ onChevronClick,
36
+ onKeyDown,
37
+ loading,
38
+ parentNode,
39
+ tabIndexAvailable,
40
+ href,
41
+ ...rest
42
+ },
43
+ ref,
44
+ ) => {
45
+ const {
46
+ isMultiSelect,
47
+ isSelectable,
48
+ onNodeClick,
49
+ selected,
50
+ expandedNodes,
51
+ onSelect,
52
+ nodeActions,
53
+ parentActions,
54
+ setFocusPosition,
55
+ resetFocusPosition,
56
+ focusedNodeId,
57
+ setFocusIndex,
58
+ focusableNodeIds,
59
+ showToggle,
60
+ showLines,
61
+ showIcons,
62
+ size = 'm',
63
+ titleMaxLines = 1,
64
+ getSelectionState,
65
+ } = useTreeContext();
66
+
67
+ const [isDroplistOpen, setDroplistOpen] = useState(false);
68
+ const [isDroplistTriggerFocused, setFocusDroplistTrigger] = useState(false);
69
+
70
+ const contentRef = useRef<HTMLDivElement | null>(null);
71
+
72
+ const anchorRef = useRef<HTMLAnchorElement | null>(null);
73
+
74
+ const isExpandable = Array.isArray(nested);
75
+ const isExpanded = isExpandable ? expandedNodes?.includes(id) : undefined;
76
+
77
+ const selectedIds = useMemo(() => {
78
+ if (Array.isArray(selected)) {
79
+ return selected;
80
+ }
81
+
82
+ if (selected) {
83
+ return [selected];
84
+ }
85
+
86
+ return [];
87
+ }, [selected]);
88
+
89
+ const descendantIds = useMemo(() => (nested?.length ? findAllChildNodeIds(nested) : []), [nested]);
90
+
91
+ const nestedNodesSelection = useMemo(() => {
92
+ if (!nested || !selected) return undefined;
93
+
94
+ return checkNestedNodesSelection(nested, selectedIds);
95
+ }, [nested, selected, selectedIds]);
96
+
97
+ const multiSelectState = useMemo(() => {
98
+ if (!isMultiSelect) {
99
+ return { checked: false, indeterminate: false };
100
+ }
101
+
102
+ return getSelectionState({
103
+ nodeId: id,
104
+ childIds: descendantIds,
105
+ selectedIds,
106
+ });
107
+ }, [descendantIds, getSelectionState, id, isMultiSelect, selectedIds]);
108
+
109
+ const isSelected = isMultiSelect ? multiSelectState.checked : selected === id || false;
110
+ const isIndeterminate = isMultiSelect && multiSelectState.indeterminate;
111
+
112
+ const isFocused = focusedNodeId === id;
113
+
114
+ useEffect(() => {
115
+ if (contentRef.current && isFocused) {
116
+ contentRef.current.focus();
117
+ }
118
+ }, [isFocused]);
119
+
120
+ const treeNodeIcon = useMemo(() => {
121
+ if (!showIcons) return undefined;
122
+
123
+ // Размер svg задаётся в styles.module.scss через `.treeNodeIcon svg`,
124
+ // зависит от data-size корня; size в JSX не прокидываем.
125
+ if (isExpandable) {
126
+ return isExpanded ? (expandedIconProp ?? <FolderOpenSVG />) : (collapsedIconProp ?? <FolderSVG />);
127
+ }
128
+
129
+ return iconProp ?? <FileSVG />;
130
+ }, [showIcons, isExpandable, isExpanded, iconProp, expandedIconProp, collapsedIconProp]);
131
+
132
+ const handleClick: TreeNodeProps['onClick'] = e => {
133
+ onNodeClick(
134
+ {
135
+ id,
136
+ title,
137
+ disabled,
138
+ nested,
139
+ onClick,
140
+ href,
141
+ },
142
+ e,
143
+ );
144
+ };
145
+
146
+ const handleAnchorClick = (e: MouseEvent<Element>) => {
147
+ e.stopPropagation();
148
+ if (e?.metaKey || e?.ctrlKey || e?.button === 1) {
149
+ return;
150
+ }
151
+
152
+ // preventDefault только если есть кастомный onClick на ноде — иначе
153
+ // позволяем браузеру навигировать по href. Но handleClick зовём всегда,
154
+ // чтобы Tree успел прокинуть onNodeClick/onSelect.
155
+ if (onClick) {
156
+ e.preventDefault();
157
+ }
158
+ handleClick(e);
159
+ };
160
+
161
+ const handleSelect = () => {
162
+ onSelect(
163
+ {
164
+ id,
165
+ disabled,
166
+ nested,
167
+ },
168
+ parentNode,
169
+ );
170
+ };
171
+
172
+ const handleFocus = (e?: FocusEvent) => {
173
+ setFocusPosition(id);
174
+
175
+ if (!e) {
176
+ setFocusDroplistTrigger(false);
177
+ contentRef.current?.focus();
178
+ }
179
+ };
180
+
181
+ const handleBlurActions = () => {
182
+ if (isDroplistTriggerFocused && !isDroplistOpen) {
183
+ setFocusDroplistTrigger(false);
184
+
185
+ contentRef.current?.blur();
186
+ }
187
+ };
188
+
189
+ const handleKeyDown: KeyboardEventHandler<HTMLElement> = e => {
190
+ onKeyDown?.(e);
191
+
192
+ const focusableCount = focusableNodeIds.length;
193
+
194
+ switch (e.key) {
195
+ case 'ArrowDown': {
196
+ e.preventDefault();
197
+
198
+ setFocusIndex((prev = 0) => {
199
+ if (prev >= focusableCount - 1) {
200
+ return focusableCount - 1;
201
+ }
202
+ return prev + 1;
203
+ });
204
+
205
+ return;
206
+ }
207
+ case 'ArrowUp': {
208
+ e.preventDefault();
209
+
210
+ setFocusIndex((prev = 0) => {
211
+ if (!prev) {
212
+ return 0;
213
+ }
214
+ return prev - 1;
215
+ });
216
+
217
+ return;
218
+ }
219
+ case 'ArrowRight': {
220
+ e.preventDefault();
221
+
222
+ // ArrowRight уводит фокус на droplist trigger row-actions — это нужно
223
+ // только если на строке вообще есть actions. Без них клавиша не
224
+ // должна давать визуального hover-эффекта (`data-droplist-active`).
225
+ const hasRowActions = Boolean(nested ? parentActions : nodeActions);
226
+
227
+ if (hasRowActions && (isExpanded || !isExpandable || disabled)) {
228
+ setFocusDroplistTrigger(true);
229
+ }
230
+
231
+ return;
232
+ }
233
+ case 'ArrowLeft': {
234
+ e.preventDefault();
235
+
236
+ if (isDroplistTriggerFocused) {
237
+ setFocusDroplistTrigger(false);
238
+ contentRef.current?.focus();
239
+ }
240
+
241
+ return;
242
+ }
243
+ case 'Escape': {
244
+ contentRef.current?.blur();
245
+ return;
246
+ }
247
+ case ' ':
248
+ case 'Enter': {
249
+ e.preventDefault();
250
+ handleSelect();
251
+
252
+ if (href && anchorRef.current) {
253
+ anchorRef.current.click();
254
+ }
255
+
256
+ return;
257
+ }
258
+ default:
259
+ return;
260
+ }
261
+ };
262
+
263
+ const getNodeActions = nested ? parentActions : nodeActions;
264
+
265
+ return (
266
+ <div
267
+ role='presentation'
268
+ className={cn(styles.treeNode, className)}
269
+ {...extractSupportProps(rest)}
270
+ data-node-id={id}
271
+ data-size={size}
272
+ ref={ref}
273
+ >
274
+ {parentNode && <TreeLine halfWidth horizontal visible={showLines} data-test-id={TEST_IDS.line} />}
275
+
276
+ <div className={styles.treeNodeExpandButtonWrapper}>
277
+ {isExpandable ? (
278
+ <>
279
+ <button
280
+ type='button'
281
+ onClick={onChevronClick}
282
+ data-expanded={isExpanded || undefined}
283
+ className={styles.treeNodeExpandButton}
284
+ tabIndex={-1}
285
+ data-test-id={TEST_IDS.chevron}
286
+ aria-label={isExpanded ? 'Collapse' : 'Expand'}
287
+ >
288
+ <span
289
+ className={styles.treeNodeExpandButtonStateLayer}
290
+ aria-hidden
291
+ data-state='emptyNeutralOnBackground'
292
+ />
293
+ {loading ? <Spinner size={CONTROL_SIZE_BY_TREE_SIZE[size]} /> : <ChevronRightSVG />}
294
+ </button>
295
+ <TreeLine visible={isExpanded && showLines} />
296
+ </>
297
+ ) : (
298
+ <div className={styles.treeNodeExpandButtonSpacer} aria-hidden />
299
+ )}
300
+ </div>
301
+
302
+ <div
303
+ role='treeitem'
304
+ aria-expanded={isExpanded}
305
+ aria-selected={
306
+ isSelectable
307
+ ? isSelected || (nestedNodesSelection?.someSelected && !isExpanded && !isMultiSelect)
308
+ : undefined
309
+ }
310
+ aria-disabled={disabled}
311
+ data-multiselect={isMultiSelect || undefined}
312
+ data-droplist-active={isDroplistOpen || isDroplistTriggerFocused || undefined}
313
+ onClick={handleClick}
314
+ onKeyDown={handleKeyDown}
315
+ onFocus={handleFocus}
316
+ onBlur={resetFocusPosition}
317
+ tabIndex={tabIndexAvailable ? 0 : -1}
318
+ className={styles.treeNodeContent}
319
+ data-test-id={TEST_IDS.item}
320
+ ref={contentRef}
321
+ >
322
+ <span
323
+ className={styles.stateLayer}
324
+ aria-hidden
325
+ data-state={isSelected && !isMultiSelect ? 'activatedOnBackground' : 'emptyNeutralOnBackground'}
326
+ />
327
+ {(isMultiSelect || showToggle) && (
328
+ <div className={styles.treeNodeCheckboxWrap}>
329
+ {isMultiSelect && (
330
+ <Checkbox
331
+ size={CONTROL_SIZE_BY_TREE_SIZE[size]}
332
+ disabled={disabled}
333
+ checked={isSelected}
334
+ indeterminate={isIndeterminate}
335
+ onChange={handleSelect}
336
+ onClick={stopPropagationClick}
337
+ data-test-id={TEST_IDS.checkbox}
338
+ tabIndex={-1}
339
+ />
340
+ )}
341
+ {showToggle && (
342
+ <Radio
343
+ size={CONTROL_SIZE_BY_TREE_SIZE[size]}
344
+ checked={isSelected}
345
+ disabled={disabled}
346
+ data-test-id={TEST_IDS.radio}
347
+ tabIndex={-1}
348
+ />
349
+ )}
350
+ </div>
351
+ )}
352
+
353
+ {treeNodeIcon && (
354
+ <div className={styles.treeNodeIcon} data-test-id={TEST_IDS.icon}>
355
+ {treeNodeIcon}
356
+ </div>
357
+ )}
358
+
359
+ <Typography variant='body' size={size} as='div' className={styles.treeNodeTitle}>
360
+ <TreeNodeHref href={href} onClick={handleAnchorClick} ref={anchorRef}>
361
+ {typeof title === 'string' && (
362
+ <TruncateString text={title} maxLines={titleMaxLines} data-test-id={TEST_IDS.title} />
363
+ )}
364
+ {typeof title !== 'string' && title({ id, disabled, nested } as TreeNodeProps)}
365
+ </TreeNodeHref>
366
+ </Typography>
367
+
368
+ {getNodeActions && (
369
+ <TreeNodeActions
370
+ getNodeActions={getNodeActions}
371
+ node={{
372
+ id,
373
+ title,
374
+ disabled,
375
+ nested,
376
+ }}
377
+ focusNode={handleFocus}
378
+ onBlurActions={handleBlurActions}
379
+ isDroplistTriggerFocused={isDroplistTriggerFocused}
380
+ isDroplistOpen={isDroplistOpen}
381
+ setDroplistOpen={setDroplistOpen}
382
+ size={size}
383
+ />
384
+ )}
385
+ </div>
386
+ </div>
387
+ );
388
+ },
389
+ );
@@ -0,0 +1,129 @@
1
+ import { Button } from '@cloud-ru/ds-button';
2
+ import { KebabSVG } from '@cloud-ru/ds-icons/interface/system';
3
+ import { Droplist, ItemProps } from '@cloud-ru/ds-list';
4
+ import { Dispatch, KeyboardEventHandler, SetStateAction, useEffect, useRef } from 'react';
5
+
6
+ import { TEST_IDS } from '../../../constants';
7
+ import { Size, TreeNodeProps } from '../../../types';
8
+ import styles from '../styles.module.scss';
9
+ import { stopPropagationClick, stopPropagationFocus } from '../utils';
10
+
11
+ type TreeNodeActionsProps = {
12
+ isDroplistOpen: boolean;
13
+ setDroplistOpen: Dispatch<SetStateAction<boolean>>;
14
+ getNodeActions(node: Omit<TreeNodeProps, 'href'>): ItemProps[];
15
+ node: Omit<TreeNodeProps, 'href'>;
16
+ isDroplistTriggerFocused: boolean;
17
+ focusNode(): void;
18
+ onBlurActions(): void;
19
+ size: Size;
20
+ };
21
+
22
+ export function TreeNodeActions({
23
+ getNodeActions,
24
+ isDroplistTriggerFocused,
25
+ focusNode,
26
+ isDroplistOpen,
27
+ setDroplistOpen,
28
+ onBlurActions,
29
+ node,
30
+ size,
31
+ }: TreeNodeActionsProps) {
32
+ const droplistActions = getNodeActions(node);
33
+
34
+ const localRef = useRef<HTMLButtonElement>(null);
35
+
36
+ useEffect(() => {
37
+ if (localRef.current && isDroplistTriggerFocused) {
38
+ localRef.current.focus();
39
+ }
40
+ }, [isDroplistTriggerFocused, localRef]);
41
+
42
+ const handleKeyDown: KeyboardEventHandler<HTMLElement> = e => {
43
+ switch (e.key) {
44
+ case 'Tab': {
45
+ focusNode();
46
+ setDroplistOpen(false);
47
+ e.preventDefault();
48
+ e.stopPropagation();
49
+ return;
50
+ }
51
+ case 'ArrowLeft': {
52
+ if (isDroplistTriggerFocused) {
53
+ focusNode();
54
+ setDroplistOpen(false);
55
+ e.stopPropagation();
56
+ }
57
+ return;
58
+ }
59
+ case ' ':
60
+ case 'Enter': {
61
+ e.stopPropagation();
62
+
63
+ return;
64
+ }
65
+ case 'ArrowDown': {
66
+ if (isDroplistTriggerFocused) {
67
+ setDroplistOpen(true);
68
+ }
69
+
70
+ e.stopPropagation();
71
+ return;
72
+ }
73
+ case 'ArrowUp': {
74
+ setDroplistOpen(false);
75
+ localRef.current?.focus();
76
+
77
+ e.stopPropagation();
78
+ return;
79
+ }
80
+ default:
81
+ return;
82
+ }
83
+ };
84
+
85
+ if (!droplistActions.length) {
86
+ return null;
87
+ }
88
+
89
+ return (
90
+ <div
91
+ role='presentation'
92
+ className={styles.treeNodeActions}
93
+ data-focused={isDroplistTriggerFocused || undefined}
94
+ onClick={stopPropagationClick}
95
+ onKeyDown={handleKeyDown}
96
+ onFocus={stopPropagationFocus}
97
+ >
98
+ <Droplist
99
+ open={isDroplistOpen}
100
+ onOpenChange={open => {
101
+ setDroplistOpen(open);
102
+ // Закрытие дроплиста (по клику в пункт, ESC, outside-click) должно
103
+ // вернуть фокус на строку и снять `isDroplistTriggerFocused` —
104
+ // иначе kebab остаётся в visible-hover-состоянии. Tab и ArrowLeft
105
+ // уже делают это сами; для пути через `closeDroplistOnItemClick`
106
+ // ресет навешиваем здесь.
107
+ if (!open) {
108
+ focusNode();
109
+ }
110
+ }}
111
+ items={droplistActions}
112
+ closeDroplistOnItemClick
113
+ placement='bottom-end'
114
+ size={size}
115
+ >
116
+ <Button
117
+ view='elevated'
118
+ appearance='neutral'
119
+ size={size}
120
+ icon={<KebabSVG />}
121
+ onBlur={onBlurActions}
122
+ tabIndex={-1}
123
+ data-test-id={TEST_IDS.droplistTrigger}
124
+ innerRef={localRef}
125
+ />
126
+ </Droplist>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,29 @@
1
+ import { forwardRef, MouseEventHandler, ReactNode } from 'react';
2
+
3
+ import { TEST_IDS } from '../../../constants';
4
+ import styles from '../styles.module.scss';
5
+
6
+ export type TreeNodeHrefProps = {
7
+ href?: string;
8
+ children: ReactNode;
9
+ onClick?: MouseEventHandler<HTMLAnchorElement>;
10
+ };
11
+
12
+ export const TreeNodeHref = forwardRef<HTMLAnchorElement, TreeNodeHrefProps>(({ href, children, onClick }, ref) =>
13
+ href ? (
14
+ <a
15
+ href={href}
16
+ className={styles.treeNodeLink}
17
+ onClick={onClick}
18
+ data-test-id={TEST_IDS.link}
19
+ tabIndex={-2}
20
+ ref={ref}
21
+ >
22
+ {children}
23
+ </a>
24
+ ) : (
25
+ children
26
+ ),
27
+ );
28
+
29
+ TreeNodeHref.displayName = 'TreeNodeHref';
@@ -0,0 +1,2 @@
1
+ export * from './TreeNodeActions';
2
+ export * from './TreeNodeHref';
@@ -0,0 +1,7 @@
1
+ // Size-ось контролов (Checkbox / Radio / Spinner) короче, чем у Tree:
2
+ // 's'-строка → 'xs'-контрол, 'm'/'l' → 's'.
3
+ export const CONTROL_SIZE_BY_TREE_SIZE = {
4
+ s: 'xs',
5
+ m: 's',
6
+ l: 's',
7
+ } as const;
@@ -0,0 +1 @@
1
+ export * from './TreeNode';