@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.46

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 (77) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +84 -32
  4. package/lib/components/subModel/LazyDropdown.js +208 -16
  5. package/lib/components/subModel/utils.d.ts +1 -0
  6. package/lib/components/subModel/utils.js +6 -2
  7. package/lib/data-source/index.d.ts +9 -0
  8. package/lib/data-source/index.js +12 -0
  9. package/lib/executor/FlowExecutor.js +0 -3
  10. package/lib/flowContext.d.ts +6 -1
  11. package/lib/flowContext.js +38 -6
  12. package/lib/flowEngine.d.ts +4 -3
  13. package/lib/flowEngine.js +72 -40
  14. package/lib/models/flowModel.js +48 -16
  15. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  16. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  17. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  18. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  19. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  20. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  21. package/lib/runjs-context/contexts/base.js +464 -29
  22. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  23. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  24. package/lib/utils/loadedPageCache.d.ts +24 -0
  25. package/lib/utils/loadedPageCache.js +139 -0
  26. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  27. package/lib/utils/parsePathnameToViewParams.js +28 -4
  28. package/lib/views/ViewNavigation.d.ts +12 -2
  29. package/lib/views/ViewNavigation.js +22 -7
  30. package/lib/views/createViewMeta.js +114 -50
  31. package/lib/views/inheritLayoutContext.d.ts +10 -0
  32. package/lib/views/inheritLayoutContext.js +50 -0
  33. package/lib/views/useDialog.js +2 -0
  34. package/lib/views/useDrawer.js +2 -0
  35. package/lib/views/usePage.js +2 -0
  36. package/package.json +4 -4
  37. package/src/FlowContextProvider.tsx +9 -1
  38. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  39. package/src/__tests__/flowContext.test.ts +23 -0
  40. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  41. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  42. package/src/__tests__/runjsContext.test.ts +18 -0
  43. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  44. package/src/__tests__/runjsLocales.test.ts +6 -5
  45. package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
  46. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +90 -38
  47. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +155 -5
  48. package/src/components/subModel/LazyDropdown.tsx +237 -16
  49. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +254 -1
  50. package/src/components/subModel/utils.ts +6 -1
  51. package/src/data-source/index.ts +18 -0
  52. package/src/executor/FlowExecutor.ts +0 -3
  53. package/src/executor/__tests__/flowExecutor.test.ts +26 -0
  54. package/src/flowContext.ts +43 -6
  55. package/src/flowEngine.ts +75 -38
  56. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  57. package/src/models/__tests__/flowModel.test.ts +46 -62
  58. package/src/models/flowModel.tsx +65 -32
  59. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  60. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  61. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  62. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  63. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  64. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  65. package/src/runjs-context/contexts/base.ts +467 -31
  66. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  67. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  68. package/src/utils/loadedPageCache.ts +147 -0
  69. package/src/utils/parsePathnameToViewParams.ts +45 -5
  70. package/src/views/ViewNavigation.ts +40 -7
  71. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  72. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  73. package/src/views/createViewMeta.ts +106 -34
  74. package/src/views/inheritLayoutContext.ts +26 -0
  75. package/src/views/useDialog.tsx +2 -0
  76. package/src/views/useDrawer.tsx +2 -0
  77. package/src/views/usePage.tsx +2 -0
@@ -7,10 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React from 'react';
11
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import { render, cleanup, waitFor, act } from '@testing-library/react';
10
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
13
11
  import { App, ConfigProvider } from 'antd';
12
+ import React from 'react';
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
14
 
15
15
  import { FlowEngine } from '../../../../../flowEngine';
16
16
  import { FlowModel } from '../../../../../models/flowModel';
@@ -141,6 +141,86 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
141
141
  vi.clearAllMocks();
142
142
  });
143
143
 
144
+ it('defers nested configurable step resolution and clears stale config while closed', async () => {
145
+ class TestFlowModel extends FlowModel {}
146
+
147
+ const engine = new FlowEngine();
148
+ const model = new TestFlowModel({ uid: 'model-lazy-settings', flowEngine: engine });
149
+ const hideInSettings = vi.fn((ctx) => !!ctx.getStepParams('general')?.hidden);
150
+ const uiSchema = vi.fn(() => ({
151
+ field: { type: 'string', 'x-component': 'Input' },
152
+ }));
153
+
154
+ TestFlowModel.registerFlow({
155
+ key: 'lazyFlow',
156
+ title: 'Lazy Flow',
157
+ steps: {
158
+ general: {
159
+ title: 'General',
160
+ hideInSettings,
161
+ uiSchema,
162
+ },
163
+ },
164
+ });
165
+
166
+ const { getByLabelText } = render(
167
+ React.createElement(
168
+ ConfigProvider as any,
169
+ null,
170
+ React.createElement(
171
+ App as any,
172
+ null,
173
+ React.createElement(DefaultSettingsIcon as any, { model, menuLevels: 2 }),
174
+ ),
175
+ ),
176
+ );
177
+
178
+ expect(getByLabelText('flows-settings')).toBeTruthy();
179
+ expect(hideInSettings).not.toHaveBeenCalled();
180
+ expect(uiSchema).not.toHaveBeenCalled();
181
+
182
+ await act(async () => {
183
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
184
+ });
185
+
186
+ await waitFor(() => {
187
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
188
+ expect(uiSchema).toHaveBeenCalledTimes(1);
189
+ const menu = (globalThis as any).__lastDropdownMenu;
190
+ const items = (menu?.items || []) as any[];
191
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(true);
192
+ });
193
+
194
+ await act(async () => {
195
+ (globalThis as any).__lastDropdownOnOpenChange?.(false, { source: 'trigger' });
196
+ });
197
+
198
+ await waitFor(() => {
199
+ const menu = (globalThis as any).__lastDropdownMenu;
200
+ const items = (menu?.items || []) as any[];
201
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
202
+ });
203
+
204
+ await act(async () => {
205
+ model.setStepParams('lazyFlow', 'general', { hidden: true });
206
+ });
207
+
208
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
209
+ expect(uiSchema).toHaveBeenCalledTimes(1);
210
+
211
+ await act(async () => {
212
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
213
+ });
214
+
215
+ await waitFor(() => {
216
+ expect(hideInSettings).toHaveBeenCalledTimes(2);
217
+ const menu = (globalThis as any).__lastDropdownMenu;
218
+ const items = (menu?.items || []) as any[];
219
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
220
+ });
221
+ expect(uiSchema).toHaveBeenCalledTimes(1);
222
+ });
223
+
144
224
  it('excludes instance (dynamic) flows from the settings menu', async () => {
145
225
  class TestFlowModel extends FlowModel {}
146
226
 
@@ -720,6 +800,10 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
720
800
  ),
721
801
  );
722
802
 
803
+ await act(async () => {
804
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
805
+ });
806
+
723
807
  await waitFor(() => {
724
808
  const menu = (globalThis as any).__lastDropdownMenu;
725
809
  expect(menu).toBeTruthy();
@@ -800,14 +884,16 @@ describe('DefaultSettingsIcon - extra menu items', () => {
800
884
  await waitFor(() => {
801
885
  const menu = (globalThis as any).__lastDropdownMenu;
802
886
  const items = (menu?.items || []) as any[];
803
- expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
887
+ const extraActionItem = items.find((it) => String(it.key || '') === 'extra-action');
888
+ expect(extraActionItem).toBeTruthy();
889
+ expect(extraActionItem.onClick).toBeUndefined();
804
890
  });
805
891
 
806
892
  const menu = (globalThis as any).__lastDropdownMenu;
807
893
  await act(async () => {
808
894
  menu.onClick?.({ key: 'extra-action' });
809
895
  });
810
- expect(onClick).toHaveBeenCalled();
896
+ expect(onClick).toHaveBeenCalledTimes(1);
811
897
  expect((globalThis as any).__lastDropdownOpen).toBe(false);
812
898
  } finally {
813
899
  dispose?.();
@@ -880,6 +966,7 @@ describe('DefaultSettingsIcon - extra menu items', () => {
880
966
  'insert-after',
881
967
  'insert-inner',
882
968
  ]);
969
+ expect((nested.children || []).find((it) => String(it.key || '') === 'insert-before')?.onClick).toBeUndefined();
883
970
  expect((nested.children || []).find((it) => String(it.key || '') === 'insert-inner')?.disabled).toBe(true);
884
971
  });
885
972
 
@@ -900,4 +987,67 @@ describe('DefaultSettingsIcon - extra menu items', () => {
900
987
  dispose?.();
901
988
  }
902
989
  });
990
+
991
+ it('uses common extra actions to defer nested configurable step resolution', async () => {
992
+ const onClick = vi.fn();
993
+
994
+ class TestFlowModel extends FlowModel {}
995
+ const dispose = TestFlowModel.registerExtraMenuItems({
996
+ group: 'common-actions',
997
+ sort: 10,
998
+ items: [{ key: 'extra-action', label: 'Extra Action', onClick }],
999
+ });
1000
+
1001
+ const engine = new FlowEngine();
1002
+ const model = new TestFlowModel({ uid: 'm-extra-lazy', flowEngine: engine });
1003
+ const uiSchema = vi.fn(() => ({
1004
+ f: { type: 'string', 'x-component': 'Input' },
1005
+ }));
1006
+
1007
+ TestFlowModel.registerFlow({
1008
+ key: 'flow',
1009
+ title: 'Flow',
1010
+ steps: { s: { title: 'S', uiSchema } },
1011
+ });
1012
+
1013
+ try {
1014
+ const { getByLabelText } = render(
1015
+ React.createElement(
1016
+ ConfigProvider as any,
1017
+ null,
1018
+ React.createElement(
1019
+ App as any,
1020
+ null,
1021
+ React.createElement(DefaultSettingsIcon as any, {
1022
+ model,
1023
+ menuLevels: 2,
1024
+ showCopyUidButton: false,
1025
+ showDeleteButton: false,
1026
+ }),
1027
+ ),
1028
+ ),
1029
+ );
1030
+
1031
+ await waitFor(() => {
1032
+ expect(getByLabelText('flows-settings')).toBeTruthy();
1033
+ const menu = (globalThis as any).__lastDropdownMenu;
1034
+ const items = (menu?.items || []) as any[];
1035
+ expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
1036
+ });
1037
+ expect(uiSchema).not.toHaveBeenCalled();
1038
+
1039
+ await act(async () => {
1040
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
1041
+ });
1042
+
1043
+ await waitFor(() => {
1044
+ expect(uiSchema).toHaveBeenCalledTimes(1);
1045
+ const menu = (globalThis as any).__lastDropdownMenu;
1046
+ const items = (menu?.items || []) as any[];
1047
+ expect(items.some((it) => String(it.key || '') === 'flow:s')).toBe(true);
1048
+ });
1049
+ } finally {
1050
+ dispose?.();
1051
+ }
1052
+ });
903
1053
  });
@@ -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
 
@@ -390,23 +454,70 @@ const createSearchItem = (
390
454
  currentSearchValue: string,
391
455
  menuVisible: boolean,
392
456
  t: (key: string) => string,
393
- 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,
394
467
  ) => ({
395
468
  key: `${searchKey}-search`,
396
469
  type: 'group' as const,
397
470
  label: (
398
- <div>
471
+ <div onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
399
472
  <SearchInputWithAutoFocus
400
473
  visible={menuVisible}
401
474
  variant="borderless"
402
475
  allowClear
403
476
  placeholder={t(item.searchPlaceholder || 'Search')}
404
477
  value={currentSearchValue}
478
+ onFocus={(e) => {
479
+ e.stopPropagation();
480
+ }}
405
481
  onChange={(e) => {
406
482
  e.stopPropagation();
407
- 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();
408
520
  }}
409
- onClick={(e) => e.stopPropagation()}
410
521
  onMouseDown={(e) => {
411
522
  // 防止菜单聚焦丢失或页面滚动
412
523
  e.stopPropagation();
@@ -441,14 +552,22 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
441
552
 
442
553
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
443
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
+ };
444
560
  const dropdownPersistRegistry: Map<string, number> = new Map();
445
561
 
446
562
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
447
563
  const engine = useFlowEngine();
448
564
  const [menuVisible, setMenuVisible] = useState(false);
449
565
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
566
+ const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
450
567
  const [rootItems, setRootItems] = useState<Item[]>([]);
451
568
  const [rootLoading, setRootLoading] = useState(false);
569
+ const closeByOutsideClickRef = useRef(false);
570
+ const skipPreserveActiveSearchRef = useRef(false);
452
571
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
453
572
  const t = engine.translate.bind(engine);
454
573
 
@@ -463,23 +582,105 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
463
582
  openKeys,
464
583
  refreshKeys,
465
584
  );
466
- const { searchValues, isSearching, updateSearchValue } = useMenuSearch();
585
+ const searchHandlers = useMenuSearch();
586
+ const { searchValues, inputValues, clearSearchValue, clearAllSearchValues } = searchHandlers;
467
587
  const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
468
588
  useSubmenuStyles(menuVisible, dropdownMaxHeight);
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
+
469
634
  const handleMenuOpenChange = useCallback(
470
635
  (nextOpenKeys: string[]) => {
471
- if (!nextOpenKeys.length && shouldPreventClose()) {
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()) {
472
647
  dropdownMenuProps.onOpenChange?.(Array.from(openKeys));
648
+ skipPreserveActiveSearchRef.current = false;
473
649
  return;
474
650
  }
475
651
 
476
- const normalized = normalizeOpenKeys(nextOpenKeys);
652
+ Array.from(openKeys).forEach((key) => {
653
+ if (!normalized.includes(key)) {
654
+ clearSearchValue(key);
655
+ }
656
+ });
477
657
  setOpenKeys(new Set(normalized));
478
658
  dropdownMenuProps.onOpenChange?.(normalized);
659
+ skipPreserveActiveSearchRef.current = false;
479
660
  },
480
- [dropdownMenuProps, openKeys, shouldPreventClose],
661
+ [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
481
662
  );
482
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
+
483
684
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
484
685
  useEffect(() => {
485
686
  if (!persistKey) return;
@@ -542,6 +743,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
542
743
  ): any[] {
543
744
  const searchKey = keyPath;
544
745
  const currentSearchValue = searchValues[searchKey] || '';
746
+ const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
747
+ const shouldActivateSearchSubmenu = !(item.type === 'group' && path.length === 0);
545
748
 
546
749
  // 递归过滤:当 child 为分组时,会继续向下过滤其 children;
547
750
  // 仅保留自身匹配或存在匹配子项的分组。
@@ -566,7 +769,17 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
566
769
  : children;
567
770
 
568
771
  const resolvedFiltered = resolve(filteredChildren, [...path, item.key]);
569
- 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
+ );
570
783
  const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
571
784
 
572
785
  if (currentSearchValue && resolvedFiltered.length === 0) {
@@ -641,6 +854,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
641
854
  key: keyPath,
642
855
  label,
643
856
  onClick: (info: any) => {},
857
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
644
858
  children: buildSearchChildren(children, item, keyPath, path, menuVisible, resolveItems),
645
859
  };
646
860
  }
@@ -694,6 +908,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
694
908
  onClick: (info: any) => {
695
909
  if (!itemShouldKeepOpen) handleLeafClick(info);
696
910
  },
911
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
697
912
  onMouseDown: () => {
698
913
  if (!itemShouldKeepOpen) {
699
914
  return;
@@ -748,6 +963,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
748
963
  ...dropdownMenuProps,
749
964
  openKeys: Array.from(openKeys),
750
965
  items: items,
966
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
967
+ motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
751
968
  onClick: () => {},
752
969
  onOpenChange: handleMenuOpenChange,
753
970
  style: {
@@ -756,9 +973,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
756
973
  ...dropdownMenuProps?.style,
757
974
  },
758
975
  }}
759
- onOpenChange={(visible) => {
760
- // 阻止在搜索时关闭菜单
761
- if (!visible && isSearching) {
976
+ onOpenChange={(visible, info) => {
977
+ if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
762
978
  return;
763
979
  }
764
980
 
@@ -767,7 +983,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
767
983
  return;
768
984
  }
769
985
 
770
- setMenuVisible(visible);
986
+ if (!visible) {
987
+ closeMenu();
988
+ } else {
989
+ setMenuVisible(visible);
990
+ }
991
+ closeByOutsideClickRef.current = false;
771
992
  }}
772
993
  >
773
994
  {props.children}