@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.45

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 (209) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/FlowContextProvider.d.ts +5 -1
  4. package/lib/FlowContextProvider.js +9 -2
  5. package/lib/JSRunner.d.ts +10 -1
  6. package/lib/JSRunner.js +50 -5
  7. package/lib/ViewScopedFlowEngine.js +5 -1
  8. package/lib/components/FieldModelRenderer.js +2 -2
  9. package/lib/components/FlowModelRenderer.d.ts +3 -1
  10. package/lib/components/FlowModelRenderer.js +12 -6
  11. package/lib/components/FormItem.d.ts +6 -0
  12. package/lib/components/FormItem.js +11 -3
  13. package/lib/components/MobilePopup.js +6 -5
  14. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  15. package/lib/components/dnd/gridDragPlanner.js +613 -21
  16. package/lib/components/dnd/index.d.ts +31 -2
  17. package/lib/components/dnd/index.js +244 -23
  18. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  19. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  20. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  21. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -11
  22. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  23. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  24. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  27. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  28. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  29. package/lib/components/subModel/AddSubModelButton.js +27 -1
  30. package/lib/components/subModel/LazyDropdown.js +293 -52
  31. package/lib/components/subModel/index.d.ts +1 -0
  32. package/lib/components/subModel/index.js +19 -0
  33. package/lib/components/subModel/utils.d.ts +1 -1
  34. package/lib/components/subModel/utils.js +9 -3
  35. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  36. package/lib/components/variables/VariableHybridInput.js +499 -0
  37. package/lib/components/variables/index.d.ts +2 -0
  38. package/lib/components/variables/index.js +3 -0
  39. package/lib/data-source/index.d.ts +84 -0
  40. package/lib/data-source/index.js +259 -5
  41. package/lib/executor/FlowExecutor.js +32 -9
  42. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  43. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  44. package/lib/flow-registry/index.d.ts +1 -0
  45. package/lib/flow-registry/index.js +3 -1
  46. package/lib/flowContext.d.ts +3 -0
  47. package/lib/flowContext.js +46 -1
  48. package/lib/flowEngine.d.ts +151 -1
  49. package/lib/flowEngine.js +392 -18
  50. package/lib/flowI18n.js +2 -1
  51. package/lib/flowSettings.d.ts +14 -6
  52. package/lib/flowSettings.js +34 -6
  53. package/lib/index.d.ts +2 -0
  54. package/lib/index.js +7 -0
  55. package/lib/lazy-helper.d.ts +14 -0
  56. package/lib/lazy-helper.js +71 -0
  57. package/lib/locale/en-US.json +1 -0
  58. package/lib/locale/index.d.ts +2 -0
  59. package/lib/locale/zh-CN.json +1 -0
  60. package/lib/models/DisplayItemModel.d.ts +1 -1
  61. package/lib/models/EditableItemModel.d.ts +1 -1
  62. package/lib/models/FilterableItemModel.d.ts +1 -1
  63. package/lib/models/flowModel.d.ts +13 -10
  64. package/lib/models/flowModel.js +81 -21
  65. package/lib/provider.js +38 -23
  66. package/lib/reactive/observer.js +46 -16
  67. package/lib/runjs-context/registry.d.ts +1 -1
  68. package/lib/runjs-context/setup.js +20 -12
  69. package/lib/runjs-context/snippets/index.js +13 -2
  70. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  72. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  74. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  75. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  82. package/lib/utils/parsePathnameToViewParams.js +29 -5
  83. package/lib/utils/randomId.d.ts +39 -0
  84. package/lib/utils/randomId.js +45 -0
  85. package/lib/utils/runjsTemplateCompat.js +1 -1
  86. package/lib/utils/runjsValue.js +41 -11
  87. package/lib/utils/schema-utils.d.ts +7 -1
  88. package/lib/utils/schema-utils.js +19 -0
  89. package/lib/views/FlowView.d.ts +7 -1
  90. package/lib/views/FlowView.js +11 -1
  91. package/lib/views/PageComponent.js +8 -6
  92. package/lib/views/ViewNavigation.d.ts +12 -2
  93. package/lib/views/ViewNavigation.js +28 -9
  94. package/lib/views/createViewMeta.js +114 -50
  95. package/lib/views/inheritLayoutContext.d.ts +10 -0
  96. package/lib/views/inheritLayoutContext.js +50 -0
  97. package/lib/views/runViewBeforeClose.d.ts +10 -0
  98. package/lib/views/runViewBeforeClose.js +45 -0
  99. package/lib/views/useDialog.d.ts +2 -1
  100. package/lib/views/useDialog.js +22 -3
  101. package/lib/views/useDrawer.d.ts +2 -1
  102. package/lib/views/useDrawer.js +22 -3
  103. package/lib/views/usePage.d.ts +5 -11
  104. package/lib/views/usePage.js +304 -144
  105. package/package.json +6 -5
  106. package/src/FlowContextProvider.tsx +9 -1
  107. package/src/JSRunner.ts +68 -4
  108. package/src/ViewScopedFlowEngine.ts +4 -0
  109. package/src/__tests__/JSRunner.test.ts +27 -1
  110. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  111. package/src/__tests__/flow-engine.test.ts +166 -0
  112. package/src/__tests__/flowContext.test.ts +82 -1
  113. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +16 -0
  120. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  121. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  122. package/src/__tests__/runjsSnippets.test.ts +21 -0
  123. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  124. package/src/components/FieldModelRenderer.tsx +2 -1
  125. package/src/components/FlowModelRenderer.tsx +18 -6
  126. package/src/components/FormItem.tsx +7 -1
  127. package/src/components/MobilePopup.tsx +4 -2
  128. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  129. package/src/components/__tests__/FormItem.test.tsx +25 -0
  130. package/src/components/__tests__/dnd.test.ts +44 -0
  131. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  132. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  133. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  134. package/src/components/dnd/gridDragPlanner.ts +758 -19
  135. package/src/components/dnd/index.tsx +305 -28
  136. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  137. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +99 -11
  138. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  139. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +194 -5
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  145. package/src/components/subModel/LazyDropdown.tsx +332 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +522 -37
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +7 -1
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +68 -1
  154. package/src/data-source/index.ts +322 -6
  155. package/src/executor/FlowExecutor.ts +35 -10
  156. package/src/executor/__tests__/flowExecutor.test.ts +85 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +50 -3
  161. package/src/flowEngine.ts +449 -14
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  172. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  173. package/src/models/__tests__/flowModel.test.ts +80 -37
  174. package/src/models/flowModel.tsx +122 -36
  175. package/src/provider.tsx +41 -25
  176. package/src/reactive/__tests__/observer.test.tsx +82 -0
  177. package/src/reactive/observer.tsx +87 -25
  178. package/src/runjs-context/registry.ts +1 -1
  179. package/src/runjs-context/setup.ts +22 -12
  180. package/src/runjs-context/snippets/index.ts +12 -1
  181. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  182. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  183. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  184. package/src/types.ts +62 -0
  185. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  186. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +28 -0
  187. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  188. package/src/utils/__tests__/utils.test.ts +62 -0
  189. package/src/utils/createCollectionContextMeta.ts +6 -2
  190. package/src/utils/index.ts +5 -1
  191. package/src/utils/parsePathnameToViewParams.ts +47 -7
  192. package/src/utils/randomId.ts +48 -0
  193. package/src/utils/runjsTemplateCompat.ts +1 -1
  194. package/src/utils/runjsValue.ts +50 -11
  195. package/src/utils/schema-utils.ts +30 -1
  196. package/src/views/FlowView.tsx +22 -2
  197. package/src/views/PageComponent.tsx +7 -4
  198. package/src/views/ViewNavigation.ts +46 -9
  199. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  200. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  201. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  202. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  203. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  204. package/src/views/createViewMeta.ts +106 -34
  205. package/src/views/inheritLayoutContext.ts +26 -0
  206. package/src/views/runViewBeforeClose.ts +19 -0
  207. package/src/views/useDialog.tsx +27 -3
  208. package/src/views/useDrawer.tsx +27 -3
  209. package/src/views/usePage.tsx +367 -179
@@ -247,18 +247,75 @@ const useKeepDropdownOpen = () => {
247
247
  */
248
248
  const useMenuSearch = () => {
249
249
  const [searchValues, setSearchValues] = useState<Record<string, string>>({});
250
+ const [inputValues, setInputValues] = useState<Record<string, string>>({});
250
251
  const [isSearching, setIsSearching] = useState(false);
252
+ const [composingCount, setComposingCount] = useState(0);
253
+ const composingKeysRef = useRef<Set<string>>(new Set());
251
254
  const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
252
255
 
253
- const updateSearchValue = (key: string, value: string) => {
256
+ const updateSearchValue = useCallback((key: string, value: string) => {
254
257
  setIsSearching(true);
258
+ setInputValues((prev) => ({ ...prev, [key]: value }));
255
259
  setSearchValues((prev) => ({ ...prev, [key]: value }));
256
260
 
257
261
  if (searchTimeoutRef.current) {
258
262
  clearTimeout(searchTimeoutRef.current);
259
263
  }
260
264
  searchTimeoutRef.current = setTimeout(() => setIsSearching(false), 300);
261
- };
265
+ }, []);
266
+
267
+ const startComposition = useCallback((key: string) => {
268
+ composingKeysRef.current.add(key);
269
+ setIsSearching(true);
270
+ setComposingCount(composingKeysRef.current.size);
271
+ if (searchTimeoutRef.current) {
272
+ clearTimeout(searchTimeoutRef.current);
273
+ searchTimeoutRef.current = null;
274
+ }
275
+ }, []);
276
+
277
+ const endComposition = useCallback(
278
+ (key: string, value: string) => {
279
+ composingKeysRef.current.delete(key);
280
+ setComposingCount(composingKeysRef.current.size);
281
+ updateSearchValue(key, value);
282
+ },
283
+ [updateSearchValue],
284
+ );
285
+
286
+ const updateInputValue = useCallback((key: string, value: string) => {
287
+ setInputValues((prev) => ({ ...prev, [key]: value }));
288
+ }, []);
289
+
290
+ const clearSearchValue = useCallback((key: string) => {
291
+ composingKeysRef.current.delete(key);
292
+ setComposingCount(composingKeysRef.current.size);
293
+
294
+ setInputValues((prev) => {
295
+ if (!(key in prev)) return prev;
296
+ const next = { ...prev };
297
+ delete next[key];
298
+ return next;
299
+ });
300
+ setSearchValues((prev) => {
301
+ if (!(key in prev)) return prev;
302
+ const next = { ...prev };
303
+ delete next[key];
304
+ return next;
305
+ });
306
+ }, []);
307
+
308
+ const clearAllSearchValues = useCallback(() => {
309
+ composingKeysRef.current.clear();
310
+ setComposingCount(0);
311
+ setInputValues({});
312
+ setSearchValues({});
313
+ setIsSearching(false);
314
+ }, []);
315
+
316
+ const isComposing = useCallback((key?: string) => {
317
+ return key ? composingKeysRef.current.has(key) : composingKeysRef.current.size > 0;
318
+ }, []);
262
319
 
263
320
  useEffect(() => {
264
321
  return () => {
@@ -270,8 +327,15 @@ const useMenuSearch = () => {
270
327
 
271
328
  return {
272
329
  searchValues,
273
- isSearching,
330
+ inputValues,
331
+ isSearching: isSearching || composingCount > 0,
274
332
  updateSearchValue,
333
+ updateInputValue,
334
+ startComposition,
335
+ endComposition,
336
+ clearSearchValue,
337
+ clearAllSearchValues,
338
+ isComposing,
275
339
  };
276
340
  };
277
341
 
@@ -358,29 +422,95 @@ const SearchInputWithAutoFocus: FC<InputProps & { visible: boolean }> = (props)
358
422
 
359
423
  const getKeyPath = (path: string[], key: string) => [...path, key].join('/');
360
424
 
425
+ const normalizeOpenKeys = (nextOpenKeys: string[]) => {
426
+ const latestKey = nextOpenKeys[nextOpenKeys.length - 1];
427
+
428
+ if (!latestKey) {
429
+ return [];
430
+ }
431
+
432
+ return nextOpenKeys.filter((key) => latestKey === key || latestKey.startsWith(`${key}/`));
433
+ };
434
+
435
+ const getLabelSearchText = (label: React.ReactNode): string => {
436
+ if (label === null || label === undefined || typeof label === 'boolean') {
437
+ return '';
438
+ }
439
+ if (typeof label === 'string' || typeof label === 'number') {
440
+ return String(label);
441
+ }
442
+ if (Array.isArray(label)) {
443
+ return label.map(getLabelSearchText).join(' ');
444
+ }
445
+ if (React.isValidElement(label)) {
446
+ return getLabelSearchText(label.props.children);
447
+ }
448
+ return '';
449
+ };
450
+
361
451
  const createSearchItem = (
362
452
  item: Item,
363
453
  searchKey: string,
364
454
  currentSearchValue: string,
365
455
  menuVisible: boolean,
366
456
  t: (key: string) => string,
367
- updateSearchValue: (key: string, value: string) => void,
457
+ searchHandlers: {
458
+ updateSearchValue: (key: string, value: string) => void;
459
+ updateInputValue: (key: string, value: string) => void;
460
+ startComposition: (key: string) => void;
461
+ endComposition: (key: string, value: string) => void;
462
+ isComposing: (key: string) => boolean;
463
+ },
464
+ activateSearchSubmenu: (key: string) => void,
465
+ deactivateSearchSubmenu: (key: string) => void,
368
466
  ) => ({
369
467
  key: `${searchKey}-search`,
370
468
  type: 'group' as const,
371
469
  label: (
372
- <div>
470
+ <div onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
373
471
  <SearchInputWithAutoFocus
374
472
  visible={menuVisible}
375
473
  variant="borderless"
376
474
  allowClear
377
475
  placeholder={t(item.searchPlaceholder || 'Search')}
378
476
  value={currentSearchValue}
477
+ onFocus={(e) => {
478
+ e.stopPropagation();
479
+ }}
379
480
  onChange={(e) => {
380
481
  e.stopPropagation();
381
- updateSearchValue(searchKey, e.target.value);
482
+ const value = e.target.value;
483
+ activateSearchSubmenu(searchKey);
484
+ if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
485
+ searchHandlers.updateInputValue(searchKey, value);
486
+ return;
487
+ }
488
+ if (!value) {
489
+ deactivateSearchSubmenu(searchKey);
490
+ }
491
+ searchHandlers.updateSearchValue(searchKey, value);
492
+ }}
493
+ onCompositionStart={(e) => {
494
+ e.stopPropagation();
495
+ activateSearchSubmenu(searchKey);
496
+ searchHandlers.startComposition(searchKey);
497
+ }}
498
+ onCompositionEnd={(e) => {
499
+ e.stopPropagation();
500
+ const value = e.currentTarget.value;
501
+ if (value) {
502
+ activateSearchSubmenu(searchKey);
503
+ } else {
504
+ deactivateSearchSubmenu(searchKey);
505
+ }
506
+ searchHandlers.endComposition(searchKey, value);
507
+ }}
508
+ onClick={(e) => {
509
+ e.stopPropagation();
510
+ }}
511
+ onKeyDown={(e) => {
512
+ e.stopPropagation();
382
513
  }}
383
- onClick={(e) => e.stopPropagation()}
384
514
  onMouseDown={(e) => {
385
515
  // 防止菜单聚焦丢失或页面滚动
386
516
  e.stopPropagation();
@@ -406,18 +536,31 @@ const createEmptyItem = (itemKey: string, t: (key: string) => string) => ({
406
536
  disabled: true,
407
537
  });
408
538
 
539
+ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
540
+ display: 'block',
541
+ width: '100%',
542
+ };
543
+
409
544
  // ==================== Main Component ====================
410
545
 
411
546
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
412
547
  const DROPDOWN_PERSIST_TTL_MS = 350;
548
+ const SUBMENU_CLOSE_DELAY = 0.05;
549
+ const SUBMENU_MOTION_DISABLED = {
550
+ motionEnter: false,
551
+ motionLeave: false,
552
+ };
413
553
  const dropdownPersistRegistry: Map<string, number> = new Map();
414
554
 
415
555
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
416
556
  const engine = useFlowEngine();
417
557
  const [menuVisible, setMenuVisible] = useState(false);
418
558
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
559
+ const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
419
560
  const [rootItems, setRootItems] = useState<Item[]>([]);
420
561
  const [rootLoading, setRootLoading] = useState(false);
562
+ const closeByOutsideClickRef = useRef(false);
563
+ const skipPreserveActiveSearchRef = useRef(false);
421
564
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
422
565
  const t = engine.translate.bind(engine);
423
566
 
@@ -432,10 +575,105 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
432
575
  openKeys,
433
576
  refreshKeys,
434
577
  );
435
- const { searchValues, isSearching, updateSearchValue } = useMenuSearch();
578
+ const searchHandlers = useMenuSearch();
579
+ const { searchValues, inputValues, clearSearchValue, clearAllSearchValues } = searchHandlers;
436
580
  const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
437
581
  useSubmenuStyles(menuVisible, dropdownMaxHeight);
438
582
 
583
+ const closeMenu = useCallback(() => {
584
+ setMenuVisible(false);
585
+ setActiveSearchKey(null);
586
+ setOpenKeys(new Set());
587
+ clearAllSearchValues();
588
+ }, [clearAllSearchValues]);
589
+
590
+ const activateSearchSubmenu = useCallback((key: string) => {
591
+ setActiveSearchKey(key);
592
+ setOpenKeys((prev) => {
593
+ if (prev.has(key)) return prev;
594
+ const next = new Set(prev);
595
+ next.add(key);
596
+ return next;
597
+ });
598
+ }, []);
599
+
600
+ const deactivateSearchSubmenu = useCallback((key: string) => {
601
+ setActiveSearchKey((prev) => (prev === key ? null : prev));
602
+ }, []);
603
+
604
+ const closeActiveSearchForPath = useCallback(
605
+ (keyPath: string) => {
606
+ if (
607
+ !activeSearchKey ||
608
+ keyPath === activeSearchKey ||
609
+ keyPath.startsWith(`${activeSearchKey}/`) ||
610
+ activeSearchKey.startsWith(`${keyPath}/`)
611
+ ) {
612
+ return;
613
+ }
614
+
615
+ skipPreserveActiveSearchRef.current = true;
616
+ clearSearchValue(activeSearchKey);
617
+ setActiveSearchKey(null);
618
+ setOpenKeys((prev) => {
619
+ const next = new Set(prev);
620
+ next.delete(activeSearchKey);
621
+ return next;
622
+ });
623
+ },
624
+ [activeSearchKey, clearSearchValue],
625
+ );
626
+
627
+ const handleMenuOpenChange = useCallback(
628
+ (nextOpenKeys: string[]) => {
629
+ let normalized = normalizeOpenKeys(nextOpenKeys);
630
+ if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
631
+ if (normalized.length || skipPreserveActiveSearchRef.current) {
632
+ clearSearchValue(activeSearchKey);
633
+ setActiveSearchKey(null);
634
+ } else {
635
+ normalized = [activeSearchKey];
636
+ }
637
+ }
638
+
639
+ if (!normalized.length && shouldPreventClose()) {
640
+ dropdownMenuProps.onOpenChange?.(Array.from(openKeys));
641
+ skipPreserveActiveSearchRef.current = false;
642
+ return;
643
+ }
644
+
645
+ Array.from(openKeys).forEach((key) => {
646
+ if (!normalized.includes(key)) {
647
+ clearSearchValue(key);
648
+ }
649
+ });
650
+ setOpenKeys(new Set(normalized));
651
+ dropdownMenuProps.onOpenChange?.(normalized);
652
+ skipPreserveActiveSearchRef.current = false;
653
+ },
654
+ [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
655
+ );
656
+
657
+ useEffect(() => {
658
+ if (!menuVisible) return;
659
+
660
+ const markOutsideClick = (event: MouseEvent | PointerEvent) => {
661
+ const target = event.target as HTMLElement | null;
662
+ const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
663
+ closeByOutsideClickRef.current = isOutside;
664
+ if (isOutside) {
665
+ closeMenu();
666
+ }
667
+ };
668
+
669
+ document.addEventListener('pointerdown', markOutsideClick, true);
670
+ document.addEventListener('mousedown', markOutsideClick, true);
671
+ return () => {
672
+ document.removeEventListener('pointerdown', markOutsideClick, true);
673
+ document.removeEventListener('mousedown', markOutsideClick, true);
674
+ };
675
+ }, [closeMenu, menuVisible]);
676
+
439
677
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
440
678
  useEffect(() => {
441
679
  if (!persistKey) return;
@@ -460,6 +698,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
460
698
  };
461
699
  }, [persistKey, menuVisible]);
462
700
 
701
+ useEffect(() => {
702
+ if (!menuVisible) {
703
+ setOpenKeys(new Set());
704
+ }
705
+ }, [menuVisible]);
706
+
463
707
  // 加载根 items,支持同步/异步函数
464
708
  useEffect(() => {
465
709
  const loadRootItems = async () => {
@@ -492,21 +736,17 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
492
736
  ): any[] {
493
737
  const searchKey = keyPath;
494
738
  const currentSearchValue = searchValues[searchKey] || '';
739
+ const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
495
740
 
496
741
  // 递归过滤:当 child 为分组时,会继续向下过滤其 children;
497
742
  // 仅保留自身匹配或存在匹配子项的分组。
498
743
  const filteredChildren = currentSearchValue
499
744
  ? (function deepFilter(items: Item[]): Item[] {
500
745
  const searchText = currentSearchValue.toLowerCase();
501
- const tryString = (v: any) => {
502
- if (!v) return '';
503
- return typeof v === 'string' ? v : String(v);
504
- };
505
746
  return items
506
747
  .map((child) => {
507
- const labelStr = tryString(child.label).toLowerCase();
508
- const selfMatch =
509
- labelStr.includes(searchText) || (child.key && String(child.key).toLowerCase().includes(searchText));
748
+ const labelStr = getLabelSearchText(child.label).toLowerCase();
749
+ const selfMatch = labelStr.includes(searchText);
510
750
  if (child.type === 'group' && Array.isArray(child.children)) {
511
751
  const nested = deepFilter(child.children);
512
752
  if (selfMatch || nested.length > 0) {
@@ -521,7 +761,16 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
521
761
  : children;
522
762
 
523
763
  const resolvedFiltered = resolve(filteredChildren, [...path, item.key]);
524
- const searchItem = createSearchItem(item, searchKey, currentSearchValue, menuVisible, t, updateSearchValue);
764
+ const searchItem = createSearchItem(
765
+ item,
766
+ searchKey,
767
+ currentInputValue,
768
+ menuVisible,
769
+ t,
770
+ searchHandlers,
771
+ activateSearchSubmenu,
772
+ deactivateSearchSubmenu,
773
+ );
525
774
  const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
526
775
 
527
776
  if (currentSearchValue && resolvedFiltered.length === 0) {
@@ -588,56 +837,75 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
588
837
  return { type: 'divider', key: keyPath };
589
838
  }
590
839
 
840
+ const label = typeof item.label === 'string' ? t(item.label) : item.label;
841
+
591
842
  // 非 group 的“子菜单”也支持本层级搜索:当 item.searchable = true 且存在 children 时
592
843
  if (item.searchable && children) {
593
844
  return {
594
- key: item.key,
595
- label: typeof item.label === 'string' ? t(item.label) : item.label,
845
+ key: keyPath,
846
+ label,
596
847
  onClick: (info: any) => {},
597
- onMouseEnter: () => {
598
- setOpenKeys((prev) => {
599
- if (prev.has(keyPath)) return prev;
600
- const next = new Set(prev);
601
- next.add(keyPath);
602
- return next;
603
- });
604
- },
848
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
605
849
  children: buildSearchChildren(children, item, keyPath, path, menuVisible, resolveItems),
606
850
  };
607
851
  }
608
852
 
853
+ const itemShouldKeepOpen = !children && (item.keepDropdownOpen ?? keepDropdownOpen ?? false);
854
+ const handleLeafClick = (info: any) => {
855
+ if (children) {
856
+ return;
857
+ }
858
+
859
+ if (itemShouldKeepOpen) {
860
+ requestKeepOpen();
861
+ }
862
+
863
+ const extendedInfo: ExtendedMenuInfo = {
864
+ ...info,
865
+ key: info?.key ?? keyPath,
866
+ keyPath: info?.keyPath ?? [keyPath],
867
+ item: info?.item || item,
868
+ originalItem: item,
869
+ keepDropdownOpen: itemShouldKeepOpen,
870
+ };
871
+
872
+ menu.onClick?.(extendedInfo);
873
+ };
874
+
609
875
  return {
610
876
  key: keyPath,
611
- label: typeof item.label === 'string' ? t(item.label) : item.label,
877
+ label: itemShouldKeepOpen ? (
878
+ <div
879
+ style={KEEP_OPEN_LABEL_STYLE}
880
+ onMouseDown={(event) => {
881
+ event.stopPropagation();
882
+ requestKeepOpen();
883
+ }}
884
+ onClick={(event) => {
885
+ event.stopPropagation();
886
+ handleLeafClick({
887
+ key: keyPath,
888
+ keyPath: [keyPath],
889
+ item,
890
+ domEvent: event,
891
+ });
892
+ }}
893
+ >
894
+ {label}
895
+ </div>
896
+ ) : (
897
+ label
898
+ ),
612
899
  onClick: (info: any) => {
613
- if (children) {
900
+ if (!itemShouldKeepOpen) handleLeafClick(info);
901
+ },
902
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
903
+ onMouseDown: () => {
904
+ if (!itemShouldKeepOpen) {
614
905
  return;
615
906
  }
616
907
 
617
- // 检查是否应该保持下拉菜单打开
618
- const itemShouldKeepOpen = item.keepDropdownOpen ?? keepDropdownOpen ?? false;
619
-
620
- // 如果需要保持菜单打开,请求保持打开状态
621
- if (itemShouldKeepOpen) {
622
- requestKeepOpen();
623
- }
624
-
625
- const extendedInfo: ExtendedMenuInfo = {
626
- ...info,
627
- item: info.item || item,
628
- originalItem: item,
629
- keepDropdownOpen: itemShouldKeepOpen,
630
- };
631
-
632
- menu.onClick?.(extendedInfo);
633
- },
634
- onMouseEnter: () => {
635
- setOpenKeys((prev) => {
636
- if (prev.has(keyPath)) return prev;
637
- const next = new Set(prev);
638
- next.add(keyPath);
639
- return next;
640
- });
908
+ requestKeepOpen();
641
909
  },
642
910
  children:
643
911
  children && children.length > 0
@@ -684,17 +952,20 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
684
952
  placement="bottomLeft"
685
953
  menu={{
686
954
  ...dropdownMenuProps,
955
+ openKeys: Array.from(openKeys),
687
956
  items: items,
957
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
958
+ motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
688
959
  onClick: () => {},
960
+ onOpenChange: handleMenuOpenChange,
689
961
  style: {
690
962
  maxHeight: dropdownMaxHeight,
691
963
  overflowY: 'auto',
692
964
  ...dropdownMenuProps?.style,
693
965
  },
694
966
  }}
695
- onOpenChange={(visible) => {
696
- // 阻止在搜索时关闭菜单
697
- if (!visible && isSearching) {
967
+ onOpenChange={(visible, info) => {
968
+ if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
698
969
  return;
699
970
  }
700
971
 
@@ -703,7 +974,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
703
974
  return;
704
975
  }
705
976
 
706
- setMenuVisible(visible);
977
+ if (!visible) {
978
+ closeMenu();
979
+ } else {
980
+ setMenuVisible(visible);
981
+ }
982
+ closeByOutsideClickRef.current = false;
707
983
  }}
708
984
  >
709
985
  {props.children}