@nocobase/flow-engine 2.1.0-beta.8 → 2.2.0-alpha.1

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 (215) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/FieldModelRenderer.js +2 -2
  4. package/lib/components/FlowModelRenderer.d.ts +3 -1
  5. package/lib/components/FlowModelRenderer.js +12 -6
  6. package/lib/components/FormItem.d.ts +6 -0
  7. package/lib/components/FormItem.js +11 -3
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  10. package/lib/components/dnd/gridDragPlanner.js +607 -19
  11. package/lib/components/dnd/index.d.ts +31 -2
  12. package/lib/components/dnd/index.js +244 -23
  13. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  15. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +152 -42
  17. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  19. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  20. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  23. package/lib/components/subModel/AddSubModelButton.js +12 -1
  24. package/lib/components/subModel/LazyDropdown.js +301 -52
  25. package/lib/components/subModel/index.d.ts +1 -0
  26. package/lib/components/subModel/index.js +19 -0
  27. package/lib/components/subModel/utils.d.ts +2 -1
  28. package/lib/components/subModel/utils.js +15 -5
  29. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  30. package/lib/components/variables/VariableHybridInput.js +499 -0
  31. package/lib/components/variables/index.d.ts +2 -0
  32. package/lib/components/variables/index.js +3 -0
  33. package/lib/data-source/index.d.ts +84 -0
  34. package/lib/data-source/index.js +269 -7
  35. package/lib/executor/FlowExecutor.js +6 -3
  36. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  37. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  38. package/lib/flow-registry/index.d.ts +1 -0
  39. package/lib/flow-registry/index.js +3 -1
  40. package/lib/flowContext.d.ts +9 -1
  41. package/lib/flowContext.js +77 -6
  42. package/lib/flowEngine.d.ts +136 -4
  43. package/lib/flowEngine.js +429 -51
  44. package/lib/flowI18n.js +2 -1
  45. package/lib/flowSettings.d.ts +14 -6
  46. package/lib/flowSettings.js +34 -6
  47. package/lib/index.d.ts +2 -0
  48. package/lib/index.js +7 -0
  49. package/lib/lazy-helper.d.ts +14 -0
  50. package/lib/lazy-helper.js +71 -0
  51. package/lib/locale/en-US.json +1 -0
  52. package/lib/locale/index.d.ts +2 -0
  53. package/lib/locale/zh-CN.json +1 -0
  54. package/lib/models/DisplayItemModel.d.ts +1 -1
  55. package/lib/models/EditableItemModel.d.ts +1 -1
  56. package/lib/models/FilterableItemModel.d.ts +1 -1
  57. package/lib/models/flowModel.d.ts +13 -10
  58. package/lib/models/flowModel.js +126 -34
  59. package/lib/provider.js +38 -23
  60. package/lib/reactive/observer.js +46 -16
  61. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  62. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  63. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  64. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  65. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  66. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  67. package/lib/runjs-context/contexts/base.js +464 -29
  68. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  69. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  70. package/lib/runjs-context/setup.js +1 -0
  71. package/lib/runjs-context/snippets/index.js +13 -2
  72. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  74. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  75. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  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/loadedPageCache.d.ts +24 -0
  82. package/lib/utils/loadedPageCache.js +139 -0
  83. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  84. package/lib/utils/parsePathnameToViewParams.js +28 -4
  85. package/lib/utils/randomId.d.ts +39 -0
  86. package/lib/utils/randomId.js +45 -0
  87. package/lib/utils/runjsTemplateCompat.js +1 -1
  88. package/lib/utils/runjsValue.js +41 -11
  89. package/lib/utils/schema-utils.d.ts +7 -1
  90. package/lib/utils/schema-utils.js +19 -0
  91. package/lib/views/FlowView.d.ts +7 -1
  92. package/lib/views/FlowView.js +11 -1
  93. package/lib/views/PageComponent.js +8 -6
  94. package/lib/views/ViewNavigation.d.ts +12 -2
  95. package/lib/views/ViewNavigation.js +28 -9
  96. package/lib/views/createViewMeta.js +114 -50
  97. package/lib/views/inheritLayoutContext.d.ts +10 -0
  98. package/lib/views/inheritLayoutContext.js +50 -0
  99. package/lib/views/runViewBeforeClose.d.ts +10 -0
  100. package/lib/views/runViewBeforeClose.js +45 -0
  101. package/lib/views/useDialog.d.ts +2 -1
  102. package/lib/views/useDialog.js +12 -3
  103. package/lib/views/useDrawer.d.ts +2 -1
  104. package/lib/views/useDrawer.js +12 -3
  105. package/lib/views/usePage.d.ts +5 -11
  106. package/lib/views/usePage.js +304 -144
  107. package/package.json +5 -4
  108. package/src/FlowContextProvider.tsx +9 -1
  109. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  110. package/src/__tests__/flow-engine.test.ts +166 -0
  111. package/src/__tests__/flowContext.test.ts +105 -1
  112. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  113. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  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 +21 -0
  120. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  121. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  122. package/src/__tests__/runjsLocales.test.ts +6 -5
  123. package/src/__tests__/runjsSnippets.test.ts +21 -0
  124. package/src/__tests__/viewScopedFlowEngine.test.ts +136 -3
  125. package/src/components/FieldModelRenderer.tsx +2 -1
  126. package/src/components/FlowModelRenderer.tsx +18 -6
  127. package/src/components/FormItem.tsx +7 -1
  128. package/src/components/MobilePopup.tsx +4 -2
  129. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  130. package/src/components/__tests__/FormItem.test.tsx +25 -0
  131. package/src/components/__tests__/dnd.test.ts +44 -0
  132. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  133. package/src/components/__tests__/gridDragPlanner.test.ts +472 -5
  134. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  135. package/src/components/dnd/gridDragPlanner.ts +750 -17
  136. package/src/components/dnd/index.tsx +305 -28
  137. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  138. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +178 -48
  139. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +344 -8
  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 +16 -2
  145. package/src/components/subModel/LazyDropdown.tsx +341 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +524 -38
  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 +13 -2
  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 +69 -2
  154. package/src/data-source/index.ts +332 -8
  155. package/src/executor/FlowExecutor.ts +6 -3
  156. package/src/executor/__tests__/flowExecutor.test.ts +57 -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 +85 -6
  161. package/src/flowEngine.ts +484 -45
  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__/flowEngine.resolveUse.test.ts +0 -15
  172. package/src/models/__tests__/flowModel.test.ts +65 -37
  173. package/src/models/flowModel.tsx +184 -65
  174. package/src/provider.tsx +41 -25
  175. package/src/reactive/__tests__/observer.test.tsx +82 -0
  176. package/src/reactive/observer.tsx +87 -25
  177. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  178. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  179. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  180. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  181. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  182. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  183. package/src/runjs-context/contexts/base.ts +467 -31
  184. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  185. package/src/runjs-context/setup.ts +1 -0
  186. package/src/runjs-context/snippets/index.ts +12 -1
  187. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  188. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  189. package/src/types.ts +62 -0
  190. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  191. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  192. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  193. package/src/utils/__tests__/utils.test.ts +62 -0
  194. package/src/utils/createCollectionContextMeta.ts +6 -2
  195. package/src/utils/index.ts +5 -1
  196. package/src/utils/loadedPageCache.ts +147 -0
  197. package/src/utils/parsePathnameToViewParams.ts +45 -5
  198. package/src/utils/randomId.ts +48 -0
  199. package/src/utils/runjsTemplateCompat.ts +1 -1
  200. package/src/utils/runjsValue.ts +50 -11
  201. package/src/utils/schema-utils.ts +30 -1
  202. package/src/views/FlowView.tsx +22 -2
  203. package/src/views/PageComponent.tsx +7 -4
  204. package/src/views/ViewNavigation.ts +46 -9
  205. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  206. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  207. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  208. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  209. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +12 -12
  210. package/src/views/createViewMeta.ts +106 -34
  211. package/src/views/inheritLayoutContext.ts +26 -0
  212. package/src/views/runViewBeforeClose.ts +19 -0
  213. package/src/views/useDialog.tsx +13 -3
  214. package/src/views/useDrawer.tsx +13 -3
  215. package/src/views/usePage.tsx +367 -180
@@ -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,102 @@ 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,
466
+ shouldActivateSearchSubmenu: boolean,
368
467
  ) => ({
369
468
  key: `${searchKey}-search`,
370
469
  type: 'group' as const,
371
470
  label: (
372
- <div>
471
+ <div onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
373
472
  <SearchInputWithAutoFocus
374
473
  visible={menuVisible}
375
474
  variant="borderless"
376
475
  allowClear
377
476
  placeholder={t(item.searchPlaceholder || 'Search')}
378
477
  value={currentSearchValue}
478
+ onFocus={(e) => {
479
+ e.stopPropagation();
480
+ }}
379
481
  onChange={(e) => {
380
482
  e.stopPropagation();
381
- updateSearchValue(searchKey, e.target.value);
483
+ const value = e.target.value;
484
+ if (shouldActivateSearchSubmenu) {
485
+ activateSearchSubmenu(searchKey);
486
+ }
487
+ if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
488
+ searchHandlers.updateInputValue(searchKey, value);
489
+ return;
490
+ }
491
+ if (!value && shouldActivateSearchSubmenu) {
492
+ deactivateSearchSubmenu(searchKey);
493
+ }
494
+ searchHandlers.updateSearchValue(searchKey, value);
495
+ }}
496
+ onCompositionStart={(e) => {
497
+ e.stopPropagation();
498
+ if (shouldActivateSearchSubmenu) {
499
+ activateSearchSubmenu(searchKey);
500
+ }
501
+ searchHandlers.startComposition(searchKey);
502
+ }}
503
+ onCompositionEnd={(e) => {
504
+ e.stopPropagation();
505
+ const value = e.currentTarget.value;
506
+ if (shouldActivateSearchSubmenu) {
507
+ if (value) {
508
+ activateSearchSubmenu(searchKey);
509
+ } else {
510
+ deactivateSearchSubmenu(searchKey);
511
+ }
512
+ }
513
+ searchHandlers.endComposition(searchKey, value);
514
+ }}
515
+ onClick={(e) => {
516
+ e.stopPropagation();
517
+ }}
518
+ onKeyDown={(e) => {
519
+ e.stopPropagation();
382
520
  }}
383
- onClick={(e) => e.stopPropagation()}
384
521
  onMouseDown={(e) => {
385
522
  // 防止菜单聚焦丢失或页面滚动
386
523
  e.stopPropagation();
@@ -406,18 +543,31 @@ const createEmptyItem = (itemKey: string, t: (key: string) => string) => ({
406
543
  disabled: true,
407
544
  });
408
545
 
546
+ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
547
+ display: 'block',
548
+ width: '100%',
549
+ };
550
+
409
551
  // ==================== Main Component ====================
410
552
 
411
553
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
412
554
  const DROPDOWN_PERSIST_TTL_MS = 350;
555
+ const SUBMENU_CLOSE_DELAY = 0.05;
556
+ const SUBMENU_MOTION_DISABLED = {
557
+ motionEnter: false,
558
+ motionLeave: false,
559
+ };
413
560
  const dropdownPersistRegistry: Map<string, number> = new Map();
414
561
 
415
562
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
416
563
  const engine = useFlowEngine();
417
564
  const [menuVisible, setMenuVisible] = useState(false);
418
565
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
566
+ const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
419
567
  const [rootItems, setRootItems] = useState<Item[]>([]);
420
568
  const [rootLoading, setRootLoading] = useState(false);
569
+ const closeByOutsideClickRef = useRef(false);
570
+ const skipPreserveActiveSearchRef = useRef(false);
421
571
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
422
572
  const t = engine.translate.bind(engine);
423
573
 
@@ -432,10 +582,105 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
432
582
  openKeys,
433
583
  refreshKeys,
434
584
  );
435
- const { searchValues, isSearching, updateSearchValue } = useMenuSearch();
585
+ const searchHandlers = useMenuSearch();
586
+ const { searchValues, inputValues, clearSearchValue, clearAllSearchValues } = searchHandlers;
436
587
  const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
437
588
  useSubmenuStyles(menuVisible, dropdownMaxHeight);
438
589
 
590
+ const closeMenu = useCallback(() => {
591
+ setMenuVisible(false);
592
+ setActiveSearchKey(null);
593
+ setOpenKeys(new Set());
594
+ clearAllSearchValues();
595
+ }, [clearAllSearchValues]);
596
+
597
+ const activateSearchSubmenu = useCallback((key: string) => {
598
+ setActiveSearchKey(key);
599
+ setOpenKeys((prev) => {
600
+ if (prev.has(key)) return prev;
601
+ const next = new Set(prev);
602
+ next.add(key);
603
+ return next;
604
+ });
605
+ }, []);
606
+
607
+ const deactivateSearchSubmenu = useCallback((key: string) => {
608
+ setActiveSearchKey((prev) => (prev === key ? null : prev));
609
+ }, []);
610
+
611
+ const closeActiveSearchForPath = useCallback(
612
+ (keyPath: string) => {
613
+ if (
614
+ !activeSearchKey ||
615
+ keyPath === activeSearchKey ||
616
+ keyPath.startsWith(`${activeSearchKey}/`) ||
617
+ activeSearchKey.startsWith(`${keyPath}/`)
618
+ ) {
619
+ return;
620
+ }
621
+
622
+ skipPreserveActiveSearchRef.current = true;
623
+ clearSearchValue(activeSearchKey);
624
+ setActiveSearchKey(null);
625
+ setOpenKeys((prev) => {
626
+ const next = new Set(prev);
627
+ next.delete(activeSearchKey);
628
+ return next;
629
+ });
630
+ },
631
+ [activeSearchKey, clearSearchValue],
632
+ );
633
+
634
+ const handleMenuOpenChange = useCallback(
635
+ (nextOpenKeys: string[]) => {
636
+ let normalized = normalizeOpenKeys(nextOpenKeys);
637
+ if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
638
+ if (normalized.length || skipPreserveActiveSearchRef.current) {
639
+ clearSearchValue(activeSearchKey);
640
+ setActiveSearchKey(null);
641
+ } else {
642
+ normalized = [activeSearchKey];
643
+ }
644
+ }
645
+
646
+ if (!normalized.length && shouldPreventClose()) {
647
+ dropdownMenuProps.onOpenChange?.(Array.from(openKeys));
648
+ skipPreserveActiveSearchRef.current = false;
649
+ return;
650
+ }
651
+
652
+ Array.from(openKeys).forEach((key) => {
653
+ if (!normalized.includes(key)) {
654
+ clearSearchValue(key);
655
+ }
656
+ });
657
+ setOpenKeys(new Set(normalized));
658
+ dropdownMenuProps.onOpenChange?.(normalized);
659
+ skipPreserveActiveSearchRef.current = false;
660
+ },
661
+ [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
662
+ );
663
+
664
+ useEffect(() => {
665
+ if (!menuVisible) return;
666
+
667
+ const markOutsideClick = (event: MouseEvent | PointerEvent) => {
668
+ const target = event.target as HTMLElement | null;
669
+ const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
670
+ closeByOutsideClickRef.current = isOutside;
671
+ if (isOutside) {
672
+ closeMenu();
673
+ }
674
+ };
675
+
676
+ document.addEventListener('pointerdown', markOutsideClick, true);
677
+ document.addEventListener('mousedown', markOutsideClick, true);
678
+ return () => {
679
+ document.removeEventListener('pointerdown', markOutsideClick, true);
680
+ document.removeEventListener('mousedown', markOutsideClick, true);
681
+ };
682
+ }, [closeMenu, menuVisible]);
683
+
439
684
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
440
685
  useEffect(() => {
441
686
  if (!persistKey) return;
@@ -460,6 +705,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
460
705
  };
461
706
  }, [persistKey, menuVisible]);
462
707
 
708
+ useEffect(() => {
709
+ if (!menuVisible) {
710
+ setOpenKeys(new Set());
711
+ }
712
+ }, [menuVisible]);
713
+
463
714
  // 加载根 items,支持同步/异步函数
464
715
  useEffect(() => {
465
716
  const loadRootItems = async () => {
@@ -492,21 +743,18 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
492
743
  ): any[] {
493
744
  const searchKey = keyPath;
494
745
  const currentSearchValue = searchValues[searchKey] || '';
746
+ const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
747
+ const shouldActivateSearchSubmenu = !(item.type === 'group' && path.length === 0);
495
748
 
496
749
  // 递归过滤:当 child 为分组时,会继续向下过滤其 children;
497
750
  // 仅保留自身匹配或存在匹配子项的分组。
498
751
  const filteredChildren = currentSearchValue
499
752
  ? (function deepFilter(items: Item[]): Item[] {
500
753
  const searchText = currentSearchValue.toLowerCase();
501
- const tryString = (v: any) => {
502
- if (!v) return '';
503
- return typeof v === 'string' ? v : String(v);
504
- };
505
754
  return items
506
755
  .map((child) => {
507
- const labelStr = tryString(child.label).toLowerCase();
508
- const selfMatch =
509
- labelStr.includes(searchText) || (child.key && String(child.key).toLowerCase().includes(searchText));
756
+ const labelStr = getLabelSearchText(child.label).toLowerCase();
757
+ const selfMatch = labelStr.includes(searchText);
510
758
  if (child.type === 'group' && Array.isArray(child.children)) {
511
759
  const nested = deepFilter(child.children);
512
760
  if (selfMatch || nested.length > 0) {
@@ -521,7 +769,17 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
521
769
  : children;
522
770
 
523
771
  const resolvedFiltered = resolve(filteredChildren, [...path, item.key]);
524
- const searchItem = createSearchItem(item, searchKey, currentSearchValue, menuVisible, t, updateSearchValue);
772
+ const searchItem = createSearchItem(
773
+ item,
774
+ searchKey,
775
+ currentInputValue,
776
+ menuVisible,
777
+ t,
778
+ searchHandlers,
779
+ activateSearchSubmenu,
780
+ deactivateSearchSubmenu,
781
+ shouldActivateSearchSubmenu,
782
+ );
525
783
  const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
526
784
 
527
785
  if (currentSearchValue && resolvedFiltered.length === 0) {
@@ -588,56 +846,75 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
588
846
  return { type: 'divider', key: keyPath };
589
847
  }
590
848
 
849
+ const label = typeof item.label === 'string' ? t(item.label) : item.label;
850
+
591
851
  // 非 group 的“子菜单”也支持本层级搜索:当 item.searchable = true 且存在 children 时
592
852
  if (item.searchable && children) {
593
853
  return {
594
- key: item.key,
595
- label: typeof item.label === 'string' ? t(item.label) : item.label,
854
+ key: keyPath,
855
+ label,
596
856
  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
- },
857
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
605
858
  children: buildSearchChildren(children, item, keyPath, path, menuVisible, resolveItems),
606
859
  };
607
860
  }
608
861
 
862
+ const itemShouldKeepOpen = !children && (item.keepDropdownOpen ?? keepDropdownOpen ?? false);
863
+ const handleLeafClick = (info: any) => {
864
+ if (children) {
865
+ return;
866
+ }
867
+
868
+ if (itemShouldKeepOpen) {
869
+ requestKeepOpen();
870
+ }
871
+
872
+ const extendedInfo: ExtendedMenuInfo = {
873
+ ...info,
874
+ key: info?.key ?? keyPath,
875
+ keyPath: info?.keyPath ?? [keyPath],
876
+ item: info?.item || item,
877
+ originalItem: item,
878
+ keepDropdownOpen: itemShouldKeepOpen,
879
+ };
880
+
881
+ menu.onClick?.(extendedInfo);
882
+ };
883
+
609
884
  return {
610
885
  key: keyPath,
611
- label: typeof item.label === 'string' ? t(item.label) : item.label,
886
+ label: itemShouldKeepOpen ? (
887
+ <div
888
+ style={KEEP_OPEN_LABEL_STYLE}
889
+ onMouseDown={(event) => {
890
+ event.stopPropagation();
891
+ requestKeepOpen();
892
+ }}
893
+ onClick={(event) => {
894
+ event.stopPropagation();
895
+ handleLeafClick({
896
+ key: keyPath,
897
+ keyPath: [keyPath],
898
+ item,
899
+ domEvent: event,
900
+ });
901
+ }}
902
+ >
903
+ {label}
904
+ </div>
905
+ ) : (
906
+ label
907
+ ),
612
908
  onClick: (info: any) => {
613
- if (children) {
909
+ if (!itemShouldKeepOpen) handleLeafClick(info);
910
+ },
911
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
912
+ onMouseDown: () => {
913
+ if (!itemShouldKeepOpen) {
614
914
  return;
615
915
  }
616
916
 
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
- });
917
+ requestKeepOpen();
641
918
  },
642
919
  children:
643
920
  children && children.length > 0
@@ -684,17 +961,20 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
684
961
  placement="bottomLeft"
685
962
  menu={{
686
963
  ...dropdownMenuProps,
964
+ openKeys: Array.from(openKeys),
687
965
  items: items,
966
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
967
+ motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
688
968
  onClick: () => {},
969
+ onOpenChange: handleMenuOpenChange,
689
970
  style: {
690
971
  maxHeight: dropdownMaxHeight,
691
972
  overflowY: 'auto',
692
973
  ...dropdownMenuProps?.style,
693
974
  },
694
975
  }}
695
- onOpenChange={(visible) => {
696
- // 阻止在搜索时关闭菜单
697
- if (!visible && isSearching) {
976
+ onOpenChange={(visible, info) => {
977
+ if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
698
978
  return;
699
979
  }
700
980
 
@@ -703,7 +983,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
703
983
  return;
704
984
  }
705
985
 
706
- setMenuVisible(visible);
986
+ if (!visible) {
987
+ closeMenu();
988
+ } else {
989
+ setMenuVisible(visible);
990
+ }
991
+ closeByOutsideClickRef.current = false;
707
992
  }}
708
993
  >
709
994
  {props.children}