@rainbow-oh/yee-c 0.4.3 → 0.4.4

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 (96) hide show
  1. package/dist/cjs/Cascader/cascader.js +53 -62
  2. package/dist/cjs/Cascader/interface.d.ts +19 -2
  3. package/dist/cjs/Cascader/node.js +11 -6
  4. package/dist/cjs/Cascader/search-list.js +2 -1
  5. package/dist/cjs/Cascader/utils/index.d.ts +58 -14
  6. package/dist/cjs/Cascader/utils/index.js +57 -73
  7. package/dist/cjs/Input/style/index.less +19 -0
  8. package/dist/cjs/Popover/style/index.less +1 -0
  9. package/dist/cjs/Select/interface.d.ts +77 -0
  10. package/dist/cjs/Select/option.d.ts +2 -1
  11. package/dist/cjs/Select/option.js +6 -3
  12. package/dist/cjs/Select/options.d.ts +3 -2
  13. package/dist/cjs/Select/options.js +53 -47
  14. package/dist/cjs/Select/select.js +1 -1
  15. package/dist/cjs/Select/style/index.less +8 -0
  16. package/dist/cjs/Selector/interface.d.ts +4 -0
  17. package/dist/cjs/Selector/selector.js +3 -0
  18. package/dist/cjs/Selector/style/index.less +135 -116
  19. package/dist/cjs/Table/hooks/useFilter.js +1 -1
  20. package/dist/cjs/Table/interface.d.ts +4 -0
  21. package/dist/cjs/Table/style/index.less +2 -0
  22. package/dist/cjs/Table/theader/filter.js +2 -1
  23. package/dist/cjs/Tabs/demo/basic.js +8 -1
  24. package/dist/cjs/Tabs/tabs.js +3 -1
  25. package/dist/cjs/Tag/demo/checkable.js +1 -0
  26. package/dist/cjs/Tag/tag.js +1 -1
  27. package/dist/cjs/Trigger/trigger.js +1 -1
  28. package/dist/cjs/style/color.css +1 -1
  29. package/dist/cjs/style/size.css +10 -2
  30. package/dist/cjs/style/themes.css +1 -1
  31. package/dist/cjs/style/variables.css +12 -4
  32. package/dist/cjs/version.d.ts +1 -1
  33. package/dist/cjs/version.js +1 -1
  34. package/dist/esm/Cascader/cascader.js +49 -56
  35. package/dist/esm/Cascader/interface.d.ts +19 -2
  36. package/dist/esm/Cascader/node.js +8 -11
  37. package/dist/esm/Cascader/search-list.js +2 -1
  38. package/dist/esm/Cascader/utils/index.d.ts +58 -14
  39. package/dist/esm/Cascader/utils/index.js +124 -75
  40. package/dist/esm/Input/style/index.less +19 -0
  41. package/dist/esm/Popover/style/index.less +1 -0
  42. package/dist/esm/Select/interface.d.ts +77 -0
  43. package/dist/esm/Select/option.d.ts +2 -1
  44. package/dist/esm/Select/option.js +11 -2
  45. package/dist/esm/Select/options.d.ts +3 -2
  46. package/dist/esm/Select/options.js +3 -1
  47. package/dist/esm/Select/select.js +1 -3
  48. package/dist/esm/Select/style/index.less +8 -0
  49. package/dist/esm/Selector/interface.d.ts +4 -0
  50. package/dist/esm/Selector/selector.js +4 -2
  51. package/dist/esm/Selector/style/index.less +135 -116
  52. package/dist/esm/Table/hooks/useFilter.js +1 -1
  53. package/dist/esm/Table/interface.d.ts +4 -0
  54. package/dist/esm/Table/style/index.less +2 -0
  55. package/dist/esm/Table/theader/filter.js +3 -4
  56. package/dist/esm/Tabs/demo/basic.js +4 -1
  57. package/dist/esm/Tabs/tabs.js +3 -1
  58. package/dist/esm/Tag/demo/checkable.js +1 -0
  59. package/dist/esm/Tag/tag.js +1 -1
  60. package/dist/esm/Trigger/trigger.js +1 -1
  61. package/dist/esm/style/color.css +1 -1
  62. package/dist/esm/style/size.css +10 -2
  63. package/dist/esm/style/themes.css +1 -1
  64. package/dist/esm/style/variables.css +12 -4
  65. package/dist/esm/version.d.ts +1 -1
  66. package/dist/esm/version.js +1 -1
  67. package/dist/umd/yee-c.min.css +2 -2
  68. package/dist/umd/yee-c.min.js +1 -1
  69. package/package.json +1 -1
  70. package/src/Cascader/cascader.tsx +65 -62
  71. package/src/Cascader/interface.ts +19 -2
  72. package/src/Cascader/node.tsx +12 -7
  73. package/src/Cascader/search-list.tsx +1 -1
  74. package/src/Cascader/utils/index.ts +130 -68
  75. package/src/Input/style/index.less +19 -0
  76. package/src/Popover/style/index.less +1 -0
  77. package/src/Select/interface.ts +87 -0
  78. package/src/Select/option.tsx +13 -4
  79. package/src/Select/options.tsx +49 -43
  80. package/src/Select/select.tsx +5 -2
  81. package/src/Select/style/index.less +8 -0
  82. package/src/Selector/interface.ts +4 -0
  83. package/src/Selector/selector.tsx +2 -0
  84. package/src/Selector/style/index.less +135 -116
  85. package/src/Table/hooks/useFilter.ts +1 -1
  86. package/src/Table/interface.ts +4 -0
  87. package/src/Table/style/index.less +2 -0
  88. package/src/Table/theader/filter.tsx +2 -2
  89. package/src/Tabs/demo/basic.tsx +7 -1
  90. package/src/Tabs/tabs.tsx +3 -2
  91. package/src/Tag/demo/checkable.tsx +1 -0
  92. package/src/Tag/tag.tsx +1 -1
  93. package/src/Trigger/trigger.tsx +1 -1
  94. package/src/style/color.css +1 -1
  95. package/src/style/size.css +10 -2
  96. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rainbow-oh/yee-c",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "yee basic components",
5
5
  "keywords": [
6
6
  "react",
@@ -17,7 +17,13 @@ import isEqual from '../utils/isEqual';
17
17
  import mergeContextToProps from '../utils/mergeContextToProps';
18
18
  import Column from './column';
19
19
  import SearchList from './search-list';
20
- import { changeToView, flattenOptions, hasChild } from './utils';
20
+ import {
21
+ changeToView,
22
+ flattenOptions,
23
+ getChildren,
24
+ isSamePrefixPath,
25
+ pathToUid,
26
+ } from './utils';
21
27
 
22
28
  import type {
23
29
  CascaderContextValue,
@@ -41,7 +47,7 @@ const Cascader = (baseprops: CascaderProps) => {
41
47
  options,
42
48
  placement = 'bottomLeft',
43
49
  popupClassName,
44
- searchable,
50
+ searchable = true,
45
51
  fieldNames = {
46
52
  label: 'label',
47
53
  value: 'value',
@@ -97,11 +103,10 @@ const Cascader = (baseprops: CascaderProps) => {
97
103
  currentValue || [],
98
104
  flatOptions,
99
105
  {
100
- needNext: true, // Set to true to expand to full path
101
106
  multiple,
102
107
  },
103
108
  );
104
- setColumns(newQueue as any);
109
+ setColumns(newQueue);
105
110
  setExpandedPath(newPath);
106
111
  },
107
112
  [flatOptions, multiple],
@@ -126,9 +131,9 @@ const Cascader = (baseprops: CascaderProps) => {
126
131
 
127
132
  // node mouse evt
128
133
  const onNodeMouseEnter = (item: FlattenOption, level: number) => {
129
- if (expandTrigger === 'hover' && hasChild(item)) {
134
+ if (expandTrigger === 'hover' && !item.isLeaf) {
130
135
  const queue = columns.slice(0, level + 1);
131
- queue.push(item.children as FlattenOption[]);
136
+ queue.push(getChildren(flatOptions, item.uid));
132
137
  setColumns(queue);
133
138
  // Also update expandedPath to maintain highlight state
134
139
  setExpandedPath((prev) => [
@@ -139,7 +144,6 @@ const Cascader = (baseprops: CascaderProps) => {
139
144
  };
140
145
 
141
146
  // Helper function to get all descendant paths of a node
142
- // Note: For very deep trees, consider adding maxDepth parameter or prevent stack overflow
143
147
  const getAllDescendantPaths = useCallback(
144
148
  (
145
149
  node: FlattenOption,
@@ -148,17 +152,17 @@ const Cascader = (baseprops: CascaderProps) => {
148
152
  const paths: Array<Array<string | number>> = [];
149
153
  const traverse = (currentNode: FlattenOption, depth: number) => {
150
154
  if (depth > maxDepth) return; // Prevent stack overflow
151
- if (!hasChild(currentNode)) {
155
+ if (currentNode.isLeaf) {
152
156
  paths.push(currentNode.path);
153
157
  return;
154
158
  }
155
- const children = currentNode.children as FlattenOption[];
159
+ const children = getChildren(flatOptions, currentNode.uid);
156
160
  children.forEach((child) => traverse(child, depth + 1));
157
161
  };
158
162
  traverse(node, 0);
159
163
  return paths;
160
164
  },
161
- [],
165
+ [flatOptions],
162
166
  );
163
167
 
164
168
  // cascader node click
@@ -177,18 +181,17 @@ const Cascader = (baseprops: CascaderProps) => {
177
181
  // Check if current path is already selected
178
182
  const isSelected = currentValue.some((v) => isEqual(v, item.path));
179
183
 
180
- if (hasChild(item)) {
184
+ if (!item.isLeaf) {
181
185
  // Parent node: select/deselect all descendants
182
186
  const descendantPaths = getAllDescendantPaths(item, 50); // Limit to depth 50 to prevent performance issues
183
- const descendantPathStrs = descendantPaths.map((p) => p.join('-'));
184
187
 
185
188
  // Filter out all descendant paths from current value
186
- let filteredValue = currentValue.filter((v) => {
187
- const pathStr = v.join('-');
188
- return !descendantPathStrs.some((descPathStr) =>
189
- pathStr.startsWith(descPathStr),
190
- );
191
- });
189
+ let filteredValue = currentValue.filter(
190
+ (v) =>
191
+ !descendantPaths.some((descPath) =>
192
+ isSamePrefixPath(descPath, v),
193
+ ),
194
+ );
192
195
 
193
196
  if (!isSelected) {
194
197
  // Add all descendant paths
@@ -196,12 +199,9 @@ const Cascader = (baseprops: CascaderProps) => {
196
199
  }
197
200
 
198
201
  newValue = filteredValue;
199
- newOpts = filteredValue.map((path) => {
200
- return path.map((_, i) => {
201
- const uid = path.slice(0, i + 1).join('-');
202
- return nodeMap.get(uid);
203
- });
204
- });
202
+ newOpts = filteredValue.map((path) =>
203
+ path.map((_, i) => nodeMap.get(pathToUid(path.slice(0, i + 1)))),
204
+ );
205
205
  } else {
206
206
  // Leaf node: toggle individual selection
207
207
  if (isSelected) {
@@ -209,12 +209,9 @@ const Cascader = (baseprops: CascaderProps) => {
209
209
  } else {
210
210
  newValue = [...currentValue, item.path];
211
211
  }
212
- newOpts = (newValue as Array<Array<string | number>>).map((path) => {
213
- return path.map((_, i) => {
214
- const uid = path.slice(0, i + 1).join('-');
215
- return nodeMap.get(uid);
216
- });
217
- });
212
+ newOpts = (newValue as Array<Array<string | number>>).map((path) =>
213
+ path.map((_, i) => nodeMap.get(pathToUid(path.slice(0, i + 1)))),
214
+ );
218
215
  }
219
216
  changed = true;
220
217
  } else if (shouldSelect) {
@@ -222,23 +219,21 @@ const Cascader = (baseprops: CascaderProps) => {
222
219
  newValue = multiple ? [...mergedValue, item.path] : item.path;
223
220
  newOpts = multiple
224
221
  ? [
225
- ...(mergedValue as Array<Array<string | number>>).map((item) => {
226
- const vs = item as Array<string | number>;
227
- const arr = vs.map((_, i) => vs.slice(0, i + 1).join('-'));
228
- return arr.map((uid: string) => nodeMap.get(uid));
222
+ ...(mergedValue as Array<Array<string | number>>).map((path) => {
223
+ const vs = path as Array<string | number>;
224
+ return vs.map((_, i) =>
225
+ nodeMap.get(pathToUid(vs.slice(0, i + 1))),
226
+ );
229
227
  }),
230
- [
231
- item.path.map((_, i) => {
232
- const uid = item.path.slice(0, i + 1).join('-');
233
- return nodeMap.get(uid);
234
- }),
235
- ],
228
+ item.path.map((_, i) =>
229
+ nodeMap.get(pathToUid(item.path.slice(0, i + 1))),
230
+ ),
236
231
  ]
237
232
  : flatOptions.filter((opt: FlattenOption) => {
238
- const arr = item.path.map((_, i) =>
239
- item.path.slice(0, i + 1).join('-'),
233
+ const ancestors = item.path.map((_, i) =>
234
+ pathToUid(item.path.slice(0, i + 1)),
240
235
  );
241
- return arr.includes(opt.uid as string);
236
+ return ancestors.includes(opt.uid);
242
237
  });
243
238
  changed = true;
244
239
  } else {
@@ -289,7 +284,7 @@ const Cascader = (baseprops: CascaderProps) => {
289
284
  return { options: [], selectedKeys: [] };
290
285
 
291
286
  const { fullNode = false, onlyParentNode = false } = props;
292
- const formatKey = (val: Array<string | number>) => val.join('-');
287
+ const formatKey = (val: Array<string | number>) => pathToUid(val);
293
288
 
294
289
  let rawValues: (string | number)[][] = [];
295
290
 
@@ -308,30 +303,27 @@ const Cascader = (baseprops: CascaderProps) => {
308
303
  // Filter values based on onlyParentNode option
309
304
  let displayValues = rawValues;
310
305
  if (multiple && onlyParentNode) {
311
- // Find parent nodes that have all children selected
312
- const parentIds: string[] = [];
313
- const childPathStrs = new Set(rawValues.map((v) => v.join('-')));
306
+ // Find parent nodes that have all (direct) children selected
307
+ const selectedPathKeys = new Set(rawValues.map(pathToUid));
308
+ const parentPaths: Array<Array<string | number>> = [];
314
309
 
315
310
  // Check each node to see if it's a fully selected parent
316
- nodeMap.forEach((node, uid) => {
317
- if (hasChild(node)) {
318
- const allChildrenSelected = (node.children as FlattenOption[])?.every(
319
- (child) => childPathStrs.has(child.path.join('-')),
311
+ nodeMap.forEach((node) => {
312
+ if (!node.isLeaf) {
313
+ const allChildrenSelected = getChildren(flatOptions, node.uid).every(
314
+ (child) => selectedPathKeys.has(pathToUid(child.path)),
320
315
  );
321
316
  if (allChildrenSelected) {
322
- parentIds.push(uid);
317
+ parentPaths.push(node.path);
323
318
  }
324
319
  }
325
320
  });
326
321
 
327
322
  // Filter out child nodes whose parent is fully selected
328
- displayValues = rawValues.filter((path) => {
329
- const pathStr = path.join('-');
330
- // Keep this path if it's not a child of any fully selected parent
331
- return !parentIds.some((parentId) =>
332
- pathStr.startsWith(parentId + '-'),
333
- );
334
- });
323
+ displayValues = rawValues.filter(
324
+ (path) =>
325
+ !parentPaths.some((parentPath) => isSamePrefixPath(parentPath, path)),
326
+ );
335
327
  }
336
328
 
337
329
  const displayOptions = displayValues.map((valPath) => {
@@ -348,8 +340,7 @@ const Cascader = (baseprops: CascaderProps) => {
348
340
  options: opts,
349
341
  });
350
342
  } else {
351
- const source =
352
- opts[opts.length - 1]?.$source ?? opts[opts.length - 1];
343
+ const source = opts[opts.length - 1]?.$source;
353
344
  label = (source?.[optionLabelProp as string] ??
354
345
  '') as React.ReactNode;
355
346
  }
@@ -367,6 +358,7 @@ const Cascader = (baseprops: CascaderProps) => {
367
358
  multiple,
368
359
  mergedValue,
369
360
  nodeMap,
361
+ flatOptions,
370
362
  optionLabelProp,
371
363
  props.fullNode,
372
364
  props.onlyParentNode,
@@ -377,6 +369,12 @@ const Cascader = (baseprops: CascaderProps) => {
377
369
  popupVisible.current = newVisible;
378
370
  };
379
371
 
372
+ const handleClear = () => {
373
+ setSearchValue('');
374
+ setMergedValue([]);
375
+ onChange?.([], []);
376
+ };
377
+
380
378
  // Performance Note: For large datasets (1000+ options), consider:
381
379
  // 1. Adding debounce to the search input
382
380
  // 2. Implementing virtual scrolling for search results
@@ -390,7 +388,11 @@ const Cascader = (baseprops: CascaderProps) => {
390
388
  }> = [];
391
389
 
392
390
  for (const item of flatOptions) {
393
- const text = String(item.label);
391
+ // Match against the full breadcrumb (e.g. "A / B / C") so that:
392
+ // - searching a parent label surfaces its descendants,
393
+ // - the result row shows the full path to disambiguate duplicate leaf
394
+ // labels across different parents.
395
+ const text = item.labelPath.join(' / ');
394
396
  const textLower = text.toLowerCase();
395
397
  if (textLower.includes(lowStr)) {
396
398
  const matches: Array<{ text: string; highlight: boolean }> = [];
@@ -435,6 +437,7 @@ const Cascader = (baseprops: CascaderProps) => {
435
437
  mode={multiple ? 'multiple' : undefined}
436
438
  value={searchValue}
437
439
  onSearch={handleSearch}
440
+ onClear={handleClear}
438
441
  />
439
442
  );
440
443
  };
@@ -24,11 +24,27 @@ export type FieldNames = {
24
24
  children: string;
25
25
  };
26
26
 
27
- export type FlattenOption = Option & {
27
+ /**
28
+ * Flattened representation of a tree node.
29
+ *
30
+ * Deliberately a concrete type (not `Option & ...`) so that:
31
+ * - `.label` / `.value` / `.isLeaf` resolve to their precise types (an index
32
+ * signature on `Option` would otherwise widen them to `unknown`),
33
+ * - `.children` is a compile error — flattened nodes intentionally do not
34
+ * carry children; use `getChildren(data, uid)` instead.
35
+ * Dynamic/extra-field access should go through `$source` (the raw node).
36
+ */
37
+ export type FlattenOption = {
38
+ $source: Option;
39
+ label: string;
40
+ value: string | number;
28
41
  pid: string;
29
42
  uid: string;
30
43
  path: Array<string | number>;
31
- $source: Option;
44
+ labelPath: string[];
45
+ level: number;
46
+ isLeaf: boolean;
47
+ disabled?: boolean;
32
48
  };
33
49
 
34
50
  export type NodeProps = FlattenOption & {
@@ -104,6 +120,7 @@ export interface CascaderProps extends Omit<
104
120
  multiple?: boolean;
105
121
  /**
106
122
  * Is it searchable
123
+ * @default true
107
124
  */
108
125
  searchable?: boolean;
109
126
  /**
@@ -5,12 +5,12 @@ import Checkbox from '../Checkbox';
5
5
  import Spin from '../Spin';
6
6
  import isEqual from '../utils/isEqual';
7
7
  import { CascaderCtx } from './cascader';
8
- import { hasChild, isHalfChecked } from './utils';
8
+ import { getChildren, isHalfChecked } from './utils';
9
9
 
10
10
  import type { NodeProps } from './interface';
11
11
 
12
12
  const Node = (props: NodeProps) => {
13
- const { uid, value, disabled, label, children, isLeaf, path, level } = props;
13
+ const { uid, value, disabled, label, $source, isLeaf, path, level } = props;
14
14
 
15
15
  const {
16
16
  prefixCls,
@@ -19,11 +19,12 @@ const Node = (props: NodeProps) => {
19
19
  mergedValue,
20
20
  loadData,
21
21
  nodeMap,
22
+ flatOptions,
22
23
  onNodeMouseEnter,
23
24
  onItemClick,
24
25
  } = useContext(CascaderCtx);
25
26
 
26
- const isParent = hasChild(props);
27
+ const isParent = !isLeaf;
27
28
 
28
29
  /**
29
30
  * 0: not load
@@ -50,7 +51,7 @@ const Node = (props: NodeProps) => {
50
51
  const handleLoadData = () => {
51
52
  if (loadData && isLeaf === false) {
52
53
  setLoad(1);
53
- loadData({ label, value, children })
54
+ loadData($source)
54
55
  ?.then(() => {
55
56
  setLoad(2);
56
57
  })
@@ -79,13 +80,17 @@ const Node = (props: NodeProps) => {
79
80
  const [checkedAll, indeterminate] = useMemo(() => {
80
81
  const currentNode = nodeMap.get(uid);
81
82
  if (!multiple || !currentNode || !isParent) return [false, false];
82
- return isHalfChecked(mergedValue as (string | number)[][], currentNode);
83
- }, [nodeMap, uid, mergedValue, isParent, multiple]);
83
+ return isHalfChecked(
84
+ mergedValue as (string | number)[][],
85
+ currentNode,
86
+ getChildren(flatOptions, uid),
87
+ );
88
+ }, [nodeMap, uid, mergedValue, flatOptions, isParent, multiple]);
84
89
 
85
90
  const clsName = clsx(
86
91
  `${prefixCls}-menu-item`,
87
92
  {
88
- [`${prefixCls}-menu-item-expand`]: hasChild(props),
93
+ [`${prefixCls}-menu-item-expand`]: !isLeaf,
89
94
  [`${prefixCls}-menu-item-disabled`]: disabled,
90
95
  active: expandedPath[level]?.key === uid,
91
96
  },
@@ -35,7 +35,7 @@ const SearchList = (props: SearchListProps) => {
35
35
  <ul className={`${prefixCls}-filter-menu`}>
36
36
  {items.length ? (
37
37
  items.map((item) => {
38
- const title = item.data.title || item.data.label;
38
+ const title = item.data.$source?.title || item.data.labelPath.join(' / ');
39
39
 
40
40
  return (
41
41
  <li
@@ -16,70 +16,104 @@ export function hasChild(
16
16
  }
17
17
 
18
18
  /**
19
- * Get the next queue element for navigation
19
+ * Separator used to build a node's `uid` from its value path. A control
20
+ * character is used on purpose so it can never appear inside a user value —
21
+ * this prevents collisions like value `"a-1"` vs path `["a", "1"]` that a plain
22
+ * `'-'` separator would produce.
23
+ */
24
+ export const UID_SEP = '';
25
+
26
+ /**
27
+ * Build the canonical uid for a value path. Use this everywhere a path needs
28
+ * to be turned into a key (nodeMap lookups, selected keys, comparisons) so the
29
+ * format stays consistent with the `uid`/`pid` produced by `flattenOptions`.
30
+ * @param path - The value path
31
+ */
32
+ export const pathToUid = (path: Array<string | number>): string =>
33
+ path.map(String).join(UID_SEP);
34
+
35
+ /**
36
+ * Get the direct flattened children of the node identified by `uid`.
37
+ *
38
+ * Flattened nodes intentionally do not carry a `children` reference (it used to
39
+ * point at the raw, un-flattened subtree and caused type lies). Derive children
40
+ * from the flat list via `pid` instead — single source of truth.
41
+ * @param data - The flattened options data
42
+ * @param uid - The parent node's uid
43
+ */
44
+ export const getChildren = (
45
+ data: Array<FlattenOption>,
46
+ uid: string,
47
+ ): FlattenOption[] => data.filter((item) => item.pid === uid);
48
+
49
+ /**
50
+ * Find the node in `data` whose parent is `pid` and whose value is `value`.
20
51
  * @internal
21
52
  */
22
- const getNextQueueElement = (
53
+ const findInColumn = (
23
54
  data: Array<FlattenOption>,
55
+ pid: string,
24
56
  value: string | number,
25
- ): [FlattenOption[], { id: string | number; text: string }] | [] => {
26
- if (!Array.isArray(data)) {
27
- return [];
28
- }
29
-
30
- for (let i = 0; i < data.length; i++) {
31
- const item = data[i];
32
- if (item.uid === value) {
33
- const nexts = hasChild(item)
34
- ? (item.children as Array<FlattenOption>).filter((c) => c.pid === value)
35
- : [];
36
- return [
37
- nexts,
38
- { id: item.value as string | number, text: item.label as string },
39
- ];
40
- }
41
- }
42
- return [];
43
- };
57
+ ): FlattenOption | undefined =>
58
+ data.find((item) => item.pid === pid && item.value === value);
44
59
 
45
60
  /**
46
- * Populate data into input fields and dropdown lists from a value array
47
- * @param arr - The value array representing the selected path
61
+ * Reconstruct cascade columns and the highlight path from a value path.
62
+ *
63
+ * Walks the flattened options level by level, matching each value segment
64
+ * against the correct parent column via `pid` + `value`. Returns:
65
+ * - `queue`: the columns to render (column 0 = root, then each matched
66
+ * node's children column).
67
+ * - `path`: the highlight breadcrumb shaped as `{ key: uid, label }`, so it
68
+ * aligns with `expandedPath[level].key === uid` in the node view.
69
+ *
70
+ * Stops safely at the last valid segment if the value path does not exist in
71
+ * the data source.
72
+ *
73
+ * @param arr - The selected path. In `multiple` mode the value is an array of
74
+ * paths; the first path is used to drive which columns get expanded.
48
75
  * @param data - The flattened options data
49
76
  * @param options - Optional configuration
50
- * @returns A tuple of [queue, path] where queue is the column data and path is the selected path info
77
+ * @returns A tuple of `[queue, path]`
51
78
  */
52
79
  export const changeToView = (
53
- arr: Array<string | number>,
80
+ arr: Array<string | number> | Array<Array<string | number>>,
54
81
  data: Array<FlattenOption>,
55
82
  options?: {
56
- needNext?: boolean;
57
83
  multiple?: boolean;
58
84
  },
59
- ) => {
60
- const { needNext, multiple } = options || {};
61
- const _queue: FlattenOption[][] = [];
62
- const _path: Array<{ id: string | number; text: string }> = [];
85
+ ): [FlattenOption[][], Array<{ key: string; label: string }>] => {
86
+ const { multiple } = options || {};
87
+ const queue: FlattenOption[][] = [];
88
+ const path: Array<{ key: string; label: string }> = [];
89
+
90
+ // Column 0 (root level) is always present so the popup can open even with
91
+ // no selection.
92
+ queue.push(data.filter((item) => item.level === 0));
93
+
94
+ if (!Array.isArray(arr) || arr.length === 0) {
95
+ return [queue, path];
96
+ }
97
+
98
+ // In multiple mode the value is an array of paths; use the first one to
99
+ // drive column expansion.
100
+ const segments: Array<string | number> =
101
+ multiple && Array.isArray(arr[0])
102
+ ? (arr[0] as Array<string | number>)
103
+ : (arr as Array<string | number>);
63
104
 
64
- if (Array.isArray(arr)) {
65
- let i = 0;
66
- let _arr = arr;
67
- if (multiple) {
68
- _arr = Array.isArray(arr[0]) ? arr[0] : arr;
105
+ let currentPid = '';
106
+ for (let i = 0; i < segments.length; i++) {
107
+ const matched = findInColumn(data, currentPid, segments[i]);
108
+ if (!matched) break; // value path doesn't exist in the data source
109
+ path.push({ key: matched.uid, label: matched.label as string });
110
+ if (!matched.isLeaf) {
111
+ queue.push(getChildren(data, matched.uid));
69
112
  }
70
- let _data = data.filter((item) => item.level === 0);
71
- do {
72
- _queue.push(_data);
73
- const result = getNextQueueElement(_data, _arr[i]);
74
- if (result.length) {
75
- const [_dataNext, _pathItem] = result;
76
- _data = _dataNext;
77
- _path.push(_pathItem);
78
- }
79
- i++;
80
- } while (needNext ? i <= _arr.length : i < _arr.length);
113
+ currentPid = matched.uid;
81
114
  }
82
- return [_queue, _path];
115
+
116
+ return [queue, path];
83
117
  };
84
118
 
85
119
  // Assemble Tags Options
@@ -302,11 +336,11 @@ export const changeToView = (
302
336
  // }
303
337
 
304
338
  /**
305
- * Format a path array into a key string
339
+ * Format a value path into a key string. Alias of `pathToUid` so all path→key
340
+ * conversions share one collision-safe format.
306
341
  * @param val - The path array
307
- * @returns A string key joined by '-'
308
342
  */
309
- export const formatKey = (val: Array<string | number>) => val.join('-');
343
+ export const formatKey = (val: Array<string | number>): string => pathToUid(val);
310
344
 
311
345
  /**
312
346
  * Check if a path has the same prefix as another path
@@ -330,14 +364,16 @@ export const isSamePrefixPath = (
330
364
  * Determine if a node is fully selected or half selected (indeterminate)
331
365
  * @param value - The selected value paths
332
366
  * @param item - The flatten option node to check
367
+ * @param children - The node's direct flattened children (derive via `getChildren`)
333
368
  * @returns A tuple [checkedAll, indeterminate]
334
369
  */
335
370
  export function isHalfChecked(
336
371
  value: Array<Array<string | number>>,
337
372
  item: FlattenOption,
373
+ children: FlattenOption[],
338
374
  ) {
339
- if (!hasChild(item)) return [false, false];
340
- const total = item.children!.length;
375
+ if (!children.length) return [false, false];
376
+ const total = children.length;
341
377
  const prefixPath = item.path;
342
378
 
343
379
  const filtered = value.filter((path) => isSamePrefixPath(prefixPath, path));
@@ -348,14 +384,29 @@ export function isHalfChecked(
348
384
  }
349
385
 
350
386
  /**
351
- * Flatten a tree structure into an array for easier searching and traversal
387
+ * Default maximum tree depth accepted by `flattenOptions`. Guards against
388
+ * stack overflow on pathological/deeply-nested input.
389
+ */
390
+ const DEFAULT_MAX_DEPTH = 100;
391
+
392
+ /**
393
+ * Flatten a tree structure into an array for easier searching and traversal.
394
+ *
395
+ * The flattened nodes intentionally do NOT carry a `children` field — use
396
+ * `getChildren(data, uid)` to read a node's children. `uid`/`pid` are built
397
+ * via `pathToUid` (collision-safe). Includes a depth cap and a cycle guard so
398
+ * malformed input (very deep or self-referencing trees) cannot overflow the
399
+ * stack.
400
+ *
352
401
  * @param tree - The tree data to flatten
353
402
  * @param fieldNames - Custom field names for label, value, and children
403
+ * @param maxDepth - Maximum nesting depth (default 100)
354
404
  * @returns An array of flattened options with additional metadata
355
405
  */
356
406
  export const flattenOptions = (
357
407
  tree: Array<Record<string, unknown>>,
358
- fieldNames: FieldNames,
408
+ fieldNames?: FieldNames,
409
+ maxDepth: number = DEFAULT_MAX_DEPTH,
359
410
  ): FlattenOption[] => {
360
411
  const res: FlattenOption[] = [];
361
412
  const {
@@ -363,29 +414,40 @@ export const flattenOptions = (
363
414
  value = 'value',
364
415
  children = 'children',
365
416
  } = fieldNames || {};
417
+ // Object-identity set of nodes on the current DFS path, for cycle detection.
418
+ const seen = new Set<Record<string, unknown>>();
366
419
  const dfs = (
367
420
  nodes: Array<Record<string, unknown>>,
368
421
  parents: Array<string | number> = [],
422
+ labelParents: string[] = [],
369
423
  ) => {
370
424
  nodes.forEach((node: Record<string, unknown>) => {
371
- const wrappered = {
372
- $source: node,
373
- label: node[label],
374
- value: node[value],
375
- children: node[children],
376
- } as Option;
377
- const path = [...parents, wrappered['value']] as Array<string | number>;
378
- const uid = path.join('-');
379
- const pid = parents.join('-');
425
+ if (parents.length >= maxDepth) return; // depth guard
426
+ if (seen.has(node)) return; // cycle guard (back-edge to an ancestor)
427
+ const nodeLabel = node[label] as string;
428
+ const path = [...parents, node[value]] as Array<string | number>;
429
+ const labelPath = [...labelParents, nodeLabel];
430
+ const uid = pathToUid(path);
380
431
  res.push({
381
- ...wrappered,
382
- pid,
432
+ $source: node as Option,
433
+ label: nodeLabel,
434
+ value: node[value] as string | number,
435
+ pid: pathToUid(parents),
383
436
  uid,
384
437
  path,
438
+ labelPath,
385
439
  level: parents.length,
386
- isLeaf: !hasChild(wrappered),
387
- } as FlattenOption);
388
- if (wrappered['children']) dfs(wrappered['children'] as Option[], path);
440
+ isLeaf: !hasChild(node, children),
441
+ });
442
+ if (hasChild(node, children)) {
443
+ seen.add(node);
444
+ dfs(
445
+ node[children] as Array<Record<string, unknown>>,
446
+ path,
447
+ labelPath,
448
+ );
449
+ seen.delete(node);
450
+ }
389
451
  });
390
452
  };
391
453
  dfs(tree);