@baishuyun/ai-design 1.0.1 → 1.0.3

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 (111) hide show
  1. package/.turbo/turbo-build.log +16 -14
  2. package/CHANGELOG.md +15 -0
  3. package/dist/ai-design.js +6255 -5314
  4. package/dist/ai-design.js.map +1 -1
  5. package/dist/ai-design.umd.cjs +67 -67
  6. package/dist/ai-design.umd.cjs.map +1 -1
  7. package/dist/src/components/agent-scroll-selector.d.ts +2 -1
  8. package/dist/src/components/agent-scroll-selector.d.ts.map +1 -1
  9. package/dist/src/components/bs-entry-select/app-group-folder.d.ts +6 -0
  10. package/dist/src/components/bs-entry-select/app-group-folder.d.ts.map +1 -0
  11. package/dist/src/components/bs-entry-select/bs-entry-select.d.ts +4 -0
  12. package/dist/src/components/bs-entry-select/bs-entry-select.d.ts.map +1 -0
  13. package/dist/src/components/bs-entry-select/build-tree.d.ts +9 -0
  14. package/dist/src/components/bs-entry-select/build-tree.d.ts.map +1 -0
  15. package/dist/src/components/bs-entry-select/entry-item.d.ts +8 -0
  16. package/dist/src/components/bs-entry-select/entry-item.d.ts.map +1 -0
  17. package/dist/src/components/bs-entry-select/entry-popover.d.ts +17 -0
  18. package/dist/src/components/bs-entry-select/entry-popover.d.ts.map +1 -0
  19. package/dist/src/components/bs-entry-select/index.d.ts +3 -0
  20. package/dist/src/components/bs-entry-select/index.d.ts.map +1 -0
  21. package/dist/src/components/bs-entry-select/other-app-section.d.ts +11 -0
  22. package/dist/src/components/bs-entry-select/other-app-section.d.ts.map +1 -0
  23. package/dist/src/components/bs-entry-select/scroll-slider.d.ts +10 -0
  24. package/dist/src/components/bs-entry-select/scroll-slider.d.ts.map +1 -0
  25. package/dist/src/components/bs-entry-select/search-input.d.ts +5 -0
  26. package/dist/src/components/bs-entry-select/search-input.d.ts.map +1 -0
  27. package/dist/src/components/bs-entry-select/tree-item-node.d.ts +9 -0
  28. package/dist/src/components/bs-entry-select/tree-item-node.d.ts.map +1 -0
  29. package/dist/src/components/{bs-entry-select.d.ts → bs-entry-select/types.d.ts} +12 -2
  30. package/dist/src/components/bs-entry-select/types.d.ts.map +1 -0
  31. package/dist/src/components/bs-icons.d.ts +2 -0
  32. package/dist/src/components/bs-icons.d.ts.map +1 -1
  33. package/dist/src/components/collapsible.d.ts +6 -0
  34. package/dist/src/components/collapsible.d.ts.map +1 -0
  35. package/dist/src/components/field-checker-list-msg.d.ts +19 -0
  36. package/dist/src/components/field-checker-list-msg.d.ts.map +1 -0
  37. package/dist/src/components/field-checker.d.ts +19 -0
  38. package/dist/src/components/field-checker.d.ts.map +1 -0
  39. package/dist/src/components/field-icon.d.ts +5 -0
  40. package/dist/src/components/field-icon.d.ts.map +1 -0
  41. package/dist/src/components/field-value-checker.d.ts +16 -0
  42. package/dist/src/components/field-value-checker.d.ts.map +1 -0
  43. package/dist/src/components/legacy-slot.d.ts.map +1 -1
  44. package/dist/src/components/office-container.d.ts +5 -0
  45. package/dist/src/components/office-container.d.ts.map +1 -1
  46. package/dist/src/components/subform-fields-value-checker.d.ts +22 -0
  47. package/dist/src/components/subform-fields-value-checker.d.ts.map +1 -0
  48. package/dist/src/const/ui.d.ts +3 -0
  49. package/dist/src/const/ui.d.ts.map +1 -1
  50. package/dist/src/hooks/use-column-appear.d.ts +22 -0
  51. package/dist/src/hooks/use-column-appear.d.ts.map +1 -0
  52. package/dist/src/hooks/use-resizable-width.d.ts +3 -1
  53. package/dist/src/hooks/use-resizable-width.d.ts.map +1 -1
  54. package/dist/src/index.d.ts +11 -2
  55. package/dist/src/index.d.ts.map +1 -1
  56. package/dist/src/lib/utils.d.ts +3 -0
  57. package/dist/src/lib/utils.d.ts.map +1 -1
  58. package/dist/src/stories/BsEntrySelect.stories.d.ts +4 -0
  59. package/dist/src/stories/BsEntrySelect.stories.d.ts.map +1 -1
  60. package/dist/src/stories/OfficeContainer.stories.d.ts +5 -0
  61. package/dist/src/stories/OfficeContainer.stories.d.ts.map +1 -1
  62. package/dist/src/stories/collapsible.stories.d.ts +8 -0
  63. package/dist/src/stories/collapsible.stories.d.ts.map +1 -0
  64. package/dist/src/stories/field-checker-list-msg.stories.d.ts +11 -0
  65. package/dist/src/stories/field-checker-list-msg.stories.d.ts.map +1 -0
  66. package/dist/src/stories/field-checker.stories.d.ts +13 -0
  67. package/dist/src/stories/field-checker.stories.d.ts.map +1 -0
  68. package/dist/src/stories/field-icon.stories.d.ts +15 -0
  69. package/dist/src/stories/field-icon.stories.d.ts.map +1 -0
  70. package/dist/src/stories/field-value-checker.stories.d.ts +13 -0
  71. package/dist/src/stories/field-value-checker.stories.d.ts.map +1 -0
  72. package/dist/src/stories/subform-fields-value-checker.stories.d.ts +9 -0
  73. package/dist/src/stories/subform-fields-value-checker.stories.d.ts.map +1 -0
  74. package/package.json +7 -5
  75. package/src/components/agent-scroll-selector.tsx +10 -3
  76. package/src/components/bs-entry-select/app-group-folder.tsx +61 -0
  77. package/src/components/bs-entry-select/bs-entry-select.tsx +271 -0
  78. package/src/components/bs-entry-select/build-tree.ts +144 -0
  79. package/src/components/bs-entry-select/entry-item.tsx +43 -0
  80. package/src/components/bs-entry-select/entry-popover.tsx +97 -0
  81. package/src/components/bs-entry-select/index.ts +2 -0
  82. package/src/components/bs-entry-select/other-app-section.tsx +96 -0
  83. package/src/components/bs-entry-select/scroll-slider.tsx +455 -0
  84. package/src/components/bs-entry-select/search-input.tsx +32 -0
  85. package/src/components/bs-entry-select/tree-item-node.tsx +84 -0
  86. package/src/components/bs-entry-select/types.ts +30 -0
  87. package/src/components/bs-icons.tsx +36 -1
  88. package/src/components/card.tsx +1 -1
  89. package/src/components/collapsible.tsx +9 -0
  90. package/src/components/field-checker-list-msg.tsx +146 -0
  91. package/src/components/field-checker.tsx +182 -0
  92. package/src/components/field-icon.tsx +13 -0
  93. package/src/components/field-value-checker.tsx +118 -0
  94. package/src/components/legacy-slot.tsx +7 -0
  95. package/src/components/office-container.tsx +111 -28
  96. package/src/components/subform-fields-value-checker.tsx +185 -0
  97. package/src/const/ui.ts +67 -0
  98. package/src/hooks/use-column-appear.ts +78 -0
  99. package/src/hooks/use-resizable-width.ts +10 -2
  100. package/src/index.ts +11 -2
  101. package/src/lib/utils.ts +67 -0
  102. package/src/stories/BsEntrySelect.stories.tsx +45 -2
  103. package/src/stories/OfficeContainer.stories.tsx +150 -1
  104. package/src/stories/collapsible.stories.tsx +47 -0
  105. package/src/stories/field-checker-list-msg.stories.tsx +95 -0
  106. package/src/stories/field-checker.stories.tsx +117 -0
  107. package/src/stories/field-icon.stories.tsx +70 -0
  108. package/src/stories/field-value-checker.stories.tsx +113 -0
  109. package/src/stories/subform-fields-value-checker.stories.tsx +98 -0
  110. package/dist/src/components/bs-entry-select.d.ts.map +0 -1
  111. package/src/components/bs-entry-select.tsx +0 -724
@@ -0,0 +1,43 @@
1
+ import { cn } from '../../lib/utils';
2
+ import { transCls } from '../../const/ui';
3
+ import { getDepthPadding, getEntryIconClass } from './build-tree';
4
+ import type { EntryTreeItem } from './types';
5
+
6
+ export function EntryItem({
7
+ item,
8
+ depth,
9
+ isSelected,
10
+ onClick,
11
+ }: {
12
+ item: EntryTreeItem;
13
+ depth: number;
14
+ isSelected: boolean;
15
+ onClick: () => void;
16
+ }) {
17
+ return (
18
+ <div
19
+ className={cn(
20
+ 'flex items-center justify-between h-[34px] px-[10px] cursor-pointer rounded-[4px]',
21
+ getDepthPadding(depth),
22
+ 'hover:bg-[#EFF0F6]',
23
+ isSelected && 'bg-[#EFF0F6]',
24
+ transCls,
25
+ )}
26
+ onClick={onClick}
27
+ >
28
+ <span className="flex items-center gap-[6px] min-w-0 flex-1">
29
+ <i
30
+ className={
31
+ item.entry
32
+ ? getEntryIconClass(item.entry)
33
+ : 'node-icon icon-bsy-form icon-new-bsy-form-25'
34
+ }
35
+ style={{ fontSize: 16, lineHeight: 1 }}
36
+ />
37
+ <span className="text-[14px] text-[#030303] truncate">
38
+ {item.name}
39
+ </span>
40
+ </span>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,97 @@
1
+ import { cn } from '../../lib/utils';
2
+ import { transCls } from '../../const/ui';
3
+ import { SearchInput } from './search-input';
4
+ import { TreeItemNode } from './tree-item-node';
5
+ import { AppGroupFolder } from './app-group-folder';
6
+ import { OtherAppSection } from './other-app-section';
7
+ import type { IAppDetail } from '@baishuyun/types';
8
+ import type { EntryTreeItem, BsEntrySelectChangeResult } from './types';
9
+
10
+ export function EntryPopover({
11
+ searchQuery,
12
+ onSearchChange,
13
+ currentAppTree,
14
+ currentAppId,
15
+ otherAppIds,
16
+ appDetails,
17
+ selectedEntryId,
18
+ hasCurrentApp,
19
+ hasOtherApps,
20
+ hasAnyContent,
21
+ hasSearch,
22
+ onSelect,
23
+ }: {
24
+ searchQuery: string;
25
+ onSearchChange: (v: string) => void;
26
+ currentAppTree: EntryTreeItem[];
27
+ currentAppId: string | undefined;
28
+ otherAppIds: string[];
29
+ appDetails: Record<string, IAppDetail>;
30
+ selectedEntryId: string | undefined;
31
+ hasCurrentApp: boolean;
32
+ hasOtherApps: boolean;
33
+ hasAnyContent: boolean;
34
+ hasSearch: boolean;
35
+ onSelect: (result: BsEntrySelectChangeResult) => void;
36
+ }) {
37
+ return (
38
+ <div
39
+ className={cn(
40
+ 'bg-white rounded-[10px] shadow-[0px_0px_10px_0px_rgba(0,0,0,0.04)] p-[4px]',
41
+ 'flex flex-col gap-[4px]',
42
+ transCls,
43
+ )}
44
+ >
45
+ <SearchInput value={searchQuery} onChange={onSearchChange} />
46
+
47
+ <div className="flex flex-col gap-[4px] max-h-[400px] overflow-y-auto">
48
+ {hasCurrentApp && (
49
+ <AppGroupFolder label="当前应用表单" searchQuery={searchQuery}>
50
+ {currentAppTree.length > 0 ? (
51
+ currentAppTree.map((node) => (
52
+ <TreeItemNode
53
+ key={node.id}
54
+ item={node}
55
+ selectedEntryId={selectedEntryId}
56
+ appId={currentAppId!}
57
+ section="current"
58
+ onSelect={onSelect}
59
+ />
60
+ ))
61
+ ) : hasSearch ? (
62
+ <div className="text-[12px] text-[#999999] py-[8px] pl-[22px]">
63
+ 无匹配结果
64
+ </div>
65
+ ) : (
66
+ <div className="text-[12px] text-[#999999] py-[8px] pl-[22px]">
67
+ 暂无表单
68
+ </div>
69
+ )}
70
+ </AppGroupFolder>
71
+ )}
72
+
73
+ {hasOtherApps && (
74
+ <AppGroupFolder label="其他应用表单" searchQuery={searchQuery}>
75
+ {otherAppIds.map((appId) => (
76
+ <OtherAppSection
77
+ key={appId}
78
+ appId={appId}
79
+ appDetail={appDetails[appId]}
80
+ searchQuery={searchQuery}
81
+ selectedEntryId={selectedEntryId}
82
+ section="other"
83
+ onSelect={onSelect}
84
+ />
85
+ ))}
86
+ </AppGroupFolder>
87
+ )}
88
+
89
+ {!hasAnyContent && (
90
+ <div className="text-[14px] text-[#999999] py-[12px] text-center">
91
+ 暂无应用
92
+ </div>
93
+ )}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,2 @@
1
+ export { BsEntrySelect } from './bs-entry-select';
2
+ export type { BsEntrySelectProps, BsEntrySelectChangeResult } from './types';
@@ -0,0 +1,96 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import { cn } from '../../lib/utils';
3
+ import { transCls } from '../../const/ui';
4
+ import { buildEntryTreeFromAliasMap } from './build-tree';
5
+ import { TreeItemNode } from './tree-item-node';
6
+ import type { IAppDetail } from '@baishuyun/types';
7
+ import type { BsEntrySelectChangeResult } from './types';
8
+ import { SolidArrowDownIcon } from '../bs-icons';
9
+
10
+ const ChevronRightIcon = ({ size }: { size?: number }) => (
11
+ <SolidArrowDownIcon className="-rotate-90" size={size} />
12
+ );
13
+
14
+ export function OtherAppSection({
15
+ appId,
16
+ appDetail,
17
+ searchQuery,
18
+ selectedEntryId,
19
+ section,
20
+ onSelect,
21
+ }: {
22
+ appId: string;
23
+ appDetail: IAppDetail;
24
+ searchQuery: string;
25
+ selectedEntryId: string | undefined;
26
+ section: 'current' | 'other';
27
+ onSelect: (result: BsEntrySelectChangeResult) => void;
28
+ }) {
29
+ const [expanded, setExpanded] = useState(true);
30
+ const hasSearch = searchQuery.trim().length > 0;
31
+
32
+ useEffect(() => {
33
+ if (hasSearch) {
34
+ setExpanded(true);
35
+ }
36
+ }, [hasSearch]);
37
+
38
+ const tree = useMemo(() => {
39
+ return buildEntryTreeFromAliasMap(appDetail, searchQuery);
40
+ }, [appDetail, searchQuery]);
41
+
42
+ return (
43
+ <div>
44
+ <div
45
+ className={cn(
46
+ 'flex items-center gap-[6px] h-[32px] cursor-pointer rounded-[4px] pr-[10px]',
47
+ 'hover:bg-[#EFF0F6]',
48
+ transCls,
49
+ )}
50
+ style={{ paddingLeft: 15 }}
51
+ onClick={() => setExpanded(!expanded)}
52
+ >
53
+ <span
54
+ className={cn(
55
+ 'shrink-0 flex items-center justify-center w-[18px] h-[18px]',
56
+ transCls,
57
+ expanded && 'rotate-90',
58
+ )}
59
+ >
60
+ <ChevronRightIcon size={18} />
61
+ </span>
62
+ <i
63
+ className="node-icon icon-bsy-form icon-new-bsy-form-0"
64
+ style={{ fontSize: 16, lineHeight: 1 }}
65
+ />
66
+ <span className="text-[14px] text-[#030303] truncate">
67
+ {appDetail.name}
68
+ </span>
69
+ </div>
70
+ {expanded && tree.length > 0 && (
71
+ <div className="flex flex-col" style={{ paddingLeft: 15 }}>
72
+ {tree.map((node) => (
73
+ <TreeItemNode
74
+ key={node.id}
75
+ item={node}
76
+ selectedEntryId={selectedEntryId}
77
+ appId={appId}
78
+ section={section}
79
+ onSelect={onSelect}
80
+ />
81
+ ))}
82
+ </div>
83
+ )}
84
+ {expanded && tree.length === 0 && hasSearch && (
85
+ <div className="text-[12px] text-[#999999] py-[8px] pl-[32px]">
86
+ 无匹配结果
87
+ </div>
88
+ )}
89
+ {expanded && tree.length === 0 && !hasSearch && (
90
+ <div className="text-[12px] text-[#999999] py-[8px] pl-[32px]">
91
+ 暂无表单
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,455 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ useMemo,
7
+ } from 'react';
8
+ import { createPortal } from 'react-dom';
9
+ import { cn } from '../../lib/utils';
10
+ import { transCls } from '../../const/ui';
11
+ import { IconBtn } from '../icon-btn';
12
+ import { LeftArrowIcon, SolidArrowDownIcon } from '../bs-icons';
13
+ import {
14
+ buildEntryTreeFromAliasMap,
15
+ getEntryIconClass,
16
+ filterTreeBySearch,
17
+ } from './build-tree';
18
+ import { SearchInput } from './search-input';
19
+ import { TreeItemNode } from './tree-item-node';
20
+ import type { IAppDetail } from '@baishuyun/types';
21
+ import type { EntryTreeItem, BsEntrySelectChangeResult } from './types';
22
+
23
+ const ChevronRightIcon = ({ size }: { size?: number }) => (
24
+ <SolidArrowDownIcon className="-rotate-90" size={size} />
25
+ );
26
+
27
+ interface FlatNode {
28
+ node: EntryTreeItem;
29
+ appId: string;
30
+ section: 'current' | 'other';
31
+ }
32
+
33
+ export function ScrollSlider({
34
+ appDetails,
35
+ currentAppId,
36
+ selectedEntryId,
37
+ onSelect,
38
+ onOverflowChange,
39
+ }: {
40
+ appDetails: Record<string, IAppDetail>;
41
+ currentAppId?: string;
42
+ selectedEntryId?: string;
43
+ onSelect: (result: BsEntrySelectChangeResult) => void;
44
+ onOverflowChange?: (hasOverflow: boolean) => void;
45
+ }) {
46
+ const scrollRef = useRef<HTMLDivElement>(null);
47
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
48
+ const [canScrollRight, setCanScrollRight] = useState(false);
49
+
50
+ // Group popover state
51
+ const [activeGroup, setActiveGroup] = useState<FlatNode | null>(null);
52
+ const [groupSearchQuery, setGroupSearchQuery] = useState('');
53
+ const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
54
+ const popoverRef = useRef<HTMLDivElement>(null);
55
+ const groupPillRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
56
+ const [groupPortalContainer, setGroupPortalContainer] = useState<HTMLElement | null>(null);
57
+
58
+ // Build first-level nodes from all apps (no search filter)
59
+ const flatNodes = useMemo<FlatNode[]>(() => {
60
+ const appIds = Object.keys(appDetails);
61
+ const currentFirst: FlatNode[] = [];
62
+ const otherNodes: FlatNode[] = [];
63
+
64
+ for (const appId of appIds) {
65
+ const detail = appDetails[appId];
66
+ const tree = buildEntryTreeFromAliasMap(detail, '');
67
+ const section = appId === currentAppId ? 'current' as const : 'other' as const;
68
+ const mapped = tree.map((node) => ({ node, appId, section }));
69
+ if (section === 'current') {
70
+ currentFirst.push(...mapped);
71
+ } else {
72
+ otherNodes.push(...mapped);
73
+ }
74
+ }
75
+
76
+ return [...currentFirst, ...otherNodes];
77
+ }, [appDetails, currentAppId]);
78
+
79
+ // Filtered tree for the active group popover
80
+ const filteredGroupTree = useMemo(() => {
81
+ if (!activeGroup) return [];
82
+ return filterTreeBySearch(activeGroup.node.children, groupSearchQuery);
83
+ }, [activeGroup, groupSearchQuery]);
84
+
85
+ const checkScroll = useCallback(() => {
86
+ const el = scrollRef.current;
87
+ if (!el) return;
88
+ const canLeft = el.scrollLeft > 0;
89
+ const canRight = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
90
+ setCanScrollLeft(canLeft);
91
+ setCanScrollRight(canRight);
92
+ }, []);
93
+
94
+ const hasOverflow = canScrollLeft || canScrollRight;
95
+
96
+ useEffect(() => {
97
+ onOverflowChange?.(hasOverflow);
98
+ }, [hasOverflow, onOverflowChange]);
99
+
100
+ // Map from descendant entry ID to the group pill node ID
101
+ const descendantToGroupId = useMemo(() => {
102
+ const map = new Map<string, string>();
103
+ function collectDescendants(node: EntryTreeItem, groupId: string) {
104
+ if (node.isSelectable) {
105
+ map.set(node.id, groupId);
106
+ }
107
+ for (const child of node.children) {
108
+ collectDescendants(child, groupId);
109
+ }
110
+ }
111
+ for (const { node } of flatNodes) {
112
+ if (!node.isSelectable) {
113
+ for (const child of node.children) {
114
+ collectDescendants(child, node.id);
115
+ }
116
+ }
117
+ }
118
+ return map;
119
+ }, [flatNodes]);
120
+
121
+ // Auto-scroll selected pill into view
122
+ useEffect(() => {
123
+ if (!selectedEntryId || !scrollRef.current) return;
124
+ // Try direct match first (leaf pill)
125
+ let targetId = selectedEntryId;
126
+ let pill = scrollRef.current.querySelector(
127
+ `[data-entry-id="${targetId}"]`,
128
+ ) as HTMLElement | null;
129
+ // Fall back to ancestor group pill
130
+ if (!pill) {
131
+ targetId = descendantToGroupId.get(selectedEntryId) || '';
132
+ if (targetId) {
133
+ pill = scrollRef.current.querySelector(
134
+ `[data-entry-id="${targetId}"]`,
135
+ ) as HTMLElement | null;
136
+ }
137
+ }
138
+ if (pill) {
139
+ pill.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
140
+ }
141
+ }, [selectedEntryId, descendantToGroupId]);
142
+
143
+ useEffect(() => {
144
+ checkScroll();
145
+ const el = scrollRef.current;
146
+ if (!el) return;
147
+
148
+ const observer = new ResizeObserver(checkScroll);
149
+ observer.observe(el);
150
+ el.addEventListener('scroll', checkScroll, { passive: true });
151
+ return () => {
152
+ observer.disconnect();
153
+ el.removeEventListener('scroll', checkScroll);
154
+ };
155
+ }, [flatNodes, checkScroll]);
156
+
157
+ const scroll = useCallback((direction: 'left' | 'right') => {
158
+ const el = scrollRef.current;
159
+ if (!el) return;
160
+ el.scrollBy({
161
+ left: direction === 'left' ? -150 : 150,
162
+ behavior: 'smooth',
163
+ });
164
+ }, []);
165
+
166
+ const updatePopoverPosition = useCallback(() => {
167
+ if (!activeGroup) return;
168
+ const triggerEl = groupPillRefs.current.get(activeGroup.node.id);
169
+ if (!triggerEl) return;
170
+ const rect = triggerEl.getBoundingClientRect();
171
+ setPopoverStyle({
172
+ position: 'fixed',
173
+ top: rect.bottom + 4,
174
+ left: rect.left,
175
+ minWidth: Math.max(rect.width, 240),
176
+ zIndex: 9999,
177
+ });
178
+ }, [activeGroup]);
179
+
180
+ const closeGroupPopover = useCallback(() => {
181
+ setActiveGroup(null);
182
+ setGroupSearchQuery('');
183
+ }, []);
184
+
185
+ const openGroupPopover = useCallback((flatNode: FlatNode, event: React.MouseEvent) => {
186
+ event.stopPropagation();
187
+ if (activeGroup?.node.id === flatNode.node.id) {
188
+ setActiveGroup(null);
189
+ setGroupSearchQuery('');
190
+ return;
191
+ }
192
+ setGroupSearchQuery('');
193
+ setActiveGroup(flatNode);
194
+
195
+ // Check Shadow DOM portal
196
+ const triggerEl = groupPillRefs.current.get(flatNode.node.id);
197
+ if (triggerEl) {
198
+ const rootNode = triggerEl.getRootNode();
199
+ if (rootNode instanceof ShadowRoot) {
200
+ const existing = rootNode.querySelector<HTMLElement>(
201
+ '[data-bs-entry-select-group-portal]',
202
+ );
203
+ if (existing) {
204
+ setGroupPortalContainer(existing);
205
+ } else {
206
+ const container = document.createElement('div');
207
+ container.setAttribute('data-bs-entry-select-group-portal', 'true');
208
+ rootNode.appendChild(container);
209
+ setGroupPortalContainer(container);
210
+ }
211
+ } else {
212
+ setGroupPortalContainer(null);
213
+ }
214
+ }
215
+
216
+ // Position the popover after the next frame
217
+ const groupId = flatNode.node.id;
218
+ requestAnimationFrame(() => {
219
+ const el = groupPillRefs.current.get(groupId);
220
+ if (el) {
221
+ const rect = el.getBoundingClientRect();
222
+ setPopoverStyle({
223
+ position: 'fixed',
224
+ top: rect.bottom + 4,
225
+ left: rect.left,
226
+ minWidth: Math.max(rect.width, 240),
227
+ zIndex: 9999,
228
+ });
229
+ }
230
+ });
231
+ }, [activeGroup]);
232
+
233
+ const handleGroupPopoverSelect = useCallback(
234
+ (result: BsEntrySelectChangeResult) => {
235
+ onSelect(result);
236
+ closeGroupPopover();
237
+ },
238
+ [onSelect, closeGroupPopover],
239
+ );
240
+
241
+ // Click outside for group popover
242
+ useEffect(() => {
243
+ if (!activeGroup) return;
244
+
245
+ const handleClickOutside = (e: MouseEvent) => {
246
+ const path = e.composedPath();
247
+ if (popoverRef.current && path.includes(popoverRef.current)) return;
248
+ const triggerEl = groupPillRefs.current.get(activeGroup.node.id);
249
+ if (triggerEl && path.includes(triggerEl)) return;
250
+ closeGroupPopover();
251
+ };
252
+
253
+ const handleScroll = () => updatePopoverPosition();
254
+
255
+ document.addEventListener('mousedown', handleClickOutside);
256
+ window.addEventListener('scroll', handleScroll, true);
257
+ window.addEventListener('resize', handleScroll);
258
+
259
+ return () => {
260
+ document.removeEventListener('mousedown', handleClickOutside);
261
+ window.removeEventListener('scroll', handleScroll, true);
262
+ window.removeEventListener('resize', handleScroll);
263
+ };
264
+ }, [activeGroup, closeGroupPopover, updatePopoverPosition]);
265
+
266
+ // Keyboard escape
267
+ useEffect(() => {
268
+ if (!activeGroup) return;
269
+ const handleEscape = (e: KeyboardEvent) => {
270
+ if (e.key === 'Escape') closeGroupPopover();
271
+ };
272
+ document.addEventListener('keydown', handleEscape);
273
+ return () => document.removeEventListener('keydown', handleEscape);
274
+ }, [activeGroup, closeGroupPopover]);
275
+
276
+ if (flatNodes.length === 0) return null;
277
+
278
+ const hasGroupSearch = groupSearchQuery.trim().length > 0;
279
+
280
+ return (
281
+ <div className="flex items-center flex-1 min-w-0">
282
+ {/* Left arrow: always in DOM, width transition */}
283
+ <span
284
+ className={cn(
285
+ transCls,
286
+ 'inline-flex overflow-hidden',
287
+ canScrollLeft ? 'w-[26px] opacity-100 mr-2.5' : 'w-0 opacity-0 mr-0',
288
+ )}
289
+ >
290
+ <IconBtn
291
+ rounded
292
+ icon={<LeftArrowIcon />}
293
+ onClick={() => scroll('left')}
294
+ />
295
+ </span>
296
+
297
+ <div
298
+ ref={scrollRef}
299
+ className="flex-1 overflow-x-auto scrollbar-hide flex gap-2.5 min-w-0"
300
+ >
301
+ {flatNodes.map((flatNode) => {
302
+ const { node, appId, section } = flatNode;
303
+ const isLeaf = node.isSelectable;
304
+ const isSelected = isLeaf && selectedEntryId === node.id;
305
+ const isGroupActive = activeGroup?.node.id === node.id;
306
+
307
+ const setPillRef = (el: HTMLButtonElement | null) => {
308
+ if (el) {
309
+ groupPillRefs.current.set(node.id, el);
310
+ } else {
311
+ groupPillRefs.current.delete(node.id);
312
+ }
313
+ };
314
+
315
+ if (isLeaf) {
316
+ return (
317
+ <button
318
+ key={node.id}
319
+ data-entry-id={node.id}
320
+ type="button"
321
+ onClick={() => {
322
+ if (node.entry) {
323
+ onSelect({ entry: node.entry, appId, section });
324
+ }
325
+ }}
326
+ className={cn(
327
+ 'flex items-center gap-[6px] shrink-0 max-w-[240px]',
328
+ 'px-[10px] py-[7px] rounded-[4px]',
329
+ transCls,
330
+ isSelected
331
+ ? 'border border-[#F0F6FF] bg-[#F0F6FF] text-[#0265FF]'
332
+ : 'border border-[#E0E0E0] text-[#030303] hover:bg-[#F0F6FF]',
333
+ )}
334
+ >
335
+ <i
336
+ className={
337
+ node.entry
338
+ ? getEntryIconClass(node.entry)
339
+ : 'node-icon icon-bsy-form icon-new-bsy-form-25'
340
+ }
341
+ style={{ fontSize: 16, lineHeight: 1 }}
342
+ />
343
+ <span className="text-[14px] leading-[normal] truncate">
344
+ {node.name}
345
+ </span>
346
+ </button>
347
+ );
348
+ }
349
+
350
+ // Group pill (has descendants)
351
+ return (
352
+ <button
353
+ key={node.id}
354
+ data-entry-id={node.id}
355
+ ref={setPillRef}
356
+ type="button"
357
+ onClick={(e) => openGroupPopover(flatNode, e)}
358
+ className={cn(
359
+ 'flex items-center gap-[10px] shrink-0 max-w-[240px]',
360
+ 'px-[10px] py-[7px] rounded-[4px]',
361
+ transCls,
362
+ isGroupActive
363
+ ? 'border border-[#0265FF] text-[#030303]'
364
+ : 'border border-[#E0E0E0] text-[#030303] hover:bg-[#F0F6FF]',
365
+ )}
366
+ >
367
+ <span className="flex items-center gap-[6px] min-w-0 flex-1">
368
+ <i
369
+ className="icon-bsy-folder-close"
370
+ style={{ fontSize: 16, lineHeight: 1 }}
371
+ />
372
+ <span className="text-[14px] leading-[normal] truncate">
373
+ {node.name}
374
+ </span>
375
+ </span>
376
+ <span
377
+ className={cn(
378
+ 'shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
379
+ transCls,
380
+ isGroupActive && 'rotate-90',
381
+ )}
382
+ >
383
+ <ChevronRightIcon size={16} />
384
+ </span>
385
+ </button>
386
+ );
387
+ })}
388
+ </div>
389
+
390
+ {/* Right arrow: always in DOM, width transition */}
391
+ <span
392
+ className={cn(
393
+ transCls,
394
+ 'inline-flex overflow-hidden',
395
+ canScrollRight ? 'w-[26px] opacity-100 ml-2.5' : 'w-0 opacity-0 ml-0',
396
+ )}
397
+ >
398
+ <IconBtn
399
+ rounded
400
+ icon={
401
+ <span className="rotate-180 inline-flex">
402
+ <LeftArrowIcon />
403
+ </span>
404
+ }
405
+ onClick={() => scroll('right')}
406
+ />
407
+ </span>
408
+
409
+ {/* Group subtree popover */}
410
+ {activeGroup &&
411
+ (() => {
412
+ const content = (
413
+ <div
414
+ ref={popoverRef}
415
+ style={popoverStyle}
416
+ className={cn(
417
+ 'bg-white rounded-[10px] shadow-[0px_0px_10px_0px_rgba(0,0,0,0.04)] p-[4px]',
418
+ 'flex flex-col gap-[4px]',
419
+ transCls,
420
+ )}
421
+ >
422
+ <SearchInput
423
+ value={groupSearchQuery}
424
+ onChange={setGroupSearchQuery}
425
+ />
426
+ <div className="flex flex-col gap-[4px] max-h-[300px] overflow-y-auto">
427
+ {filteredGroupTree.length > 0 ? (
428
+ filteredGroupTree.map((child) => (
429
+ <TreeItemNode
430
+ key={child.id}
431
+ item={child}
432
+ selectedEntryId={selectedEntryId}
433
+ appId={activeGroup.appId}
434
+ section={activeGroup.section}
435
+ onSelect={handleGroupPopoverSelect}
436
+ />
437
+ ))
438
+ ) : hasGroupSearch ? (
439
+ <div className="text-[12px] text-[#999999] py-[8px] text-center">
440
+ 无匹配结果
441
+ </div>
442
+ ) : (
443
+ <div className="text-[12px] text-[#999999] py-[8px] text-center">
444
+ 暂无表单
445
+ </div>
446
+ )}
447
+ </div>
448
+ </div>
449
+ );
450
+
451
+ return createPortal(content, groupPortalContainer || document.body);
452
+ })()}
453
+ </div>
454
+ );
455
+ }