@baishuyun/ai-design 2.0.38 → 2.1.0-alpha.0

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 (269) hide show
  1. package/.storybook/main.ts +31 -31
  2. package/.storybook/preview.ts +16 -16
  3. package/CHANGELOG.md +512 -501
  4. package/components.json +22 -22
  5. package/package.json +9 -6
  6. package/scripts/strip-layers.cjs +41 -41
  7. package/src/components/abs-fullscreen-gradient-bg.tsx +35 -35
  8. package/src/components/agent-helper/agent-category-select-menu/index.tsx +57 -57
  9. package/src/components/agent-helper/agent-entry-card/index.tsx +94 -94
  10. package/src/components/agent-helper/agent-helper-card/index.tsx +179 -179
  11. package/src/components/agent-helper/agent-helper-card-list/index.tsx +59 -59
  12. package/src/components/agent-helper/agent-helper-main-layout/index.tsx +60 -60
  13. package/src/components/agent-helper/agent-sort-select/index.tsx +89 -89
  14. package/src/components/agent-helper/agent-tag/index.tsx +29 -29
  15. package/src/components/agent-helper/app-select/index.tsx +559 -559
  16. package/src/components/agent-helper/condition-group/index.tsx +75 -75
  17. package/src/components/agent-helper/condition-input/index.tsx +233 -233
  18. package/src/components/agent-helper/condition-item/index.tsx +237 -237
  19. package/src/components/agent-helper/condition-quantifier-select/index.tsx +142 -142
  20. package/src/components/agent-helper/condition-trigger-description/index.tsx +49 -49
  21. package/src/components/agent-helper/date-picker/calendar-panel.tsx +164 -164
  22. package/src/components/agent-helper/date-picker/date-input.tsx +91 -91
  23. package/src/components/agent-helper/date-picker/date-picker.tsx +327 -327
  24. package/src/components/agent-helper/date-picker/footer-panel.tsx +44 -44
  25. package/src/components/agent-helper/date-picker/index.ts +3 -3
  26. package/src/components/agent-helper/date-picker/month-panel.tsx +82 -82
  27. package/src/components/agent-helper/date-picker/time-panel.tsx +146 -146
  28. package/src/components/agent-helper/date-picker/types.ts +37 -37
  29. package/src/components/agent-helper/date-picker/utils.ts +121 -121
  30. package/src/components/agent-helper/date-picker/year-panel.tsx +86 -86
  31. package/src/components/agent-helper/exec-log-filter/index.tsx +232 -232
  32. package/src/components/agent-helper/field-edit-preference-select/index.tsx +291 -291
  33. package/src/components/agent-helper/field-select/index.tsx +466 -466
  34. package/src/components/agent-helper/field-value-input/china-address-data.ts +4117 -4117
  35. package/src/components/agent-helper/field-value-input/display.tsx +71 -71
  36. package/src/components/agent-helper/field-value-input/icons.tsx +38 -38
  37. package/src/components/agent-helper/field-value-input/index.tsx +144 -144
  38. package/src/components/agent-helper/field-value-input/inputs.tsx +247 -247
  39. package/src/components/agent-helper/field-value-input/picker-inputs.tsx +1141 -1141
  40. package/src/components/agent-helper/flow-node-action-trigger-select/index.tsx +57 -57
  41. package/src/components/agent-helper/form-action-trigger-select/index.tsx +60 -60
  42. package/src/components/agent-helper/form-divider/index.tsx +44 -44
  43. package/src/components/agent-helper/form-select/index.tsx +732 -732
  44. package/src/components/agent-helper/monthdays-select/index.tsx +378 -378
  45. package/src/components/agent-helper/offset-preference-select/index.tsx +268 -268
  46. package/src/components/agent-helper/offset-type-select/index.tsx +66 -66
  47. package/src/components/agent-helper/offset-unit-select/index.tsx +54 -54
  48. package/src/components/agent-helper/placeholder-card/index.tsx +75 -75
  49. package/src/components/agent-helper/prompt-input/index.tsx +646 -646
  50. package/src/components/agent-helper/relational-operator-select/index.tsx +179 -179
  51. package/src/components/agent-helper/repeat-by-type-select/index.tsx +53 -53
  52. package/src/components/agent-helper/repeat-preference-select/index.tsx +162 -162
  53. package/src/components/agent-helper/repeat-preference-type-select/index.tsx +58 -58
  54. package/src/components/agent-helper/search-input/index.tsx +35 -35
  55. package/src/components/agent-helper/table/index.tsx +114 -114
  56. package/src/components/agent-helper/trigger-select/index.tsx +121 -121
  57. package/src/components/agent-helper/trigger-time-preference-select/index.tsx +189 -189
  58. package/src/components/agent-helper/trigger-time-source-type-select/index.tsx +53 -53
  59. package/src/components/agent-helper/weekdays-select/index.tsx +230 -230
  60. package/src/components/agent-scroll-selector.tsx +193 -193
  61. package/src/components/agent-select.tsx +154 -154
  62. package/src/components/attachment-part-group.tsx +35 -35
  63. package/src/components/attachment-part.tsx +296 -296
  64. package/src/components/attachments-previewer.tsx +272 -272
  65. package/src/components/base-button.tsx +49 -49
  66. package/src/components/border-animation.tsx +107 -107
  67. package/src/components/border-color-animation.tsx +66 -66
  68. package/src/components/bot-avatar-name.tsx +36 -36
  69. package/src/components/bot-icon.tsx +23 -23
  70. package/src/components/bs-entry-select/app-group-folder.tsx +61 -61
  71. package/src/components/bs-entry-select/bs-entry-select.tsx +296 -296
  72. package/src/components/bs-entry-select/build-tree.ts +265 -265
  73. package/src/components/bs-entry-select/entry-item.tsx +45 -45
  74. package/src/components/bs-entry-select/entry-popover.tsx +99 -99
  75. package/src/components/bs-entry-select/index.ts +7 -7
  76. package/src/components/bs-entry-select/other-app-section.tsx +96 -96
  77. package/src/components/bs-entry-select/scroll-slider.tsx +872 -872
  78. package/src/components/bs-entry-select/search-input.tsx +4 -4
  79. package/src/components/bs-entry-select/tree-item-node.tsx +84 -84
  80. package/src/components/bs-entry-select/types.ts +46 -46
  81. package/src/components/bs-icons.tsx +1362 -1362
  82. package/src/components/card-field.tsx +23 -23
  83. package/src/components/card.tsx +36 -36
  84. package/src/components/chat-area-header.tsx +65 -65
  85. package/src/components/check-card.tsx +77 -77
  86. package/src/components/circle-checker.tsx +66 -66
  87. package/src/components/collapsible-txt-msg.tsx +75 -75
  88. package/src/components/collapsible.tsx +9 -9
  89. package/src/components/confirm-dialog.tsx +139 -139
  90. package/src/components/date-range-picker.tsx +91 -91
  91. package/src/components/fake-bot-msg.tsx +51 -51
  92. package/src/components/field-checker-list-msg.tsx +146 -146
  93. package/src/components/field-checker.tsx +185 -185
  94. package/src/components/field-icon.tsx +13 -13
  95. package/src/components/field-value-checker.tsx +116 -116
  96. package/src/components/fields-design-info-table.tsx +160 -160
  97. package/src/components/fields-generating-indicator.tsx +95 -95
  98. package/src/components/fields-portal.tsx +18 -18
  99. package/src/components/fields-previewer.tsx +216 -216
  100. package/src/components/font-icon.tsx +65 -65
  101. package/src/components/form-info-editor.tsx +263 -263
  102. package/src/components/formula-tag.tsx +32 -32
  103. package/src/components/generate-animation.tsx +56 -56
  104. package/src/components/icon-btn.tsx +43 -43
  105. package/src/components/icon-card.tsx +67 -67
  106. package/src/components/icons/calendar-icon.tsx +5 -5
  107. package/src/components/icons/clear-all-icon.tsx +5 -5
  108. package/src/components/icons/empty-agent-icon.tsx +93 -93
  109. package/src/components/icons/empty-table-icon.tsx +104 -104
  110. package/src/components/icons/grids-icon.tsx +5 -5
  111. package/src/components/img-part.tsx +27 -27
  112. package/src/components/legacy-slot.tsx +110 -110
  113. package/src/components/line-checker.tsx +168 -168
  114. package/src/components/linear-gradient-border-btn.tsx +48 -48
  115. package/src/components/linear-gradient-color-bg-animation.tsx +103 -103
  116. package/src/components/memoized-markdown.tsx +32 -32
  117. package/src/components/message-content.tsx +21 -21
  118. package/src/components/number-part.tsx +46 -46
  119. package/src/components/office-container.tsx +289 -289
  120. package/src/components/option-item.tsx +27 -27
  121. package/src/components/output-config-editor.tsx +478 -478
  122. package/src/components/previewer-header.tsx +270 -270
  123. package/src/components/primary-confirm-btn.tsx +58 -58
  124. package/src/components/primary-entry-btn.tsx +48 -48
  125. package/src/components/radio-card.tsx +63 -63
  126. package/src/components/scroll-to-bottom-btn.tsx +28 -28
  127. package/src/components/split-line.tsx +9 -9
  128. package/src/components/square-checker.tsx +87 -87
  129. package/src/components/subform-fields-value-checker.tsx +184 -184
  130. package/src/components/tab-radio-group.tsx +83 -83
  131. package/src/components/tooltip.tsx +53 -53
  132. package/src/components/ui/button.tsx +51 -51
  133. package/src/components/ui/calendar.tsx +57 -57
  134. package/src/components/ui/dialog.tsx +104 -104
  135. package/src/components/ui/drawer.tsx +279 -279
  136. package/src/components/ui/form.tsx +202 -202
  137. package/src/components/ui/icons.tsx +1267 -1267
  138. package/src/components/ui/input.tsx +28 -28
  139. package/src/components/ui/label.tsx +30 -30
  140. package/src/components/ui/popover.tsx +85 -85
  141. package/src/components/ui/select.tsx +236 -236
  142. package/src/components/ui/spinner.tsx +17 -17
  143. package/src/components/ui/switch.tsx +33 -33
  144. package/src/components/ui/tabs.tsx +62 -62
  145. package/src/components/ui/textarea.tsx +28 -28
  146. package/src/components/ui/tooltip.tsx +53 -53
  147. package/src/components/user-txt-msg-bubble.tsx +47 -47
  148. package/src/components/warning-msg.tsx +27 -27
  149. package/src/const/ui.ts +119 -119
  150. package/src/css.d.ts +4 -4
  151. package/src/hooks/use-column-appear.ts +78 -78
  152. package/src/hooks/use-portal-container.ts +27 -27
  153. package/src/hooks/use-resizable-width.ts +129 -129
  154. package/src/hooks/use-select-popover-position.ts +74 -74
  155. package/src/hooks/use-shadow.ts +127 -127
  156. package/src/index.ts +226 -226
  157. package/src/lib/utils.ts +138 -138
  158. package/src/stories/AbsFullscreenGradientBg.stories.tsx +36 -36
  159. package/src/stories/AgentCategorySelectMenu.stories.tsx +33 -33
  160. package/src/stories/AgentEntryCard.stories.tsx +90 -90
  161. package/src/stories/AgentHelperCard.stories.tsx +105 -105
  162. package/src/stories/AgentHelperCardList.stories.tsx +120 -120
  163. package/src/stories/AgentHelperMainLayout.stories.tsx +71 -71
  164. package/src/stories/AgentScrollSelector.stories.tsx +95 -95
  165. package/src/stories/AgentSelect.stories.tsx +134 -134
  166. package/src/stories/AgentTag.stories.tsx +44 -44
  167. package/src/stories/AppSelect.stories.tsx +250 -250
  168. package/src/stories/AttachmentPart.stories.tsx +122 -122
  169. package/src/stories/AttachmentPartGroup.stories.tsx +152 -152
  170. package/src/stories/AttachmentsPreviewer.stories.tsx +306 -306
  171. package/src/stories/BaseButton.stories.tsx +33 -33
  172. package/src/stories/BorderAnimation.stories.tsx +116 -116
  173. package/src/stories/BorderColorAnimation.stories.tsx +63 -63
  174. package/src/stories/BotAvatarAndName.stories.ts +24 -24
  175. package/src/stories/BotIcon.stories.ts +22 -22
  176. package/src/stories/BsConditionInput.stories.tsx +460 -460
  177. package/src/stories/BsEntrySelect.stories.tsx +343 -343
  178. package/src/stories/BsTooltip.stories.tsx +32 -32
  179. package/src/stories/Card.stories.tsx +28 -28
  180. package/src/stories/CardField.stories.tsx +87 -87
  181. package/src/stories/ChatAreaHeader.stories.ts +26 -26
  182. package/src/stories/CheckCard.stories.tsx +114 -114
  183. package/src/stories/CircleChecker.stories.tsx +89 -89
  184. package/src/stories/CollapsibleTextMsg.stories.ts +44 -44
  185. package/src/stories/ConditionGroup.stories.tsx +216 -216
  186. package/src/stories/ConditionInput.stories.tsx +502 -502
  187. package/src/stories/ConditionItem.stories.tsx +163 -163
  188. package/src/stories/ConditonQuantifierSelect.stories.tsx +54 -54
  189. package/src/stories/ConditonTriggerDescription.stories.tsx +61 -61
  190. package/src/stories/ConfirmDialog.stories.tsx +123 -123
  191. package/src/stories/DataTable.stories.tsx +191 -191
  192. package/src/stories/DatePicker.stories.tsx +174 -174
  193. package/src/stories/ExecLogFilter.stories.tsx +145 -145
  194. package/src/stories/FakeBotMsg.stories.tsx +36 -36
  195. package/src/stories/FieldEditPreferenceSelect.stories.tsx +161 -161
  196. package/src/stories/FieldSelect.stories.tsx +228 -228
  197. package/src/stories/FieldValueInput.stories.tsx +442 -442
  198. package/src/stories/FlowNodeActionTriggerSelect.stories.tsx +80 -80
  199. package/src/stories/Form.stories.tsx +302 -302
  200. package/src/stories/FormActionTriggerSelect.stories.tsx +80 -80
  201. package/src/stories/FormDivider.stories.tsx +31 -31
  202. package/src/stories/FormInfoEditor.stories.tsx +173 -173
  203. package/src/stories/FormSelect.stories.tsx +222 -222
  204. package/src/stories/FormulaTag.stories.tsx +68 -68
  205. package/src/stories/GenerateAnimation.stories.ts +20 -20
  206. package/src/stories/IconBtn.stories.tsx +32 -32
  207. package/src/stories/IconCard.stories.tsx +35 -35
  208. package/src/stories/IconLib.tsx +45 -45
  209. package/src/stories/Icons.stories.ts +22 -22
  210. package/src/stories/ImgMsgPart.stories.tsx +42 -42
  211. package/src/stories/Input.stories.tsx +102 -102
  212. package/src/stories/Label.stories.tsx +66 -66
  213. package/src/stories/LegacySlot.stories.tsx +56 -56
  214. package/src/stories/LineChecker.stories.tsx +133 -133
  215. package/src/stories/LinearGradientBorderBtn.stories.tsx +86 -86
  216. package/src/stories/LinearGradientColorBgAnimation.stories.tsx +60 -60
  217. package/src/stories/MemoizedMarkdown.stories.tsx +102 -102
  218. package/src/stories/MessageContent.stories.tsx +36 -36
  219. package/src/stories/MonthdaysSelect.stories.tsx +78 -78
  220. package/src/stories/NumbersMsgPart.stories.tsx +42 -42
  221. package/src/stories/OfficeContainer.stories.tsx +287 -287
  222. package/src/stories/OffsetPreferenceSelect.stories.tsx +118 -118
  223. package/src/stories/OffsetTypeSelect.stories.tsx +75 -75
  224. package/src/stories/OffsetUnitSelect.stories.tsx +61 -61
  225. package/src/stories/OptionItem.stories.tsx +77 -77
  226. package/src/stories/OutputConfigEditor.stories.tsx +96 -96
  227. package/src/stories/PlaceholderCard.stories.tsx +35 -35
  228. package/src/stories/PreviewerHeader.stories.tsx +91 -91
  229. package/src/stories/PrimaryConfirmBtn.stories.tsx +60 -60
  230. package/src/stories/PrimaryEntryBtn.stories.tsx +74 -74
  231. package/src/stories/PromptInput.stories.tsx +228 -228
  232. package/src/stories/RadioCard.stories.tsx +103 -103
  233. package/src/stories/RelationalOperatorSelect.stories.tsx +61 -61
  234. package/src/stories/RepeatByTypeSelect.stories.tsx +58 -58
  235. package/src/stories/RepeatPreferenceSelect.stories.tsx +107 -107
  236. package/src/stories/RepeatPreferenceTypeSelect.stories.tsx +58 -58
  237. package/src/stories/ScrollToBottom.stories.tsx +24 -24
  238. package/src/stories/SearchInput.stories.tsx +50 -50
  239. package/src/stories/Select.stories.tsx +128 -128
  240. package/src/stories/SplitLine.stories.tsx +40 -40
  241. package/src/stories/SquareChecker.stories.tsx +96 -96
  242. package/src/stories/TabRadioGroup.stories.tsx +84 -84
  243. package/src/stories/Textarea.stories.tsx +67 -67
  244. package/src/stories/TriggerSelect.stories.tsx +48 -48
  245. package/src/stories/TriggerTimePreferenceSelect.stories.tsx +106 -106
  246. package/src/stories/TriggerTimeSourceTypeSelect.stories.tsx +68 -68
  247. package/src/stories/UserTextMsgBubble.stories.ts +24 -24
  248. package/src/stories/WarningMessage.stories.tsx +23 -23
  249. package/src/stories/WeekdaysSelect.stories.tsx +66 -66
  250. package/src/stories/assets/demo.css +539 -539
  251. package/src/stories/assets/demo_index.html +7988 -7988
  252. package/src/stories/assets/iconfont.css +1377 -1377
  253. package/src/stories/assets/iconfont.json +2382 -2382
  254. package/src/stories/assets/iconfont.svg +697 -697
  255. package/src/stories/collapsible.stories.tsx +47 -47
  256. package/src/stories/drawer.stories.tsx +196 -196
  257. package/src/stories/field-checker-list-msg.stories.tsx +95 -95
  258. package/src/stories/field-checker.stories.tsx +117 -117
  259. package/src/stories/field-icon.stories.tsx +70 -70
  260. package/src/stories/field-value-checker.stories.tsx +113 -113
  261. package/src/stories/fields-design-info-table.stories.tsx +203 -203
  262. package/src/stories/fields-generating.stories.tsx +41 -41
  263. package/src/stories/fields-previewer.stories.tsx +77 -77
  264. package/src/stories/subform-fields-value-checker.stories.tsx +98 -98
  265. package/src/style.css +226 -226
  266. package/src/types.ts +8 -8
  267. package/style.css +3652 -3652
  268. package/tsconfig.json +13 -13
  269. package/vite.config.ts +44 -44
@@ -1,872 +1,872 @@
1
- import {
2
- useState,
3
- useRef,
4
- useEffect,
5
- useCallback,
6
- useMemo,
7
- Fragment,
8
- } from 'react';
9
- import { createPortal } from 'react-dom';
10
- import { cn } from '../../lib/utils';
11
- import { transCls } from '../../const/ui';
12
- import { IconBtn } from '../icon-btn';
13
- import { LeftArrowIcon, SolidArrowDownIcon, DividerIcon } from '../bs-icons';
14
- import {
15
- buildEntryTreeFromAliasMap,
16
- getEntryIconClass,
17
- filterTreeBySearch,
18
- buildFlowCenterTree,
19
- } from './build-tree';
20
- import { SearchInput } from './search-input';
21
- import { TreeItemNode } from './tree-item-node';
22
- import type { IAppDetail } from '@baishuyun/types';
23
- import type {
24
- EntryTreeItem,
25
- BsEntrySelectChangeResult,
26
- BsEntrySelectSliderVariant,
27
- } from './types';
28
- import classNames from 'classnames';
29
- import { useSelectPopoverPosition } from '../../hooks/use-select-popover-position';
30
-
31
- const ChevronRightIcon = ({ size }: { size?: number }) => (
32
- <SolidArrowDownIcon className="-rotate-90" size={size} />
33
- );
34
-
35
- export interface FlatNode {
36
- node: EntryTreeItem;
37
- appId: string;
38
- section: 'current' | 'other';
39
- }
40
-
41
- export function ScrollSlider({
42
- appDetails,
43
- currentAppId,
44
- selectedEntryId,
45
- onSelect,
46
- onOverflowChange,
47
- showCurrentAppEntriesOnly = false,
48
- variant = 'default',
49
- }: {
50
- appDetails: Record<string, IAppDetail>;
51
- currentAppId?: string;
52
- selectedEntryId?: string;
53
- onSelect: (result: BsEntrySelectChangeResult) => void;
54
- onOverflowChange?: (hasOverflow: boolean) => void;
55
- showCurrentAppEntriesOnly?: boolean;
56
- variant?: BsEntrySelectSliderVariant;
57
- }) {
58
- const scrollRef = useRef<HTMLDivElement>(null);
59
- const [canScrollLeft, setCanScrollLeft] = useState(false);
60
- const [canScrollRight, setCanScrollRight] = useState(false);
61
-
62
- // Group popover state
63
- const [activeGroup, setActiveGroup] = useState<FlatNode | null>(null);
64
- const [groupSearchQuery, setGroupSearchQuery] = useState('');
65
- const popoverRef = useRef<HTMLDivElement>(null);
66
- const groupPillRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
67
- const [groupPortalContainer, setGroupPortalContainer] = useState<HTMLElement | null>(null);
68
- const activeGroupIdRef = useRef<string | null>(null);
69
-
70
- const { popoverStyle, updatePopoverPosition } = useSelectPopoverPosition(
71
- () => {
72
- if (!activeGroupIdRef.current) return null;
73
- return groupPillRefs.current.get(activeGroupIdRef.current) ?? null;
74
- },
75
- { estimatedHeight: 340 },
76
- );
77
-
78
- const hoverCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
79
-
80
- const isTabs = variant === 'tabs';
81
- const showFlowCenter = isTabs && (appDetails[currentAppId ?? '']?.isFlow ?? false);
82
-
83
- // Build first-level nodes from all apps (no search filter)
84
- const flatNodes = useMemo<FlatNode[]>(() => {
85
- const flowCenterNodes: FlatNode[] = [];
86
-
87
- // Flow center — first tab in tabs mode for flow-type apps
88
- if (showFlowCenter) {
89
- const flowTree = buildFlowCenterTree();
90
- flowCenterNodes.push(
91
- ...flowTree.map((node) => ({
92
- node,
93
- appId: currentAppId ?? '',
94
- section: 'current' as const,
95
- })),
96
- );
97
- }
98
-
99
- const appIds = Object.keys(appDetails);
100
- const currentFirst: FlatNode[] = [];
101
- const otherNodes: FlatNode[] = [];
102
-
103
- for (const appId of appIds) {
104
- const detail = appDetails[appId];
105
- const tree = buildEntryTreeFromAliasMap(detail, '');
106
- const section = appId === currentAppId ? 'current' as const : 'other' as const;
107
- const mapped = tree.map((node) => ({ node, appId, section }));
108
- if (section === 'current') {
109
- currentFirst.push(...mapped);
110
- } else {
111
- otherNodes.push(...mapped);
112
- }
113
- }
114
-
115
- const merged = [...flowCenterNodes, ...currentFirst, ...otherNodes];
116
- return showCurrentAppEntriesOnly ? [...flowCenterNodes, ...currentFirst] : merged;
117
- }, [
118
- appDetails,
119
- currentAppId,
120
- showCurrentAppEntriesOnly,
121
- showFlowCenter,
122
- Object.keys(appDetails)
123
- .map((id) => (appDetails[id]?.entryList?.length ?? 0))
124
- .join(','),
125
- ]);
126
-
127
- // Filtered tree for the active group popover
128
- const filteredGroupTree = useMemo(() => {
129
- if (!activeGroup) return [];
130
- return filterTreeBySearch(activeGroup.node.children, groupSearchQuery);
131
- }, [activeGroup, groupSearchQuery]);
132
-
133
- // Map from group pill node ID → FlatNode, for native hover delegation
134
- const groupFlatNodeById = useMemo(() => {
135
- const map = new Map<string, FlatNode>();
136
- for (const fn of flatNodes) {
137
- if (!fn.node.isSelectable) {
138
- map.set(fn.node.id, fn);
139
- }
140
- }
141
- return map;
142
- }, [flatNodes]);
143
-
144
- const checkScroll = useCallback(() => {
145
- const el = scrollRef.current;
146
- if (!el) return;
147
- const canLeft = el.scrollLeft > 0;
148
- const canRight = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
149
- setCanScrollLeft(canLeft);
150
- setCanScrollRight(canRight);
151
- }, []);
152
-
153
- const hasOverflow = canScrollLeft || canScrollRight;
154
-
155
- useEffect(() => {
156
- onOverflowChange?.(hasOverflow);
157
- }, [hasOverflow, onOverflowChange]);
158
-
159
- // Map from descendant entry ID to the group pill node ID
160
- const descendantToGroupId = useMemo(() => {
161
- const map = new Map<string, string>();
162
- function collectDescendants(node: EntryTreeItem, groupId: string) {
163
- if (node.isSelectable) {
164
- map.set(node.id, groupId);
165
- }
166
- for (const child of node.children) {
167
- collectDescendants(child, groupId);
168
- }
169
- }
170
- for (const { node } of flatNodes) {
171
- if (!node.isSelectable) {
172
- for (const child of node.children) {
173
- collectDescendants(child, node.id);
174
- }
175
- }
176
- }
177
- return map;
178
- }, [flatNodes]);
179
-
180
- // Auto-scroll selected pill into view
181
- useEffect(() => {
182
- if (!selectedEntryId || !scrollRef.current) return;
183
- // Try direct match first (leaf pill)
184
- let targetId = selectedEntryId;
185
- let pill = scrollRef.current.querySelector(
186
- `[data-entry-id="${targetId}"]`,
187
- ) as HTMLElement | null;
188
- // Fall back to ancestor group pill
189
- if (!pill) {
190
- targetId = descendantToGroupId.get(selectedEntryId) || '';
191
- if (targetId) {
192
- pill = scrollRef.current.querySelector(
193
- `[data-entry-id="${targetId}"]`,
194
- ) as HTMLElement | null;
195
- }
196
- }
197
- if (!pill) return;
198
-
199
- const container = scrollRef.current;
200
- const PAD = 12; // matches scrollPaddingInline, covers corner flares
201
- const start = performance.now();
202
- let rafId = 0;
203
-
204
- // Ease toward the pill with the target recomputed EVERY frame. A one-shot
205
- // scrollIntoView({behavior: 'smooth'}) computes its destination upfront —
206
- // the selection triggers a host navigation whose follow-up layout changes
207
- // (content reload, arrow show/hide) shift the container mid-animation and
208
- // the pill lands clipped. Live recomputation keeps this one continuous
209
- // ease-out motion that always converges, with no visible second jump.
210
- const step = () => {
211
- const containerRect = container.getBoundingClientRect();
212
- const pillRect = pill.getBoundingClientRect();
213
- let delta = 0;
214
- if (pillRect.left < containerRect.left + PAD) {
215
- delta = pillRect.left - containerRect.left - PAD;
216
- } else if (pillRect.right > containerRect.right - PAD) {
217
- delta = pillRect.right - containerRect.right + PAD;
218
- }
219
- if (Math.abs(delta) < 1) return; // arrived
220
- container.scrollLeft += delta * 0.3;
221
- if (performance.now() - start < 600) {
222
- rafId = requestAnimationFrame(step);
223
- }
224
- };
225
- rafId = requestAnimationFrame(step);
226
-
227
- // Stop animating if the user takes over scrolling
228
- const cancelOnUserScroll = () => cancelAnimationFrame(rafId);
229
- container.addEventListener('wheel', cancelOnUserScroll, {
230
- once: true,
231
- passive: true,
232
- });
233
- container.addEventListener('touchstart', cancelOnUserScroll, {
234
- once: true,
235
- passive: true,
236
- });
237
-
238
- return () => {
239
- cancelAnimationFrame(rafId);
240
- container.removeEventListener('wheel', cancelOnUserScroll);
241
- container.removeEventListener('touchstart', cancelOnUserScroll);
242
- };
243
- }, [selectedEntryId, descendantToGroupId]);
244
-
245
- useEffect(() => {
246
- checkScroll();
247
- const el = scrollRef.current;
248
- if (!el) return;
249
-
250
- const observer = new ResizeObserver(checkScroll);
251
- observer.observe(el);
252
- el.addEventListener('scroll', checkScroll, { passive: true });
253
- return () => {
254
- observer.disconnect();
255
- el.removeEventListener('scroll', checkScroll);
256
- };
257
- }, [flatNodes, checkScroll]);
258
-
259
- const scroll = useCallback((direction: 'left' | 'right') => {
260
- const el = scrollRef.current;
261
- if (!el) return;
262
- el.scrollBy({
263
- left: direction === 'left' ? -150 : 150,
264
- behavior: 'smooth',
265
- });
266
- }, []);
267
-
268
- const closeGroupPopover = useCallback(() => {
269
- activeGroupIdRef.current = null;
270
- setActiveGroup(null);
271
- setGroupSearchQuery('');
272
- if (hoverCloseTimerRef.current) {
273
- clearTimeout(hoverCloseTimerRef.current);
274
- hoverCloseTimerRef.current = null;
275
- }
276
- }, []);
277
-
278
- const cancelCloseTimer = useCallback(() => {
279
- if (hoverCloseTimerRef.current) {
280
- clearTimeout(hoverCloseTimerRef.current);
281
- hoverCloseTimerRef.current = null;
282
- }
283
- }, []);
284
-
285
- // Delayed close — cancelled when the mouse re-enters the pill or popover.
286
- // The :hover guard prevents closing when a transient leave fired but the
287
- // cursor is still over the pill or popover (e.g. layout-shift artifacts).
288
- const scheduleClosePopover = useCallback(() => {
289
- cancelCloseTimer();
290
- hoverCloseTimerRef.current = setTimeout(() => {
291
- const pillEl = activeGroupIdRef.current
292
- ? groupPillRefs.current.get(activeGroupIdRef.current)
293
- : null;
294
- if (pillEl?.matches(':hover') || popoverRef.current?.matches(':hover')) {
295
- return;
296
- }
297
- closeGroupPopover();
298
- }, 200);
299
- }, [cancelCloseTimer, closeGroupPopover]);
300
-
301
- // Core open logic shared by click and hover paths
302
- const openGroupPopoverCore = useCallback(
303
- (flatNode: FlatNode) => {
304
- // Clear any pending close timer
305
- cancelCloseTimer();
306
-
307
- // Skip if already open for this group (hover keeps it open)
308
- if (activeGroup?.node.id === flatNode.node.id) return;
309
-
310
- setGroupSearchQuery('');
311
- activeGroupIdRef.current = flatNode.node.id;
312
- setActiveGroup(flatNode);
313
-
314
- // Check Shadow DOM portal
315
- const triggerEl = groupPillRefs.current.get(flatNode.node.id);
316
- if (triggerEl) {
317
- const rootNode = triggerEl.getRootNode();
318
- if (rootNode instanceof ShadowRoot) {
319
- const existing = rootNode.querySelector<HTMLElement>(
320
- '[data-bs-entry-select-group-portal]',
321
- );
322
- if (existing) {
323
- setGroupPortalContainer(existing);
324
- } else {
325
- const container = document.createElement('div');
326
- container.setAttribute('data-bs-entry-select-group-portal', 'true');
327
- rootNode.appendChild(container);
328
- setGroupPortalContainer(container);
329
- }
330
- } else {
331
- setGroupPortalContainer(null);
332
- }
333
- }
334
-
335
- // Position synchronously — the pill is already mounted, so its rect is
336
- // available immediately. Batching this with setActiveGroup ensures the
337
- // popover mounts at the correct position in the same commit; a deferred
338
- // (rAF) position would render one frame in-flow at the end of the body,
339
- // shifting layout and firing a spurious mouseleave on the pill.
340
- updatePopoverPosition();
341
- },
342
- [activeGroup, cancelCloseTimer, updatePopoverPosition],
343
- );
344
-
345
- const openGroupPopover = useCallback(
346
- (flatNode: FlatNode, event: React.MouseEvent) => {
347
- event.stopPropagation();
348
- if (activeGroup?.node.id === flatNode.node.id) {
349
- activeGroupIdRef.current = null;
350
- setActiveGroup(null);
351
- setGroupSearchQuery('');
352
- return;
353
- }
354
- openGroupPopoverCore(flatNode);
355
- },
356
- [activeGroup, openGroupPopoverCore],
357
- );
358
-
359
- // Hover handlers for tabs mode.
360
- //
361
- // NOTE: these are driven by NATIVE mouseover/mouseout listeners (attached in
362
- // the effects below), not React's onMouseEnter/onMouseLeave. React's
363
- // synthetic enter/leave events do not fire when the pointer crosses from
364
- // light DOM into a shadow root — which is exactly what happens when the
365
- // mouse enters a tab from the host content area below the tab bar.
366
- const handleNativeOver = useCallback(
367
- (e: MouseEvent) => {
368
- const t = e.target as HTMLElement | null;
369
- if (!t) return;
370
- if (popoverRef.current?.contains(t)) {
371
- cancelCloseTimer();
372
- return;
373
- }
374
- const pillEl = t.closest?.('[data-entry-id]') as HTMLElement | null;
375
- const flatNode = pillEl
376
- ? groupFlatNodeById.get(pillEl.getAttribute('data-entry-id') ?? '')
377
- : undefined;
378
- if (flatNode) {
379
- cancelCloseTimer();
380
- openGroupPopoverCore(flatNode);
381
- } else {
382
- // Hovering a leaf pill or empty slider area — close any open group popover
383
- scheduleClosePopover();
384
- }
385
- },
386
- [cancelCloseTimer, groupFlatNodeById, openGroupPopoverCore, scheduleClosePopover],
387
- );
388
-
389
- const handleNativeOut = useCallback(
390
- (e: MouseEvent) => {
391
- const to = e.relatedTarget as HTMLElement | null;
392
- if (to && scrollRef.current?.contains(to)) return;
393
- if (to && popoverRef.current?.contains(to)) return;
394
- scheduleClosePopover();
395
- },
396
- [scheduleClosePopover],
397
- );
398
-
399
- // Keep latest handlers in refs so listeners stay attached across re-renders
400
- const handleNativeOverRef = useRef(handleNativeOver);
401
- const handleNativeOutRef = useRef(handleNativeOut);
402
- useEffect(() => {
403
- handleNativeOverRef.current = handleNativeOver;
404
- handleNativeOutRef.current = handleNativeOut;
405
- });
406
-
407
- // Native hover listeners on the scroll container (delegated to group pills)
408
- useEffect(() => {
409
- const el = scrollRef.current;
410
- if (!el || !isTabs) return;
411
- const over = (e: MouseEvent) => handleNativeOverRef.current(e);
412
- const out = (e: MouseEvent) => handleNativeOutRef.current(e);
413
- el.addEventListener('mouseover', over);
414
- el.addEventListener('mouseout', out);
415
- return () => {
416
- el.removeEventListener('mouseover', over);
417
- el.removeEventListener('mouseout', out);
418
- };
419
- }, [isTabs]);
420
-
421
- // Native hover listeners on the group popover (rendered in a portal subtree)
422
- useEffect(() => {
423
- const pop = popoverRef.current;
424
- if (!isTabs || !activeGroup || !pop) return;
425
- const over = (e: MouseEvent) => handleNativeOverRef.current(e);
426
- const out = (e: MouseEvent) => handleNativeOutRef.current(e);
427
- pop.addEventListener('mouseover', over);
428
- pop.addEventListener('mouseout', out);
429
- return () => {
430
- pop.removeEventListener('mouseover', over);
431
- pop.removeEventListener('mouseout', out);
432
- };
433
- }, [isTabs, activeGroup]);
434
-
435
- const handleGroupPopoverSelect = useCallback(
436
- (result: BsEntrySelectChangeResult) => {
437
- onSelect(result);
438
- closeGroupPopover();
439
- },
440
- [onSelect, closeGroupPopover],
441
- );
442
-
443
- // Click outside for group popover
444
- useEffect(() => {
445
- if (!activeGroup) return;
446
-
447
- const handleClickOutside = (e: MouseEvent) => {
448
- const path = e.composedPath();
449
- if (popoverRef.current && path.includes(popoverRef.current)) return;
450
- const triggerEl = groupPillRefs.current.get(activeGroup.node.id);
451
- if (triggerEl && path.includes(triggerEl)) return;
452
- closeGroupPopover();
453
- };
454
-
455
- const handleScroll = () => updatePopoverPosition();
456
-
457
- document.addEventListener('mousedown', handleClickOutside);
458
- window.addEventListener('scroll', handleScroll, true);
459
- window.addEventListener('resize', handleScroll);
460
-
461
- return () => {
462
- document.removeEventListener('mousedown', handleClickOutside);
463
- window.removeEventListener('scroll', handleScroll, true);
464
- window.removeEventListener('resize', handleScroll);
465
- };
466
- }, [activeGroup, closeGroupPopover, updatePopoverPosition]);
467
-
468
- // Keyboard escape
469
- useEffect(() => {
470
- if (!activeGroup) return;
471
- const handleEscape = (e: KeyboardEvent) => {
472
- if (e.key === 'Escape') closeGroupPopover();
473
- };
474
- document.addEventListener('keydown', handleEscape);
475
- return () => document.removeEventListener('keydown', handleEscape);
476
- }, [activeGroup, closeGroupPopover]);
477
-
478
- if (flatNodes.length === 0) return null;
479
-
480
- const hasGroupSearch = groupSearchQuery.trim().length > 0;
481
-
482
- const sliderBody = (
483
- <>
484
- {/* Left arrow: always in DOM, width transition */}
485
- <span
486
- className={cn(
487
- transCls,
488
- 'inline-flex overflow-hidden items-center',
489
- canScrollLeft ? 'w-[26px] opacity-100 mr-2.5' : 'w-0 opacity-0 mr-0',
490
- )}
491
- >
492
- <IconBtn
493
- rounded
494
- icon={<LeftArrowIcon />}
495
- onClick={() => scroll('left')}
496
- />
497
- </span>
498
-
499
- <div
500
- ref={scrollRef}
501
- // scroll-padding: scrollIntoView aligns pills this far from the
502
- // container edge, keeping the selected tab's outward corner flares
503
- // (protruding 9px) fully visible instead of clipped
504
- style={isTabs ? { scrollPaddingInline: 12 } : undefined}
505
- className={cn(
506
- 'flex-1 overflow-x-auto scrollbar-hide flex min-w-0',
507
- isTabs
508
- // px compensates -mx: keeps pills at the same visual position
509
- // while letting bottom corner flares paint without being clipped
510
- ? 'items-end gap-0 overflow-y-hidden px-[10px] -mx-[10px] border-b-[#e0e0e0] before:content-[""] before:absolute before:bottom-[0px] before:left-[-20vw] before:right-0 before:h-[1px] before:w-[300vw] before:bg-[#e0e0e0] h-full'
511
- : 'items-center gap-2.5',
512
- )}
513
- >
514
- {flatNodes.map((flatNode, index) => {
515
- const { node, appId, section } = flatNode;
516
- const isLeaf = node.isSelectable;
517
- const isSelected = isLeaf && selectedEntryId === node.id;
518
- // Active tab styling applies only when a descendant entry is
519
- // selected — hovering/clicking to open the popover must NOT make
520
- // the group look active (that would show two active tabs at once)
521
- const isGroupActive = node?.children.findIndex(c => c.id === selectedEntryId) >= 0;
522
- // Popover-open is a separate, transient state — drives the chevron
523
- const isGroupPopoverOpen = activeGroup?.node.id === node.id;
524
-
525
- // Divider between inactive tabs: always rendered in tabs mode so
526
- // the strip width never shifts on selection change — only its
527
- // visibility toggles (hidden next to the active tab, which has
528
- // its own borders)
529
- const isNodeActive = (fn: FlatNode | null): boolean => {
530
- if (!fn) return false;
531
- return fn.node.isSelectable
532
- ? selectedEntryId === fn.node.id
533
- : fn.node.children.findIndex((c) => c.id === selectedEntryId) >= 0;
534
- };
535
- const dividerVisible =
536
- !isNodeActive(flatNode) && !isNodeActive(flatNodes[index - 1]);
537
- const divider = isTabs && index > 0 ? (
538
- <span
539
- aria-hidden
540
- className={cn(
541
- 'self-center shrink-0 flex items-center justify-center w-[1px]',
542
- !dividerVisible && 'invisible',
543
- )}
544
- >
545
- <DividerIcon />
546
- </span>
547
- ) : null;
548
-
549
- const setPillRef = (el: HTMLButtonElement | null) => {
550
- if (el) {
551
- groupPillRefs.current.set(node.id, el);
552
- } else {
553
- groupPillRefs.current.delete(node.id);
554
- }
555
- };
556
-
557
- if (isLeaf) {
558
- return (
559
- <Fragment key={node.id}>
560
- {divider}
561
- <button
562
- data-entry-id={node.id}
563
- type="button"
564
- onClick={() => {
565
- if (node.entry) {
566
- onSelect({ entry: node.entry, appId, section });
567
- }
568
- }}
569
- className={cn(
570
- 'flex items-center shrink-0 max-w-[240px]',
571
- transCls,
572
- isTabs
573
- ? [
574
- // Button stays h-full so text keeps its Y position;
575
- // the visual tab box is the decorative span below
576
- 'group px-[10px] py-[8px] relative z-10 h-full',
577
- isSelected ? 'text-[#0265FF]' : 'text-[#030303]',
578
- ]
579
- : [
580
- 'px-[10px] py-[7px] rounded-[4px] gap-[6px]',
581
- isSelected
582
- ? 'border border-[#F0F6FF] bg-[#F0F6FF] text-[#0265FF]'
583
- : 'border border-[#E0E0E0] text-[#030303] hover:bg-[#F0F6FF]',
584
- ],
585
- )}
586
- >
587
- {/* Visual tab box — side borders stop 10px above the bar so
588
- the concave corner arcs can continue them; transparent
589
- borders when unselected (decorative only, no layout shift).
590
- No transCls here: the fill strip and corner flares below
591
- appear/disappear instantly on selection change, so this
592
- box must switch instantly too — otherwise its fading
593
- background leaves a ghost image on the deselected tab */}
594
- {isTabs && (
595
- <span
596
- aria-hidden
597
- className={cn(
598
- 'pointer-events-none absolute inset-x-0 top-[6px] bottom-[10px] border-r border-t border-l border-b-none rounded-t-[10px] rounded-b-none',
599
- isSelected
600
- ? 'border-[#e0e0e0] bg-[#f6f7fa]'
601
- : 'border-transparent',
602
- )}
603
- />
604
- )}
605
- {isTabs && isSelected && (
606
- <>
607
- {/* Fill below the tab body — covers the bar line under the tab */}
608
- <span
609
- aria-hidden
610
- className="pointer-events-none absolute inset-x-0 bottom-0 h-[10px] bg-[#f6f7fa]"
611
- />
612
- {/* Concave corner flares. The square is shifted 1px
613
- inward (left/right-[-9px]) so the full border ring —
614
- including its tangent junctions with the side border
615
- (vertical tangent) and the bar line (horizontal
616
- tangent) — fits inside the element and paints above
617
- the bottom fill strip */}
618
- <span
619
- aria-hidden
620
- className="pointer-events-none absolute left-[-9px] bottom-0 w-[10px] h-[10px]"
621
- style={{
622
- background:
623
- 'radial-gradient(circle at 0% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
624
- }}
625
- />
626
- <span
627
- aria-hidden
628
- className="pointer-events-none absolute right-[-9px] bottom-0 w-[10px] h-[10px]"
629
- style={{
630
- background:
631
- 'radial-gradient(circle at 100% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
632
- }}
633
- />
634
- </>
635
- )}
636
- {/* In tabs mode, hover highlights only this icon+text chip —
637
- the tab background itself stays unchanged. No transCls:
638
- on selection the hover bg must clear instantly, otherwise
639
- it fades out slowly and leaves a chip-shaped ghost on the
640
- newly selected tab */}
641
- <span
642
- className={cn(
643
- 'flex items-center gap-[6px] min-w-0',
644
- isTabs &&
645
- cn(
646
- 'relative rounded-[6px] px-[20px] py-[6px]',
647
- !isSelected && 'group-hover:bg-[#F0F6FF]',
648
- ),
649
- )}
650
- >
651
- <i
652
- className={
653
- node.iconClass
654
- ? node.iconClass
655
- : node.entry
656
- ? getEntryIconClass(node.entry)
657
- : 'node-icon icon-bsy-form icon-new-bsy-form-25'
658
- }
659
- style={{ fontSize: 16, lineHeight: 1 }}
660
- />
661
- <span className="text-[14px] leading-[normal] truncate">
662
- {node.name}
663
- </span>
664
- </span>
665
- </button>
666
- </Fragment>
667
- );
668
- }
669
-
670
- // Group pill (has descendants)
671
- return (
672
- <Fragment key={node.id}>
673
- {divider}
674
- <button
675
- data-entry-id={node.id}
676
- ref={setPillRef}
677
- type="button"
678
- {...(!isTabs
679
- ? {
680
- onClick: (e: React.MouseEvent) => openGroupPopover(flatNode, e),
681
- }
682
- : {})}
683
- className={cn(
684
- 'flex items-center shrink-0 max-w-[240px]',
685
- transCls,
686
- isTabs
687
- ? [
688
- // Button stays h-full so text keeps its Y position;
689
- // the visual tab box is the decorative span below
690
- 'group gap-[4px] px-[10px] py-[8px] relative z-10 h-full',
691
- isGroupActive ? 'text-[#0265FF]' : 'text-[#030303]',
692
- ]
693
- : [
694
- 'gap-[10px] px-[10px] py-[7px] rounded-[4px]',
695
- isGroupActive
696
- ? 'border border-[#0265FF] text-[#030303]'
697
- : 'border border-[#E0E0E0] text-[#030303] hover:bg-[#F0F6FF]',
698
- ],
699
- )}
700
- >
701
- {/* Visual tab box — side borders stop 10px above the bar so
702
- the concave corner arcs can continue them; transparent
703
- borders when inactive (decorative only, no layout shift) */}
704
- {isTabs && (
705
- <span
706
- aria-hidden
707
- className={cn(
708
- 'pointer-events-none absolute inset-x-0 top-[6px] bottom-[10px] border-r border-t border-l border-b-none rounded-t-[10px] rounded-b-none',
709
- isGroupActive
710
- ? 'border-[#e0e0e0] bg-[#f6f7fa]'
711
- : 'border-transparent',
712
- )}
713
- />
714
- )}
715
- {isTabs && isGroupActive && (
716
- <>
717
- {/* Fill below the tab body — covers the bar line under the tab */}
718
- <span
719
- aria-hidden
720
- className="pointer-events-none absolute inset-x-0 bottom-0 h-[10px] bg-[#f6f7fa]"
721
- />
722
- {/* Concave corner flares. The square is shifted 1px
723
- inward (left/right-[-9px]) so the full border ring —
724
- including its tangent junctions with the side border
725
- (vertical tangent) and the bar line (horizontal
726
- tangent) — fits inside the element and paints above
727
- the bottom fill strip */}
728
- <span
729
- aria-hidden
730
- className="pointer-events-none absolute left-[-9px] bottom-0 w-[10px] h-[10px]"
731
- style={{
732
- background:
733
- 'radial-gradient(circle at 0% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
734
- }}
735
- />
736
- <span
737
- aria-hidden
738
- className="pointer-events-none absolute right-[-9px] bottom-0 w-[10px] h-[10px]"
739
- style={{
740
- background:
741
- 'radial-gradient(circle at 100% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
742
- }}
743
- />
744
- </>
745
- )}
746
- {/* In tabs mode, hover highlights only this icon+text chip —
747
- the tab background itself stays unchanged. No transCls:
748
- on activation the hover bg must clear instantly, otherwise
749
- it fades out slowly and leaves a chip-shaped ghost on the
750
- newly activated tab */}
751
- <span
752
- className={cn(
753
- 'flex items-center gap-[6px] min-w-0 flex-1',
754
- isTabs &&
755
- cn(
756
- 'relative rounded-[6px] px-[20px] py-[6px]',
757
- !isGroupActive && 'group-hover:bg-[#F0F6FF]',
758
- ),
759
- )}
760
- >
761
- <i
762
- className="icon-bsy-folder-close"
763
- style={{ fontSize: 16, lineHeight: 1 }}
764
- />
765
- <span className="text-[14px] leading-[normal] truncate">
766
- {node.name}
767
- </span>
768
- </span>
769
- <span
770
- className={cn(
771
- 'relative shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
772
- transCls,
773
- isGroupPopoverOpen && 'rotate-90',
774
- )}
775
- >
776
- <ChevronRightIcon size={16} />
777
- </span>
778
- </button>
779
- </Fragment>
780
- );
781
- })}
782
- </div>
783
-
784
- {/* Right arrow: always in DOM, width transition */}
785
- <span
786
- className={cn(
787
- transCls,
788
- 'inline-flex overflow-hidden items-center',
789
- canScrollRight ? 'w-[26px] opacity-100 ml-2.5' : 'w-0 opacity-0 ml-0',
790
- )}
791
- >
792
- <IconBtn
793
- rounded
794
- icon={
795
- <span className="rotate-180 inline-flex">
796
- <LeftArrowIcon />
797
- </span>
798
- }
799
- onClick={() => scroll('right')}
800
- />
801
- </span>
802
- </>
803
- );
804
-
805
- const tabCls = isTabs ? 'h-full px-[10px] items-end' : "items-center"
806
-
807
- return (
808
- <div className={classNames("flex flex-1 min-w-0", tabCls)}>
809
- {isTabs ? (
810
- <div className="relative flex items-stretch flex-1 min-w-0 h-full">
811
- {sliderBody}
812
- </div>
813
- ) : (
814
- sliderBody
815
- )}
816
-
817
- {/* Group subtree popover */}
818
- {activeGroup &&
819
- (() => {
820
- const content = (
821
- <div
822
- ref={popoverRef}
823
- style={popoverStyle}
824
- className={cn(
825
- 'bg-white rounded-[10px] shadow-[0px_0px_10px_0px_rgba(0,0,0,0.04)] p-[4px]',
826
- 'flex flex-col gap-[4px]',
827
- transCls,
828
- )}
829
- >
830
- {/* Transparent hover bridges: keep the mouse "inside" the popover
831
- while crossing the gap between pill and popover */}
832
- {isTabs && (
833
- <>
834
- <div className="absolute left-0 right-0 -top-[6px] h-[6px]" />
835
- <div className="absolute left-0 right-0 -bottom-[6px] h-[6px]" />
836
- </>
837
- )}
838
- <SearchInput
839
- placeholder='搜索表单名称'
840
- value={groupSearchQuery}
841
- onChange={setGroupSearchQuery}
842
- />
843
- <div className="flex flex-col gap-[4px] max-h-[300px] overflow-y-auto">
844
- {filteredGroupTree.length > 0 ? (
845
- filteredGroupTree.map((child) => (
846
- <TreeItemNode
847
- key={child.id}
848
- item={child}
849
- selectedEntryId={selectedEntryId}
850
- appId={activeGroup.appId}
851
- section={activeGroup.section}
852
- onSelect={handleGroupPopoverSelect}
853
- />
854
- ))
855
- ) : hasGroupSearch ? (
856
- <div className="text-[12px] text-[#999999] py-[8px] text-center">
857
- 无匹配结果
858
- </div>
859
- ) : (
860
- <div className="text-[12px] text-[#999999] py-[8px] text-center">
861
- 暂无表单
862
- </div>
863
- )}
864
- </div>
865
- </div>
866
- );
867
-
868
- return createPortal(content, groupPortalContainer || document.body);
869
- })()}
870
- </div>
871
- );
872
- }
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useCallback,
6
+ useMemo,
7
+ Fragment,
8
+ } from 'react';
9
+ import { createPortal } from 'react-dom';
10
+ import { cn } from '../../lib/utils';
11
+ import { transCls } from '../../const/ui';
12
+ import { IconBtn } from '../icon-btn';
13
+ import { LeftArrowIcon, SolidArrowDownIcon, DividerIcon } from '../bs-icons';
14
+ import {
15
+ buildEntryTreeFromAliasMap,
16
+ getEntryIconClass,
17
+ filterTreeBySearch,
18
+ buildFlowCenterTree,
19
+ } from './build-tree';
20
+ import { SearchInput } from './search-input';
21
+ import { TreeItemNode } from './tree-item-node';
22
+ import type { IAppDetail } from '@baishuyun/types';
23
+ import type {
24
+ EntryTreeItem,
25
+ BsEntrySelectChangeResult,
26
+ BsEntrySelectSliderVariant,
27
+ } from './types';
28
+ import classNames from 'classnames';
29
+ import { useSelectPopoverPosition } from '../../hooks/use-select-popover-position';
30
+
31
+ const ChevronRightIcon = ({ size }: { size?: number }) => (
32
+ <SolidArrowDownIcon className="-rotate-90" size={size} />
33
+ );
34
+
35
+ export interface FlatNode {
36
+ node: EntryTreeItem;
37
+ appId: string;
38
+ section: 'current' | 'other';
39
+ }
40
+
41
+ export function ScrollSlider({
42
+ appDetails,
43
+ currentAppId,
44
+ selectedEntryId,
45
+ onSelect,
46
+ onOverflowChange,
47
+ showCurrentAppEntriesOnly = false,
48
+ variant = 'default',
49
+ }: {
50
+ appDetails: Record<string, IAppDetail>;
51
+ currentAppId?: string;
52
+ selectedEntryId?: string;
53
+ onSelect: (result: BsEntrySelectChangeResult) => void;
54
+ onOverflowChange?: (hasOverflow: boolean) => void;
55
+ showCurrentAppEntriesOnly?: boolean;
56
+ variant?: BsEntrySelectSliderVariant;
57
+ }) {
58
+ const scrollRef = useRef<HTMLDivElement>(null);
59
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
60
+ const [canScrollRight, setCanScrollRight] = useState(false);
61
+
62
+ // Group popover state
63
+ const [activeGroup, setActiveGroup] = useState<FlatNode | null>(null);
64
+ const [groupSearchQuery, setGroupSearchQuery] = useState('');
65
+ const popoverRef = useRef<HTMLDivElement>(null);
66
+ const groupPillRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
67
+ const [groupPortalContainer, setGroupPortalContainer] = useState<HTMLElement | null>(null);
68
+ const activeGroupIdRef = useRef<string | null>(null);
69
+
70
+ const { popoverStyle, updatePopoverPosition } = useSelectPopoverPosition(
71
+ () => {
72
+ if (!activeGroupIdRef.current) return null;
73
+ return groupPillRefs.current.get(activeGroupIdRef.current) ?? null;
74
+ },
75
+ { estimatedHeight: 340 },
76
+ );
77
+
78
+ const hoverCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
79
+
80
+ const isTabs = variant === 'tabs';
81
+ const showFlowCenter = isTabs && (appDetails[currentAppId ?? '']?.isFlow ?? false);
82
+
83
+ // Build first-level nodes from all apps (no search filter)
84
+ const flatNodes = useMemo<FlatNode[]>(() => {
85
+ const flowCenterNodes: FlatNode[] = [];
86
+
87
+ // Flow center — first tab in tabs mode for flow-type apps
88
+ if (showFlowCenter) {
89
+ const flowTree = buildFlowCenterTree();
90
+ flowCenterNodes.push(
91
+ ...flowTree.map((node) => ({
92
+ node,
93
+ appId: currentAppId ?? '',
94
+ section: 'current' as const,
95
+ })),
96
+ );
97
+ }
98
+
99
+ const appIds = Object.keys(appDetails);
100
+ const currentFirst: FlatNode[] = [];
101
+ const otherNodes: FlatNode[] = [];
102
+
103
+ for (const appId of appIds) {
104
+ const detail = appDetails[appId];
105
+ const tree = buildEntryTreeFromAliasMap(detail, '');
106
+ const section = appId === currentAppId ? 'current' as const : 'other' as const;
107
+ const mapped = tree.map((node) => ({ node, appId, section }));
108
+ if (section === 'current') {
109
+ currentFirst.push(...mapped);
110
+ } else {
111
+ otherNodes.push(...mapped);
112
+ }
113
+ }
114
+
115
+ const merged = [...flowCenterNodes, ...currentFirst, ...otherNodes];
116
+ return showCurrentAppEntriesOnly ? [...flowCenterNodes, ...currentFirst] : merged;
117
+ }, [
118
+ appDetails,
119
+ currentAppId,
120
+ showCurrentAppEntriesOnly,
121
+ showFlowCenter,
122
+ Object.keys(appDetails)
123
+ .map((id) => (appDetails[id]?.entryList?.length ?? 0))
124
+ .join(','),
125
+ ]);
126
+
127
+ // Filtered tree for the active group popover
128
+ const filteredGroupTree = useMemo(() => {
129
+ if (!activeGroup) return [];
130
+ return filterTreeBySearch(activeGroup.node.children, groupSearchQuery);
131
+ }, [activeGroup, groupSearchQuery]);
132
+
133
+ // Map from group pill node ID → FlatNode, for native hover delegation
134
+ const groupFlatNodeById = useMemo(() => {
135
+ const map = new Map<string, FlatNode>();
136
+ for (const fn of flatNodes) {
137
+ if (!fn.node.isSelectable) {
138
+ map.set(fn.node.id, fn);
139
+ }
140
+ }
141
+ return map;
142
+ }, [flatNodes]);
143
+
144
+ const checkScroll = useCallback(() => {
145
+ const el = scrollRef.current;
146
+ if (!el) return;
147
+ const canLeft = el.scrollLeft > 0;
148
+ const canRight = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
149
+ setCanScrollLeft(canLeft);
150
+ setCanScrollRight(canRight);
151
+ }, []);
152
+
153
+ const hasOverflow = canScrollLeft || canScrollRight;
154
+
155
+ useEffect(() => {
156
+ onOverflowChange?.(hasOverflow);
157
+ }, [hasOverflow, onOverflowChange]);
158
+
159
+ // Map from descendant entry ID to the group pill node ID
160
+ const descendantToGroupId = useMemo(() => {
161
+ const map = new Map<string, string>();
162
+ function collectDescendants(node: EntryTreeItem, groupId: string) {
163
+ if (node.isSelectable) {
164
+ map.set(node.id, groupId);
165
+ }
166
+ for (const child of node.children) {
167
+ collectDescendants(child, groupId);
168
+ }
169
+ }
170
+ for (const { node } of flatNodes) {
171
+ if (!node.isSelectable) {
172
+ for (const child of node.children) {
173
+ collectDescendants(child, node.id);
174
+ }
175
+ }
176
+ }
177
+ return map;
178
+ }, [flatNodes]);
179
+
180
+ // Auto-scroll selected pill into view
181
+ useEffect(() => {
182
+ if (!selectedEntryId || !scrollRef.current) return;
183
+ // Try direct match first (leaf pill)
184
+ let targetId = selectedEntryId;
185
+ let pill = scrollRef.current.querySelector(
186
+ `[data-entry-id="${targetId}"]`,
187
+ ) as HTMLElement | null;
188
+ // Fall back to ancestor group pill
189
+ if (!pill) {
190
+ targetId = descendantToGroupId.get(selectedEntryId) || '';
191
+ if (targetId) {
192
+ pill = scrollRef.current.querySelector(
193
+ `[data-entry-id="${targetId}"]`,
194
+ ) as HTMLElement | null;
195
+ }
196
+ }
197
+ if (!pill) return;
198
+
199
+ const container = scrollRef.current;
200
+ const PAD = 12; // matches scrollPaddingInline, covers corner flares
201
+ const start = performance.now();
202
+ let rafId = 0;
203
+
204
+ // Ease toward the pill with the target recomputed EVERY frame. A one-shot
205
+ // scrollIntoView({behavior: 'smooth'}) computes its destination upfront —
206
+ // the selection triggers a host navigation whose follow-up layout changes
207
+ // (content reload, arrow show/hide) shift the container mid-animation and
208
+ // the pill lands clipped. Live recomputation keeps this one continuous
209
+ // ease-out motion that always converges, with no visible second jump.
210
+ const step = () => {
211
+ const containerRect = container.getBoundingClientRect();
212
+ const pillRect = pill.getBoundingClientRect();
213
+ let delta = 0;
214
+ if (pillRect.left < containerRect.left + PAD) {
215
+ delta = pillRect.left - containerRect.left - PAD;
216
+ } else if (pillRect.right > containerRect.right - PAD) {
217
+ delta = pillRect.right - containerRect.right + PAD;
218
+ }
219
+ if (Math.abs(delta) < 1) return; // arrived
220
+ container.scrollLeft += delta * 0.3;
221
+ if (performance.now() - start < 600) {
222
+ rafId = requestAnimationFrame(step);
223
+ }
224
+ };
225
+ rafId = requestAnimationFrame(step);
226
+
227
+ // Stop animating if the user takes over scrolling
228
+ const cancelOnUserScroll = () => cancelAnimationFrame(rafId);
229
+ container.addEventListener('wheel', cancelOnUserScroll, {
230
+ once: true,
231
+ passive: true,
232
+ });
233
+ container.addEventListener('touchstart', cancelOnUserScroll, {
234
+ once: true,
235
+ passive: true,
236
+ });
237
+
238
+ return () => {
239
+ cancelAnimationFrame(rafId);
240
+ container.removeEventListener('wheel', cancelOnUserScroll);
241
+ container.removeEventListener('touchstart', cancelOnUserScroll);
242
+ };
243
+ }, [selectedEntryId, descendantToGroupId]);
244
+
245
+ useEffect(() => {
246
+ checkScroll();
247
+ const el = scrollRef.current;
248
+ if (!el) return;
249
+
250
+ const observer = new ResizeObserver(checkScroll);
251
+ observer.observe(el);
252
+ el.addEventListener('scroll', checkScroll, { passive: true });
253
+ return () => {
254
+ observer.disconnect();
255
+ el.removeEventListener('scroll', checkScroll);
256
+ };
257
+ }, [flatNodes, checkScroll]);
258
+
259
+ const scroll = useCallback((direction: 'left' | 'right') => {
260
+ const el = scrollRef.current;
261
+ if (!el) return;
262
+ el.scrollBy({
263
+ left: direction === 'left' ? -150 : 150,
264
+ behavior: 'smooth',
265
+ });
266
+ }, []);
267
+
268
+ const closeGroupPopover = useCallback(() => {
269
+ activeGroupIdRef.current = null;
270
+ setActiveGroup(null);
271
+ setGroupSearchQuery('');
272
+ if (hoverCloseTimerRef.current) {
273
+ clearTimeout(hoverCloseTimerRef.current);
274
+ hoverCloseTimerRef.current = null;
275
+ }
276
+ }, []);
277
+
278
+ const cancelCloseTimer = useCallback(() => {
279
+ if (hoverCloseTimerRef.current) {
280
+ clearTimeout(hoverCloseTimerRef.current);
281
+ hoverCloseTimerRef.current = null;
282
+ }
283
+ }, []);
284
+
285
+ // Delayed close — cancelled when the mouse re-enters the pill or popover.
286
+ // The :hover guard prevents closing when a transient leave fired but the
287
+ // cursor is still over the pill or popover (e.g. layout-shift artifacts).
288
+ const scheduleClosePopover = useCallback(() => {
289
+ cancelCloseTimer();
290
+ hoverCloseTimerRef.current = setTimeout(() => {
291
+ const pillEl = activeGroupIdRef.current
292
+ ? groupPillRefs.current.get(activeGroupIdRef.current)
293
+ : null;
294
+ if (pillEl?.matches(':hover') || popoverRef.current?.matches(':hover')) {
295
+ return;
296
+ }
297
+ closeGroupPopover();
298
+ }, 200);
299
+ }, [cancelCloseTimer, closeGroupPopover]);
300
+
301
+ // Core open logic shared by click and hover paths
302
+ const openGroupPopoverCore = useCallback(
303
+ (flatNode: FlatNode) => {
304
+ // Clear any pending close timer
305
+ cancelCloseTimer();
306
+
307
+ // Skip if already open for this group (hover keeps it open)
308
+ if (activeGroup?.node.id === flatNode.node.id) return;
309
+
310
+ setGroupSearchQuery('');
311
+ activeGroupIdRef.current = flatNode.node.id;
312
+ setActiveGroup(flatNode);
313
+
314
+ // Check Shadow DOM portal
315
+ const triggerEl = groupPillRefs.current.get(flatNode.node.id);
316
+ if (triggerEl) {
317
+ const rootNode = triggerEl.getRootNode();
318
+ if (rootNode instanceof ShadowRoot) {
319
+ const existing = rootNode.querySelector<HTMLElement>(
320
+ '[data-bs-entry-select-group-portal]',
321
+ );
322
+ if (existing) {
323
+ setGroupPortalContainer(existing);
324
+ } else {
325
+ const container = document.createElement('div');
326
+ container.setAttribute('data-bs-entry-select-group-portal', 'true');
327
+ rootNode.appendChild(container);
328
+ setGroupPortalContainer(container);
329
+ }
330
+ } else {
331
+ setGroupPortalContainer(null);
332
+ }
333
+ }
334
+
335
+ // Position synchronously — the pill is already mounted, so its rect is
336
+ // available immediately. Batching this with setActiveGroup ensures the
337
+ // popover mounts at the correct position in the same commit; a deferred
338
+ // (rAF) position would render one frame in-flow at the end of the body,
339
+ // shifting layout and firing a spurious mouseleave on the pill.
340
+ updatePopoverPosition();
341
+ },
342
+ [activeGroup, cancelCloseTimer, updatePopoverPosition],
343
+ );
344
+
345
+ const openGroupPopover = useCallback(
346
+ (flatNode: FlatNode, event: React.MouseEvent) => {
347
+ event.stopPropagation();
348
+ if (activeGroup?.node.id === flatNode.node.id) {
349
+ activeGroupIdRef.current = null;
350
+ setActiveGroup(null);
351
+ setGroupSearchQuery('');
352
+ return;
353
+ }
354
+ openGroupPopoverCore(flatNode);
355
+ },
356
+ [activeGroup, openGroupPopoverCore],
357
+ );
358
+
359
+ // Hover handlers for tabs mode.
360
+ //
361
+ // NOTE: these are driven by NATIVE mouseover/mouseout listeners (attached in
362
+ // the effects below), not React's onMouseEnter/onMouseLeave. React's
363
+ // synthetic enter/leave events do not fire when the pointer crosses from
364
+ // light DOM into a shadow root — which is exactly what happens when the
365
+ // mouse enters a tab from the host content area below the tab bar.
366
+ const handleNativeOver = useCallback(
367
+ (e: MouseEvent) => {
368
+ const t = e.target as HTMLElement | null;
369
+ if (!t) return;
370
+ if (popoverRef.current?.contains(t)) {
371
+ cancelCloseTimer();
372
+ return;
373
+ }
374
+ const pillEl = t.closest?.('[data-entry-id]') as HTMLElement | null;
375
+ const flatNode = pillEl
376
+ ? groupFlatNodeById.get(pillEl.getAttribute('data-entry-id') ?? '')
377
+ : undefined;
378
+ if (flatNode) {
379
+ cancelCloseTimer();
380
+ openGroupPopoverCore(flatNode);
381
+ } else {
382
+ // Hovering a leaf pill or empty slider area — close any open group popover
383
+ scheduleClosePopover();
384
+ }
385
+ },
386
+ [cancelCloseTimer, groupFlatNodeById, openGroupPopoverCore, scheduleClosePopover],
387
+ );
388
+
389
+ const handleNativeOut = useCallback(
390
+ (e: MouseEvent) => {
391
+ const to = e.relatedTarget as HTMLElement | null;
392
+ if (to && scrollRef.current?.contains(to)) return;
393
+ if (to && popoverRef.current?.contains(to)) return;
394
+ scheduleClosePopover();
395
+ },
396
+ [scheduleClosePopover],
397
+ );
398
+
399
+ // Keep latest handlers in refs so listeners stay attached across re-renders
400
+ const handleNativeOverRef = useRef(handleNativeOver);
401
+ const handleNativeOutRef = useRef(handleNativeOut);
402
+ useEffect(() => {
403
+ handleNativeOverRef.current = handleNativeOver;
404
+ handleNativeOutRef.current = handleNativeOut;
405
+ });
406
+
407
+ // Native hover listeners on the scroll container (delegated to group pills)
408
+ useEffect(() => {
409
+ const el = scrollRef.current;
410
+ if (!el || !isTabs) return;
411
+ const over = (e: MouseEvent) => handleNativeOverRef.current(e);
412
+ const out = (e: MouseEvent) => handleNativeOutRef.current(e);
413
+ el.addEventListener('mouseover', over);
414
+ el.addEventListener('mouseout', out);
415
+ return () => {
416
+ el.removeEventListener('mouseover', over);
417
+ el.removeEventListener('mouseout', out);
418
+ };
419
+ }, [isTabs]);
420
+
421
+ // Native hover listeners on the group popover (rendered in a portal subtree)
422
+ useEffect(() => {
423
+ const pop = popoverRef.current;
424
+ if (!isTabs || !activeGroup || !pop) return;
425
+ const over = (e: MouseEvent) => handleNativeOverRef.current(e);
426
+ const out = (e: MouseEvent) => handleNativeOutRef.current(e);
427
+ pop.addEventListener('mouseover', over);
428
+ pop.addEventListener('mouseout', out);
429
+ return () => {
430
+ pop.removeEventListener('mouseover', over);
431
+ pop.removeEventListener('mouseout', out);
432
+ };
433
+ }, [isTabs, activeGroup]);
434
+
435
+ const handleGroupPopoverSelect = useCallback(
436
+ (result: BsEntrySelectChangeResult) => {
437
+ onSelect(result);
438
+ closeGroupPopover();
439
+ },
440
+ [onSelect, closeGroupPopover],
441
+ );
442
+
443
+ // Click outside for group popover
444
+ useEffect(() => {
445
+ if (!activeGroup) return;
446
+
447
+ const handleClickOutside = (e: MouseEvent) => {
448
+ const path = e.composedPath();
449
+ if (popoverRef.current && path.includes(popoverRef.current)) return;
450
+ const triggerEl = groupPillRefs.current.get(activeGroup.node.id);
451
+ if (triggerEl && path.includes(triggerEl)) return;
452
+ closeGroupPopover();
453
+ };
454
+
455
+ const handleScroll = () => updatePopoverPosition();
456
+
457
+ document.addEventListener('mousedown', handleClickOutside);
458
+ window.addEventListener('scroll', handleScroll, true);
459
+ window.addEventListener('resize', handleScroll);
460
+
461
+ return () => {
462
+ document.removeEventListener('mousedown', handleClickOutside);
463
+ window.removeEventListener('scroll', handleScroll, true);
464
+ window.removeEventListener('resize', handleScroll);
465
+ };
466
+ }, [activeGroup, closeGroupPopover, updatePopoverPosition]);
467
+
468
+ // Keyboard escape
469
+ useEffect(() => {
470
+ if (!activeGroup) return;
471
+ const handleEscape = (e: KeyboardEvent) => {
472
+ if (e.key === 'Escape') closeGroupPopover();
473
+ };
474
+ document.addEventListener('keydown', handleEscape);
475
+ return () => document.removeEventListener('keydown', handleEscape);
476
+ }, [activeGroup, closeGroupPopover]);
477
+
478
+ if (flatNodes.length === 0) return null;
479
+
480
+ const hasGroupSearch = groupSearchQuery.trim().length > 0;
481
+
482
+ const sliderBody = (
483
+ <>
484
+ {/* Left arrow: always in DOM, width transition */}
485
+ <span
486
+ className={cn(
487
+ transCls,
488
+ 'inline-flex overflow-hidden items-center',
489
+ canScrollLeft ? 'w-[26px] opacity-100 mr-2.5' : 'w-0 opacity-0 mr-0',
490
+ )}
491
+ >
492
+ <IconBtn
493
+ rounded
494
+ icon={<LeftArrowIcon />}
495
+ onClick={() => scroll('left')}
496
+ />
497
+ </span>
498
+
499
+ <div
500
+ ref={scrollRef}
501
+ // scroll-padding: scrollIntoView aligns pills this far from the
502
+ // container edge, keeping the selected tab's outward corner flares
503
+ // (protruding 9px) fully visible instead of clipped
504
+ style={isTabs ? { scrollPaddingInline: 12 } : undefined}
505
+ className={cn(
506
+ 'flex-1 overflow-x-auto scrollbar-hide flex min-w-0',
507
+ isTabs
508
+ // px compensates -mx: keeps pills at the same visual position
509
+ // while letting bottom corner flares paint without being clipped
510
+ ? 'items-end gap-0 overflow-y-hidden px-[10px] -mx-[10px] border-b-[#e0e0e0] before:content-[""] before:absolute before:bottom-[0px] before:left-[-20vw] before:right-0 before:h-[1px] before:w-[300vw] before:bg-[#e0e0e0] h-full'
511
+ : 'items-center gap-2.5',
512
+ )}
513
+ >
514
+ {flatNodes.map((flatNode, index) => {
515
+ const { node, appId, section } = flatNode;
516
+ const isLeaf = node.isSelectable;
517
+ const isSelected = isLeaf && selectedEntryId === node.id;
518
+ // Active tab styling applies only when a descendant entry is
519
+ // selected — hovering/clicking to open the popover must NOT make
520
+ // the group look active (that would show two active tabs at once)
521
+ const isGroupActive = node?.children.findIndex(c => c.id === selectedEntryId) >= 0;
522
+ // Popover-open is a separate, transient state — drives the chevron
523
+ const isGroupPopoverOpen = activeGroup?.node.id === node.id;
524
+
525
+ // Divider between inactive tabs: always rendered in tabs mode so
526
+ // the strip width never shifts on selection change — only its
527
+ // visibility toggles (hidden next to the active tab, which has
528
+ // its own borders)
529
+ const isNodeActive = (fn: FlatNode | null): boolean => {
530
+ if (!fn) return false;
531
+ return fn.node.isSelectable
532
+ ? selectedEntryId === fn.node.id
533
+ : fn.node.children.findIndex((c) => c.id === selectedEntryId) >= 0;
534
+ };
535
+ const dividerVisible =
536
+ !isNodeActive(flatNode) && !isNodeActive(flatNodes[index - 1]);
537
+ const divider = isTabs && index > 0 ? (
538
+ <span
539
+ aria-hidden
540
+ className={cn(
541
+ 'self-center shrink-0 flex items-center justify-center w-[1px]',
542
+ !dividerVisible && 'invisible',
543
+ )}
544
+ >
545
+ <DividerIcon />
546
+ </span>
547
+ ) : null;
548
+
549
+ const setPillRef = (el: HTMLButtonElement | null) => {
550
+ if (el) {
551
+ groupPillRefs.current.set(node.id, el);
552
+ } else {
553
+ groupPillRefs.current.delete(node.id);
554
+ }
555
+ };
556
+
557
+ if (isLeaf) {
558
+ return (
559
+ <Fragment key={node.id}>
560
+ {divider}
561
+ <button
562
+ data-entry-id={node.id}
563
+ type="button"
564
+ onClick={() => {
565
+ if (node.entry) {
566
+ onSelect({ entry: node.entry, appId, section });
567
+ }
568
+ }}
569
+ className={cn(
570
+ 'flex items-center shrink-0 max-w-[240px]',
571
+ transCls,
572
+ isTabs
573
+ ? [
574
+ // Button stays h-full so text keeps its Y position;
575
+ // the visual tab box is the decorative span below
576
+ 'group px-[10px] py-[8px] relative z-10 h-full',
577
+ isSelected ? 'text-[#0265FF]' : 'text-[#030303]',
578
+ ]
579
+ : [
580
+ 'px-[10px] py-[7px] rounded-[4px] gap-[6px]',
581
+ isSelected
582
+ ? 'border border-[#F0F6FF] bg-[#F0F6FF] text-[#0265FF]'
583
+ : 'border border-[#E0E0E0] text-[#030303] hover:bg-[#F0F6FF]',
584
+ ],
585
+ )}
586
+ >
587
+ {/* Visual tab box — side borders stop 10px above the bar so
588
+ the concave corner arcs can continue them; transparent
589
+ borders when unselected (decorative only, no layout shift).
590
+ No transCls here: the fill strip and corner flares below
591
+ appear/disappear instantly on selection change, so this
592
+ box must switch instantly too — otherwise its fading
593
+ background leaves a ghost image on the deselected tab */}
594
+ {isTabs && (
595
+ <span
596
+ aria-hidden
597
+ className={cn(
598
+ 'pointer-events-none absolute inset-x-0 top-[6px] bottom-[10px] border-r border-t border-l border-b-none rounded-t-[10px] rounded-b-none',
599
+ isSelected
600
+ ? 'border-[#e0e0e0] bg-[#f6f7fa]'
601
+ : 'border-transparent',
602
+ )}
603
+ />
604
+ )}
605
+ {isTabs && isSelected && (
606
+ <>
607
+ {/* Fill below the tab body — covers the bar line under the tab */}
608
+ <span
609
+ aria-hidden
610
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[10px] bg-[#f6f7fa]"
611
+ />
612
+ {/* Concave corner flares. The square is shifted 1px
613
+ inward (left/right-[-9px]) so the full border ring —
614
+ including its tangent junctions with the side border
615
+ (vertical tangent) and the bar line (horizontal
616
+ tangent) — fits inside the element and paints above
617
+ the bottom fill strip */}
618
+ <span
619
+ aria-hidden
620
+ className="pointer-events-none absolute left-[-9px] bottom-0 w-[10px] h-[10px]"
621
+ style={{
622
+ background:
623
+ 'radial-gradient(circle at 0% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
624
+ }}
625
+ />
626
+ <span
627
+ aria-hidden
628
+ className="pointer-events-none absolute right-[-9px] bottom-0 w-[10px] h-[10px]"
629
+ style={{
630
+ background:
631
+ 'radial-gradient(circle at 100% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
632
+ }}
633
+ />
634
+ </>
635
+ )}
636
+ {/* In tabs mode, hover highlights only this icon+text chip —
637
+ the tab background itself stays unchanged. No transCls:
638
+ on selection the hover bg must clear instantly, otherwise
639
+ it fades out slowly and leaves a chip-shaped ghost on the
640
+ newly selected tab */}
641
+ <span
642
+ className={cn(
643
+ 'flex items-center gap-[6px] min-w-0',
644
+ isTabs &&
645
+ cn(
646
+ 'relative rounded-[6px] px-[20px] py-[6px]',
647
+ !isSelected && 'group-hover:bg-[#F0F6FF]',
648
+ ),
649
+ )}
650
+ >
651
+ <i
652
+ className={
653
+ node.iconClass
654
+ ? node.iconClass
655
+ : node.entry
656
+ ? getEntryIconClass(node.entry)
657
+ : 'node-icon icon-bsy-form icon-new-bsy-form-25'
658
+ }
659
+ style={{ fontSize: 16, lineHeight: 1 }}
660
+ />
661
+ <span className="text-[14px] leading-[normal] truncate">
662
+ {node.name}
663
+ </span>
664
+ </span>
665
+ </button>
666
+ </Fragment>
667
+ );
668
+ }
669
+
670
+ // Group pill (has descendants)
671
+ return (
672
+ <Fragment key={node.id}>
673
+ {divider}
674
+ <button
675
+ data-entry-id={node.id}
676
+ ref={setPillRef}
677
+ type="button"
678
+ {...(!isTabs
679
+ ? {
680
+ onClick: (e: React.MouseEvent) => openGroupPopover(flatNode, e),
681
+ }
682
+ : {})}
683
+ className={cn(
684
+ 'flex items-center shrink-0 max-w-[240px]',
685
+ transCls,
686
+ isTabs
687
+ ? [
688
+ // Button stays h-full so text keeps its Y position;
689
+ // the visual tab box is the decorative span below
690
+ 'group gap-[4px] px-[10px] py-[8px] relative z-10 h-full',
691
+ isGroupActive ? 'text-[#0265FF]' : 'text-[#030303]',
692
+ ]
693
+ : [
694
+ 'gap-[10px] px-[10px] py-[7px] rounded-[4px]',
695
+ isGroupActive
696
+ ? 'border border-[#0265FF] text-[#030303]'
697
+ : 'border border-[#E0E0E0] text-[#030303] hover:bg-[#F0F6FF]',
698
+ ],
699
+ )}
700
+ >
701
+ {/* Visual tab box — side borders stop 10px above the bar so
702
+ the concave corner arcs can continue them; transparent
703
+ borders when inactive (decorative only, no layout shift) */}
704
+ {isTabs && (
705
+ <span
706
+ aria-hidden
707
+ className={cn(
708
+ 'pointer-events-none absolute inset-x-0 top-[6px] bottom-[10px] border-r border-t border-l border-b-none rounded-t-[10px] rounded-b-none',
709
+ isGroupActive
710
+ ? 'border-[#e0e0e0] bg-[#f6f7fa]'
711
+ : 'border-transparent',
712
+ )}
713
+ />
714
+ )}
715
+ {isTabs && isGroupActive && (
716
+ <>
717
+ {/* Fill below the tab body — covers the bar line under the tab */}
718
+ <span
719
+ aria-hidden
720
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[10px] bg-[#f6f7fa]"
721
+ />
722
+ {/* Concave corner flares. The square is shifted 1px
723
+ inward (left/right-[-9px]) so the full border ring —
724
+ including its tangent junctions with the side border
725
+ (vertical tangent) and the bar line (horizontal
726
+ tangent) — fits inside the element and paints above
727
+ the bottom fill strip */}
728
+ <span
729
+ aria-hidden
730
+ className="pointer-events-none absolute left-[-9px] bottom-0 w-[10px] h-[10px]"
731
+ style={{
732
+ background:
733
+ 'radial-gradient(circle at 0% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
734
+ }}
735
+ />
736
+ <span
737
+ aria-hidden
738
+ className="pointer-events-none absolute right-[-9px] bottom-0 w-[10px] h-[10px]"
739
+ style={{
740
+ background:
741
+ 'radial-gradient(circle at 100% 0%, transparent 9px, #e0e0e0 9px, #e0e0e0 10px, #f6f7fa 10px)',
742
+ }}
743
+ />
744
+ </>
745
+ )}
746
+ {/* In tabs mode, hover highlights only this icon+text chip —
747
+ the tab background itself stays unchanged. No transCls:
748
+ on activation the hover bg must clear instantly, otherwise
749
+ it fades out slowly and leaves a chip-shaped ghost on the
750
+ newly activated tab */}
751
+ <span
752
+ className={cn(
753
+ 'flex items-center gap-[6px] min-w-0 flex-1',
754
+ isTabs &&
755
+ cn(
756
+ 'relative rounded-[6px] px-[20px] py-[6px]',
757
+ !isGroupActive && 'group-hover:bg-[#F0F6FF]',
758
+ ),
759
+ )}
760
+ >
761
+ <i
762
+ className="icon-bsy-folder-close"
763
+ style={{ fontSize: 16, lineHeight: 1 }}
764
+ />
765
+ <span className="text-[14px] leading-[normal] truncate">
766
+ {node.name}
767
+ </span>
768
+ </span>
769
+ <span
770
+ className={cn(
771
+ 'relative shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
772
+ transCls,
773
+ isGroupPopoverOpen && 'rotate-90',
774
+ )}
775
+ >
776
+ <ChevronRightIcon size={16} />
777
+ </span>
778
+ </button>
779
+ </Fragment>
780
+ );
781
+ })}
782
+ </div>
783
+
784
+ {/* Right arrow: always in DOM, width transition */}
785
+ <span
786
+ className={cn(
787
+ transCls,
788
+ 'inline-flex overflow-hidden items-center',
789
+ canScrollRight ? 'w-[26px] opacity-100 ml-2.5' : 'w-0 opacity-0 ml-0',
790
+ )}
791
+ >
792
+ <IconBtn
793
+ rounded
794
+ icon={
795
+ <span className="rotate-180 inline-flex">
796
+ <LeftArrowIcon />
797
+ </span>
798
+ }
799
+ onClick={() => scroll('right')}
800
+ />
801
+ </span>
802
+ </>
803
+ );
804
+
805
+ const tabCls = isTabs ? 'h-full px-[10px] items-end' : "items-center"
806
+
807
+ return (
808
+ <div className={classNames("flex flex-1 min-w-0", tabCls)}>
809
+ {isTabs ? (
810
+ <div className="relative flex items-stretch flex-1 min-w-0 h-full">
811
+ {sliderBody}
812
+ </div>
813
+ ) : (
814
+ sliderBody
815
+ )}
816
+
817
+ {/* Group subtree popover */}
818
+ {activeGroup &&
819
+ (() => {
820
+ const content = (
821
+ <div
822
+ ref={popoverRef}
823
+ style={popoverStyle}
824
+ className={cn(
825
+ 'bg-white rounded-[10px] shadow-[0px_0px_10px_0px_rgba(0,0,0,0.04)] p-[4px]',
826
+ 'flex flex-col gap-[4px]',
827
+ transCls,
828
+ )}
829
+ >
830
+ {/* Transparent hover bridges: keep the mouse "inside" the popover
831
+ while crossing the gap between pill and popover */}
832
+ {isTabs && (
833
+ <>
834
+ <div className="absolute left-0 right-0 -top-[6px] h-[6px]" />
835
+ <div className="absolute left-0 right-0 -bottom-[6px] h-[6px]" />
836
+ </>
837
+ )}
838
+ <SearchInput
839
+ placeholder='搜索表单名称'
840
+ value={groupSearchQuery}
841
+ onChange={setGroupSearchQuery}
842
+ />
843
+ <div className="flex flex-col gap-[4px] max-h-[300px] overflow-y-auto">
844
+ {filteredGroupTree.length > 0 ? (
845
+ filteredGroupTree.map((child) => (
846
+ <TreeItemNode
847
+ key={child.id}
848
+ item={child}
849
+ selectedEntryId={selectedEntryId}
850
+ appId={activeGroup.appId}
851
+ section={activeGroup.section}
852
+ onSelect={handleGroupPopoverSelect}
853
+ />
854
+ ))
855
+ ) : hasGroupSearch ? (
856
+ <div className="text-[12px] text-[#999999] py-[8px] text-center">
857
+ 无匹配结果
858
+ </div>
859
+ ) : (
860
+ <div className="text-[12px] text-[#999999] py-[8px] text-center">
861
+ 暂无表单
862
+ </div>
863
+ )}
864
+ </div>
865
+ </div>
866
+ );
867
+
868
+ return createPortal(content, groupPortalContainer || document.body);
869
+ })()}
870
+ </div>
871
+ );
872
+ }