@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,732 +1,732 @@
1
- import {
2
- forwardRef,
3
- useState,
4
- useRef,
5
- useEffect,
6
- useMemo,
7
- useCallback,
8
- useLayoutEffect,
9
- } from 'react';
10
- import { createPortal } from 'react-dom';
11
- import { cn } from '../../../lib/utils';
12
- import { transCls } from '../../../const/ui';
13
- import { SolidArrowDownIcon, SearchIcon } from '../../bs-icons';
14
- import { useSelectPopoverPosition } from '../../../hooks/use-select-popover-position';
15
-
16
- const FolderIcon = () => (
17
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
18
- <path d="M13.9004 15H2.65039C2.25257 15 1.87104 14.842 1.58973 14.5607C1.30843 14.2794 1.15039 13.8978 1.15039 13.5V4.5C1.15039 4.10218 1.30843 3.72064 1.58973 3.43934C1.87104 3.15804 2.25257 3 2.65039 3H7.15039L8.65039 4.5H13.9004C14.0974 4.5 14.2924 4.5388 14.4744 4.61418C14.6564 4.68956 14.8218 4.80005 14.9611 4.93934C15.1003 5.07863 15.2108 5.24399 15.2862 5.42597C15.3616 5.60796 15.4004 5.80302 15.4004 6H2.65039V13.5L4.25539 7.5H16.0754C16.1927 7.49595 16.3093 7.51947 16.4158 7.56867C16.5223 7.61788 16.6158 7.69139 16.6888 7.78331C16.7617 7.87523 16.8121 7.98298 16.8358 8.0979C16.8595 8.21282 16.856 8.33171 16.8254 8.445L15.3704 13.875C15.2865 14.1999 15.0961 14.4873 14.8296 14.6913C14.5631 14.8952 14.2359 15.0039 13.9004 15Z" fill="#6F81BD"/>
19
- </svg>
20
- );
21
-
22
- /** Close X icon (12x12) for chip dismiss in multiple mode */
23
- const CloseIcon = () => (
24
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0">
25
- <path d="M3 3L9 9M9 3L3 9" stroke="#999999" strokeWidth="1.2" strokeLinecap="round" />
26
- </svg>
27
- );
28
-
29
- /** More dots icon (12x12) for overflow chip in multiple mode */
30
- const MoreIcon = () => (
31
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0">
32
- <circle cx="2" cy="6" r="1" fill="#999999" />
33
- <circle cx="6" cy="6" r="1" fill="#999999" />
34
- <circle cx="10" cy="6" r="1" fill="#999999" />
35
- </svg>
36
- );
37
-
38
- // ── Types ─────────────────────────────────────────────────────────
39
-
40
- export interface FormSelectItem {
41
- id: string;
42
- name: string;
43
- icon?: number;
44
- type?: string;
45
- parent?: string;
46
- itemType?: 'form' | 'group';
47
- }
48
-
49
- export interface FormSelectChangeResult {
50
- /** Set in single-select mode. */
51
- form?: FormSelectItem;
52
- /** Set in multiple mode — the full selection after the change. */
53
- forms?: FormSelectItem[];
54
- }
55
-
56
- export interface FormSelectProps {
57
- items: FormSelectItem[];
58
- /** Enable multi-select mode. Default false (single-select). */
59
- multiple?: boolean;
60
- /** Controlled value — form id in single mode, form id array in multiple mode. */
61
- value?: string | string[];
62
- /** Uncontrolled default value — same shape as `value`. */
63
- defaultValue?: string | string[];
64
- onChange?: (result: FormSelectChangeResult) => void;
65
- placeholder?: string;
66
- disabled?: boolean;
67
- className?: string;
68
- /** Allow selecting an empty value as the first option. Only effective in single mode. */
69
- allowEmpty?: boolean;
70
- /** Label for the empty option. Default "无". */
71
- emptyLabel?: string;
72
- /** Max visible chips in the trigger in multiple mode (default 4). */
73
- maxVisibleChips?: number;
74
- popoverOnly?: boolean;
75
- }
76
-
77
- // ── Tree types ────────────────────────────────────────────────────
78
-
79
- interface FormTreeItem {
80
- id: string;
81
- name: string;
82
- icon: number;
83
- type: string;
84
- itemType: 'form' | 'group';
85
- children: FormTreeItem[];
86
- depth: number;
87
- form: FormSelectItem | null;
88
- }
89
-
90
- // ── Depth padding ─────────────────────────────────────────────────
91
-
92
- const DEPTH_PADDING: Record<number, string> = {
93
- 0: 'pl-[10px]',
94
- 1: 'pl-[32px]',
95
- 2: 'pl-[54px]',
96
- 3: 'pl-[76px]',
97
- };
98
-
99
- function getDepthPadding(depth: number): string {
100
- return DEPTH_PADDING[Math.min(depth, 3)] ?? DEPTH_PADDING[3];
101
- }
102
-
103
- // ── Tree builder ──────────────────────────────────────────────────
104
-
105
- function buildFormTree(items: FormSelectItem[], searchQuery: string): FormTreeItem[] {
106
- const lowerQuery = searchQuery.toLowerCase().trim();
107
-
108
- // Build parent → children map
109
- const childrenByParent = new Map<string, FormSelectItem[]>();
110
- for (const item of items) {
111
- const parentKey = item.parent || '0';
112
- if (!childrenByParent.has(parentKey)) {
113
- childrenByParent.set(parentKey, []);
114
- }
115
- childrenByParent.get(parentKey)!.push(item);
116
- }
117
-
118
- // Root parents: any parent key that is '0' or not in items
119
- const allItemIds = new Set(items.map((i) => i.id));
120
- const allParentKeys = new Set(childrenByParent.keys());
121
- const rootParents = Array.from(allParentKeys).filter(
122
- (p) => p === '0' || !allItemIds.has(p),
123
- );
124
-
125
- function buildRecursive(parentId: string, depth: number): FormTreeItem[] {
126
- const childItems = childrenByParent.get(parentId) || [];
127
- const nodes: FormTreeItem[] = [];
128
-
129
- for (const item of childItems) {
130
- const isGroup = item.itemType === 'group';
131
- const children = buildRecursive(item.id, depth + 1);
132
- const nameMatch = !lowerQuery || item?.name?.toLowerCase().includes(lowerQuery);
133
-
134
- if (isGroup) {
135
- if (nameMatch || children.length > 0) {
136
- nodes.push({
137
- id: item.id,
138
- name: item.name,
139
- icon: item.icon ?? 0,
140
- type: item.type ?? 'group',
141
- itemType: 'group',
142
- children,
143
- depth,
144
- form: item,
145
- });
146
- }
147
- } else {
148
- if (nameMatch) {
149
- nodes.push({
150
- id: item.id,
151
- name: item.name,
152
- icon: item.icon ?? 0,
153
- type: item.type ?? 'form',
154
- itemType: 'form',
155
- children: [],
156
- depth,
157
- form: item,
158
- });
159
- }
160
- }
161
- }
162
-
163
- return nodes;
164
- }
165
-
166
- const result: FormTreeItem[] = [];
167
- for (const rootId of rootParents) {
168
- result.push(...buildRecursive(rootId, 0));
169
- }
170
- return result;
171
- }
172
-
173
- // ── Form Icon ─────────────────────────────────────────────────────
174
-
175
- function getFormIconClass(icon: number): string {
176
- const isNew = icon >= 25;
177
- const newStr = isNew ? '-new' : '';
178
- return `node-icon icon-bsy-form${newStr} icon${newStr}-bsy-form-${icon}`;
179
- }
180
-
181
- // ── Entry Item ────────────────────────────────────────────────────
182
-
183
- function FormEntryItem({
184
- item,
185
- depth,
186
- isSelected,
187
- onClick,
188
- }: {
189
- item: FormTreeItem;
190
- depth: number;
191
- isSelected: boolean;
192
- onClick: () => void;
193
- }) {
194
- return (
195
- <div
196
- className={cn(
197
- 'flex items-center justify-between h-[34px] px-[10px] cursor-pointer rounded-[4px] shrink-0',
198
- getDepthPadding(depth),
199
- isSelected ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
200
- transCls,
201
- )}
202
- onClick={onClick}
203
- >
204
- <span className="flex items-center gap-[6px] min-w-0 flex-1">
205
- <i
206
- className={getFormIconClass(item.icon)}
207
- style={{ fontSize: 16, lineHeight: 1 }}
208
- />
209
- <span className="text-[14px] leading-[normal] truncate">
210
- {item.name}
211
- </span>
212
- </span>
213
- </div>
214
- );
215
- }
216
-
217
- // ── Tree Item Node ────────────────────────────────────────────────
218
-
219
- function FormTreeItemNode({
220
- item,
221
- selectedIds,
222
- onSelect,
223
- }: {
224
- item: FormTreeItem;
225
- selectedIds: Set<string>;
226
- onSelect: (item: FormSelectItem) => void;
227
- }) {
228
- const [expanded, setExpanded] = useState(true);
229
-
230
- if (item.itemType === 'group') {
231
- return (
232
- <div>
233
- <div
234
- className={cn(
235
- 'flex items-center gap-[6px] h-[34px] px-[10px] cursor-pointer rounded-[4px]',
236
- getDepthPadding(item.depth),
237
- 'hover:bg-[#EFF0F6]',
238
- transCls,
239
- )}
240
- onClick={() => setExpanded(!expanded)}
241
- >
242
- <span
243
- className={cn(
244
- 'shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
245
- transCls,
246
- expanded && 'rotate-90',
247
- )}
248
- >
249
- <SolidArrowDownIcon className="-rotate-90" size={16} />
250
- </span>
251
- <FolderIcon />
252
- <span className="text-[14px] text-[#030303] truncate">
253
- {item.name}
254
- </span>
255
- </div>
256
- {expanded &&
257
- item.children.map((child) => (
258
- <FormTreeItemNode
259
- key={child.id}
260
- item={child}
261
- selectedIds={selectedIds}
262
- onSelect={onSelect}
263
- />
264
- ))}
265
- </div>
266
- );
267
- }
268
-
269
- return (
270
- <FormEntryItem
271
- item={item}
272
- depth={item.depth}
273
- isSelected={selectedIds.has(item.id)}
274
- onClick={() => {
275
- if (item.form) {
276
- onSelect(item.form);
277
- }
278
- }}
279
- />
280
- );
281
- }
282
-
283
- // ── Popover ───────────────────────────────────────────────────────
284
-
285
- function FormPopover({
286
- searchQuery,
287
- onSearchChange,
288
- tree,
289
- selectedIds,
290
- onSelect,
291
- hasSearch,
292
- allowEmpty,
293
- emptyLabel,
294
- onEmptySelect,
295
- }: {
296
- searchQuery: string;
297
- onSearchChange: (v: string) => void;
298
- tree: FormTreeItem[];
299
- selectedIds: Set<string>;
300
- onSelect: (item: FormSelectItem) => void;
301
- hasSearch: boolean;
302
- allowEmpty: boolean;
303
- emptyLabel: string;
304
- onEmptySelect: () => void;
305
- }) {
306
- return (
307
- <div
308
- className={cn(
309
- 'bg-white rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[4px]',
310
- 'flex flex-col gap-[4px]',
311
- )}
312
- >
313
- {/* Search input */}
314
- <div className="flex items-center gap-[6px] border border-[#E0E0E0] rounded-[4px] h-[30px] px-[10px] py-[5px]">
315
- <SearchIcon />
316
- <input
317
- type="text"
318
- value={searchQuery}
319
- onChange={(e) => onSearchChange(e.target.value)}
320
- placeholder="输入表单名称来搜索"
321
- className="flex-1 outline-none text-[14px] placeholder:text-[#999999] bg-transparent min-w-0 text-[#030303]"
322
- />
323
- </div>
324
-
325
- {/* Empty option */}
326
- {allowEmpty && !hasSearch && (
327
- <div
328
- className={cn(
329
- 'flex items-center h-[34px] px-[10px] cursor-pointer rounded-[4px] shrink-0',
330
- selectedIds.size === 0 ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
331
- transCls,
332
- )}
333
- onClick={onEmptySelect}
334
- >
335
- <span className="text-[14px] leading-[normal] truncate">{emptyLabel}</span>
336
- </div>
337
- )}
338
-
339
- {/* Form list */}
340
- <div className="flex flex-col gap-0 max-h-[400px] overflow-y-auto">
341
- {tree.length > 0 ? (
342
- tree.map((node) => (
343
- <FormTreeItemNode
344
- key={node.id}
345
- item={node}
346
- selectedIds={selectedIds}
347
- onSelect={onSelect}
348
- />
349
- ))
350
- ) : hasSearch ? (
351
- <div className="text-[12px] text-[#999999] py-[8px] text-center">
352
- 无匹配结果
353
- </div>
354
- ) : (
355
- <div className="text-[12px] text-[#999999] py-[8px] text-center">
356
- 暂无表单
357
- </div>
358
- )}
359
- </div>
360
- </div>
361
- );
362
- }
363
-
364
- // ── Main Component ────────────────────────────────────────────────
365
-
366
- export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
367
- (
368
- {
369
- items,
370
- multiple = false,
371
- value: controlledValue,
372
- defaultValue,
373
- onChange,
374
- placeholder = '请选择表单',
375
- disabled = false,
376
- className,
377
- allowEmpty = false,
378
- emptyLabel = '无',
379
- maxVisibleChips = 4,
380
- popoverOnly,
381
- },
382
- ref,
383
- ) => {
384
- const [isOpen, setIsOpen] = useState(false);
385
- const [searchQuery, setSearchQuery] = useState('');
386
- const [internalValue, setInternalValue] = useState<
387
- string | string[] | undefined
388
- >(defaultValue);
389
- const triggerRef = useRef<HTMLDivElement>(null);
390
- const popoverRef = useRef<HTMLDivElement>(null);
391
- const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
392
-
393
- const { popoverStyle, updatePopoverPosition } =
394
- useSelectPopoverPosition(triggerRef);
395
-
396
- const resolvedValue = controlledValue ?? internalValue;
397
-
398
- const selectedIds = useMemo(() => {
399
- if (Array.isArray(resolvedValue)) {
400
- return new Set(resolvedValue);
401
- }
402
- return new Set(resolvedValue ? [resolvedValue] : []);
403
- }, [resolvedValue]);
404
-
405
- const selectedFormId = Array.isArray(resolvedValue)
406
- ? undefined
407
- : resolvedValue;
408
-
409
- // Build the form tree
410
- const tree = useMemo(() => {
411
- return buildFormTree(items, searchQuery);
412
- }, [items, searchQuery]);
413
-
414
- // Find selected form (single mode trigger display)
415
- const selectedForm = useMemo(() => {
416
- if (!selectedFormId) return null;
417
- return items.find((item) => item.id === selectedFormId && item.itemType !== 'group') ?? null;
418
- }, [items, selectedFormId]);
419
-
420
- // Selected forms (multiple mode chips)
421
- const selectedForms = useMemo(() => {
422
- if (!multiple) return [];
423
- return items.filter(
424
- (item) => selectedIds.has(item.id) && item.itemType !== 'group',
425
- );
426
- }, [multiple, items, selectedIds]);
427
-
428
- const visibleChips = selectedForms.slice(0, maxVisibleChips);
429
- const overflowCount = selectedForms.length - visibleChips.length;
430
-
431
- const hasSearch = searchQuery.trim().length > 0;
432
-
433
- const setTriggerRef = useCallback((node: HTMLDivElement | null) => {
434
- (triggerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
435
- if (!node) {
436
- setPortalContainer(null);
437
- return;
438
- }
439
- const rootNode = node.getRootNode();
440
- if (rootNode instanceof ShadowRoot) {
441
- const existing = rootNode.querySelector<HTMLElement>(
442
- '[data-bs-form-select-portal]',
443
- );
444
- if (existing) {
445
- setPortalContainer(existing);
446
- return;
447
- }
448
- // Append portal inside the React root container so React event
449
- // delegation sees events from portal content. The React root is
450
- // the element that ReactDOM.createRoot() was called on.
451
- const reactRoot = rootNode.getElementById('ai-office-container') || rootNode;
452
- const container = document.createElement('div');
453
- container.setAttribute('data-bs-form-select-portal', 'true');
454
- reactRoot.appendChild(container);
455
- setPortalContainer(container);
456
- return;
457
- }
458
- setPortalContainer(null);
459
- }, []);
460
-
461
- // ── Handlers ──────────────────────────────────────────────────
462
-
463
- const handleClose = useCallback(() => {
464
- setIsOpen(false);
465
- setSearchQuery('');
466
- }, []);
467
-
468
- const handleToggle = useCallback(() => {
469
- if (disabled) return;
470
- if (isOpen) {
471
- handleClose();
472
- } else {
473
- setIsOpen(true);
474
- updatePopoverPosition();
475
- }
476
- }, [disabled, isOpen, handleClose, updatePopoverPosition]);
477
-
478
- // Single mode: pick one and close. Multiple mode: toggle and stay open.
479
- const handleSelect = useCallback(
480
- (form: FormSelectItem) => {
481
- if (!multiple) {
482
- setInternalValue(form.id);
483
- onChange?.({ form });
484
- handleClose();
485
- return;
486
- }
487
- const nextIds = selectedIds.has(form.id)
488
- ? [...selectedIds].filter((id) => id !== form.id)
489
- : [...selectedIds, form.id];
490
- setInternalValue(nextIds);
491
- onChange?.({
492
- forms: items.filter(
493
- (item) => nextIds.includes(item.id) && item.itemType !== 'group',
494
- ),
495
- });
496
- },
497
- [multiple, selectedIds, items, onChange, handleClose],
498
- );
499
-
500
- const handleRemoveChip = useCallback(
501
- (formId: string, e: React.MouseEvent) => {
502
- e.stopPropagation();
503
- if (disabled) return;
504
- const nextIds = [...selectedIds].filter((id) => id !== formId);
505
- setInternalValue(nextIds);
506
- onChange?.({
507
- forms: items.filter(
508
- (item) => nextIds.includes(item.id) && item.itemType !== 'group',
509
- ),
510
- });
511
- },
512
- [disabled, selectedIds, items, onChange],
513
- );
514
-
515
- const handleEmptySelect = useCallback(() => {
516
- setInternalValue('');
517
- onChange?.({});
518
- handleClose();
519
- }, [onChange, handleClose]);
520
-
521
- // ── Event listeners ───────────────────────────────────────────
522
-
523
- // Click-outside: close popover when clicking outside both the trigger and popover.
524
- // Uses native mousedown on the trigger's root node (ShadowRoot or document) so it
525
- // works regardless of stacking contexts. Mousedown events on the popover itself are
526
- // stopped from propagating, so this listener only sees external clicks.
527
- useEffect(() => {
528
- if (!isOpen) return;
529
-
530
- const rootNode = triggerRef.current?.getRootNode() as Document | ShadowRoot | undefined;
531
- const eventTarget = rootNode ?? document;
532
-
533
- const handleClickOutside = (e: Event) => {
534
- const target = e.target as HTMLElement;
535
- if (
536
- (popoverRef.current && popoverRef.current.contains(target)) ||
537
- (triggerRef.current && triggerRef.current.contains(target))
538
- ) {
539
- return;
540
- }
541
- handleClose();
542
- };
543
-
544
- eventTarget.addEventListener('mousedown', handleClickOutside);
545
-
546
- return () => {
547
- eventTarget.removeEventListener('mousedown', handleClickOutside);
548
- };
549
- }, [isOpen, handleClose]);
550
-
551
- // Keep popover positioned correctly when the viewport resizes
552
- useEffect(() => {
553
- if (!isOpen) return;
554
-
555
- const handleResize = () => {
556
- updatePopoverPosition();
557
- };
558
-
559
- window.addEventListener('resize', handleResize);
560
- return () => window.removeEventListener('resize', handleResize);
561
- }, [isOpen, updatePopoverPosition]);
562
-
563
- // Poll popover position every frame to track scroll (scroll events aren't
564
- // composed, so they don't cross shadow boundaries)
565
- useLayoutEffect(() => {
566
- if (!isOpen) return;
567
-
568
- let rafId: number;
569
- const poll = () => {
570
- updatePopoverPosition();
571
- rafId = requestAnimationFrame(poll);
572
- };
573
- rafId = requestAnimationFrame(poll);
574
-
575
- return () => cancelAnimationFrame(rafId);
576
- }, [isOpen, updatePopoverPosition]);
577
-
578
- // Keyboard: close on Escape
579
- useEffect(() => {
580
- if (!isOpen) return;
581
-
582
- const handleEscape = (e: Event) => {
583
- if ((e as KeyboardEvent).key === 'Escape') {
584
- handleClose();
585
- }
586
- };
587
-
588
- const rootNode = triggerRef.current?.getRootNode() as Document | ShadowRoot | undefined;
589
- const eventTarget = rootNode ?? document;
590
- eventTarget.addEventListener('keydown', handleEscape);
591
- return () => eventTarget.removeEventListener('keydown', handleEscape);
592
- }, [isOpen, handleClose]);
593
-
594
- // Prevent native events inside the popover from bubbling to document-
595
- // level listeners (e.g. Radix DismissableLayer, react-remove-scroll).
596
- // Without this, a parent Drawer would: (a) close on click, (b) capture
597
- // wheel events making the popover list unscrollable.
598
- useEffect(() => {
599
- if (!isOpen) return;
600
- const el = popoverRef.current;
601
- if (!el) return;
602
-
603
- const blockEvents = (e: Event) => e.stopPropagation();
604
- el.addEventListener('mousedown', blockEvents);
605
- el.addEventListener('pointerdown', blockEvents);
606
- el.addEventListener('wheel', blockEvents);
607
- return () => {
608
- el.removeEventListener('mousedown', blockEvents);
609
- el.removeEventListener('pointerdown', blockEvents);
610
- el.removeEventListener('wheel', blockEvents);
611
- };
612
- }, [isOpen]);
613
-
614
- // ── Render ────────────────────────────────────────────────────
615
-
616
- const hasMultiSelection = multiple && selectedForms.length > 0;
617
-
618
- if (popoverOnly) {
619
- return (
620
- <FormPopover
621
- searchQuery={searchQuery}
622
- onSearchChange={setSearchQuery}
623
- tree={tree}
624
- selectedIds={selectedIds}
625
- onSelect={handleSelect}
626
- hasSearch={hasSearch}
627
- allowEmpty={allowEmpty && !multiple}
628
- emptyLabel={emptyLabel}
629
- onEmptySelect={handleEmptySelect}
630
- />
631
- );
632
- }
633
-
634
- return (
635
- <div ref={ref} className={cn('relative inline-flex min-w-0 w-full', className)}>
636
- {/* Trigger */}
637
- <div
638
- ref={setTriggerRef}
639
- className={cn(
640
- 'flex items-center gap-[10px] px-[10px] py-[7px] rounded-[4px] cursor-pointer select-none w-full',
641
- 'bg-white border',
642
- isOpen
643
- ? 'border-[#0265FF]'
644
- : 'border-[#E0E0E0] hover:border-[#0265FF]',
645
- transCls,
646
- disabled && 'opacity-50 cursor-not-allowed',
647
- )}
648
- onClick={handleToggle}
649
- >
650
- {hasMultiSelection ? (
651
- <span className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
652
- {visibleChips.map((form) => (
653
- <span
654
- key={form.id}
655
- className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0 max-w-[160px]"
656
- onClick={(e) => handleRemoveChip(form.id, e)}
657
- >
658
- <i
659
- className={getFormIconClass(form.icon ?? 0)}
660
- style={{ fontSize: 14, lineHeight: 1 }}
661
- />
662
- <span className="text-[14px] text-[#030303] truncate leading-[normal]">
663
- {form.name}
664
- </span>
665
- <span className="cursor-pointer flex items-center shrink-0 hover:bg-[#EFF0F6]">
666
- <CloseIcon />
667
- </span>
668
- </span>
669
- ))}
670
- {overflowCount > 0 && (
671
- <span className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0">
672
- <span className="text-[14px] text-[#030303] leading-[normal]">
673
- +{overflowCount}
674
- </span>
675
- <MoreIcon />
676
- </span>
677
- )}
678
- </span>
679
- ) : (
680
- <span className="flex items-center gap-[6px] min-w-0 flex-1">
681
- {selectedForm ? (
682
- <>
683
- <i
684
- className={getFormIconClass(selectedForm.icon ?? 0)}
685
- style={{ fontSize: 16, lineHeight: 1 }}
686
- />
687
- <span className="text-[14px] text-[#030303] leading-[normal] truncate">
688
- {selectedForm.name}
689
- </span>
690
- </>
691
- ) : (
692
- <span className="text-[14px] text-[#999999] leading-[normal] truncate">
693
- {allowEmpty && !multiple ? emptyLabel : placeholder}
694
- </span>
695
- )}
696
- </span>
697
- )}
698
- <span
699
- className={cn(
700
- 'shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
701
- transCls,
702
- isOpen && 'rotate-180',
703
- )}
704
- >
705
- <SolidArrowDownIcon size={16} />
706
- </span>
707
- </div>
708
-
709
- {/* Popover */}
710
- {isOpen &&
711
- createPortal(
712
- <div ref={popoverRef} style={popoverStyle}>
713
- <FormPopover
714
- searchQuery={searchQuery}
715
- onSearchChange={setSearchQuery}
716
- tree={tree}
717
- selectedIds={selectedIds}
718
- onSelect={handleSelect}
719
- hasSearch={hasSearch}
720
- allowEmpty={allowEmpty && !multiple}
721
- emptyLabel={emptyLabel}
722
- onEmptySelect={handleEmptySelect}
723
- />
724
- </div>,
725
- portalContainer || document.body,
726
- )}
727
- </div>
728
- );
729
- },
730
- );
731
-
732
- FormSelect.displayName = 'FormSelect';
1
+ import {
2
+ forwardRef,
3
+ useState,
4
+ useRef,
5
+ useEffect,
6
+ useMemo,
7
+ useCallback,
8
+ useLayoutEffect,
9
+ } from 'react';
10
+ import { createPortal } from 'react-dom';
11
+ import { cn } from '../../../lib/utils';
12
+ import { transCls } from '../../../const/ui';
13
+ import { SolidArrowDownIcon, SearchIcon } from '../../bs-icons';
14
+ import { useSelectPopoverPosition } from '../../../hooks/use-select-popover-position';
15
+
16
+ const FolderIcon = () => (
17
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
18
+ <path d="M13.9004 15H2.65039C2.25257 15 1.87104 14.842 1.58973 14.5607C1.30843 14.2794 1.15039 13.8978 1.15039 13.5V4.5C1.15039 4.10218 1.30843 3.72064 1.58973 3.43934C1.87104 3.15804 2.25257 3 2.65039 3H7.15039L8.65039 4.5H13.9004C14.0974 4.5 14.2924 4.5388 14.4744 4.61418C14.6564 4.68956 14.8218 4.80005 14.9611 4.93934C15.1003 5.07863 15.2108 5.24399 15.2862 5.42597C15.3616 5.60796 15.4004 5.80302 15.4004 6H2.65039V13.5L4.25539 7.5H16.0754C16.1927 7.49595 16.3093 7.51947 16.4158 7.56867C16.5223 7.61788 16.6158 7.69139 16.6888 7.78331C16.7617 7.87523 16.8121 7.98298 16.8358 8.0979C16.8595 8.21282 16.856 8.33171 16.8254 8.445L15.3704 13.875C15.2865 14.1999 15.0961 14.4873 14.8296 14.6913C14.5631 14.8952 14.2359 15.0039 13.9004 15Z" fill="#6F81BD"/>
19
+ </svg>
20
+ );
21
+
22
+ /** Close X icon (12x12) for chip dismiss in multiple mode */
23
+ const CloseIcon = () => (
24
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0">
25
+ <path d="M3 3L9 9M9 3L3 9" stroke="#999999" strokeWidth="1.2" strokeLinecap="round" />
26
+ </svg>
27
+ );
28
+
29
+ /** More dots icon (12x12) for overflow chip in multiple mode */
30
+ const MoreIcon = () => (
31
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0">
32
+ <circle cx="2" cy="6" r="1" fill="#999999" />
33
+ <circle cx="6" cy="6" r="1" fill="#999999" />
34
+ <circle cx="10" cy="6" r="1" fill="#999999" />
35
+ </svg>
36
+ );
37
+
38
+ // ── Types ─────────────────────────────────────────────────────────
39
+
40
+ export interface FormSelectItem {
41
+ id: string;
42
+ name: string;
43
+ icon?: number;
44
+ type?: string;
45
+ parent?: string;
46
+ itemType?: 'form' | 'group';
47
+ }
48
+
49
+ export interface FormSelectChangeResult {
50
+ /** Set in single-select mode. */
51
+ form?: FormSelectItem;
52
+ /** Set in multiple mode — the full selection after the change. */
53
+ forms?: FormSelectItem[];
54
+ }
55
+
56
+ export interface FormSelectProps {
57
+ items: FormSelectItem[];
58
+ /** Enable multi-select mode. Default false (single-select). */
59
+ multiple?: boolean;
60
+ /** Controlled value — form id in single mode, form id array in multiple mode. */
61
+ value?: string | string[];
62
+ /** Uncontrolled default value — same shape as `value`. */
63
+ defaultValue?: string | string[];
64
+ onChange?: (result: FormSelectChangeResult) => void;
65
+ placeholder?: string;
66
+ disabled?: boolean;
67
+ className?: string;
68
+ /** Allow selecting an empty value as the first option. Only effective in single mode. */
69
+ allowEmpty?: boolean;
70
+ /** Label for the empty option. Default "无". */
71
+ emptyLabel?: string;
72
+ /** Max visible chips in the trigger in multiple mode (default 4). */
73
+ maxVisibleChips?: number;
74
+ popoverOnly?: boolean;
75
+ }
76
+
77
+ // ── Tree types ────────────────────────────────────────────────────
78
+
79
+ interface FormTreeItem {
80
+ id: string;
81
+ name: string;
82
+ icon: number;
83
+ type: string;
84
+ itemType: 'form' | 'group';
85
+ children: FormTreeItem[];
86
+ depth: number;
87
+ form: FormSelectItem | null;
88
+ }
89
+
90
+ // ── Depth padding ─────────────────────────────────────────────────
91
+
92
+ const DEPTH_PADDING: Record<number, string> = {
93
+ 0: 'pl-[10px]',
94
+ 1: 'pl-[32px]',
95
+ 2: 'pl-[54px]',
96
+ 3: 'pl-[76px]',
97
+ };
98
+
99
+ function getDepthPadding(depth: number): string {
100
+ return DEPTH_PADDING[Math.min(depth, 3)] ?? DEPTH_PADDING[3];
101
+ }
102
+
103
+ // ── Tree builder ──────────────────────────────────────────────────
104
+
105
+ function buildFormTree(items: FormSelectItem[], searchQuery: string): FormTreeItem[] {
106
+ const lowerQuery = searchQuery.toLowerCase().trim();
107
+
108
+ // Build parent → children map
109
+ const childrenByParent = new Map<string, FormSelectItem[]>();
110
+ for (const item of items) {
111
+ const parentKey = item.parent || '0';
112
+ if (!childrenByParent.has(parentKey)) {
113
+ childrenByParent.set(parentKey, []);
114
+ }
115
+ childrenByParent.get(parentKey)!.push(item);
116
+ }
117
+
118
+ // Root parents: any parent key that is '0' or not in items
119
+ const allItemIds = new Set(items.map((i) => i.id));
120
+ const allParentKeys = new Set(childrenByParent.keys());
121
+ const rootParents = Array.from(allParentKeys).filter(
122
+ (p) => p === '0' || !allItemIds.has(p),
123
+ );
124
+
125
+ function buildRecursive(parentId: string, depth: number): FormTreeItem[] {
126
+ const childItems = childrenByParent.get(parentId) || [];
127
+ const nodes: FormTreeItem[] = [];
128
+
129
+ for (const item of childItems) {
130
+ const isGroup = item.itemType === 'group';
131
+ const children = buildRecursive(item.id, depth + 1);
132
+ const nameMatch = !lowerQuery || item?.name?.toLowerCase().includes(lowerQuery);
133
+
134
+ if (isGroup) {
135
+ if (nameMatch || children.length > 0) {
136
+ nodes.push({
137
+ id: item.id,
138
+ name: item.name,
139
+ icon: item.icon ?? 0,
140
+ type: item.type ?? 'group',
141
+ itemType: 'group',
142
+ children,
143
+ depth,
144
+ form: item,
145
+ });
146
+ }
147
+ } else {
148
+ if (nameMatch) {
149
+ nodes.push({
150
+ id: item.id,
151
+ name: item.name,
152
+ icon: item.icon ?? 0,
153
+ type: item.type ?? 'form',
154
+ itemType: 'form',
155
+ children: [],
156
+ depth,
157
+ form: item,
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ return nodes;
164
+ }
165
+
166
+ const result: FormTreeItem[] = [];
167
+ for (const rootId of rootParents) {
168
+ result.push(...buildRecursive(rootId, 0));
169
+ }
170
+ return result;
171
+ }
172
+
173
+ // ── Form Icon ─────────────────────────────────────────────────────
174
+
175
+ function getFormIconClass(icon: number): string {
176
+ const isNew = icon >= 25;
177
+ const newStr = isNew ? '-new' : '';
178
+ return `node-icon icon-bsy-form${newStr} icon${newStr}-bsy-form-${icon}`;
179
+ }
180
+
181
+ // ── Entry Item ────────────────────────────────────────────────────
182
+
183
+ function FormEntryItem({
184
+ item,
185
+ depth,
186
+ isSelected,
187
+ onClick,
188
+ }: {
189
+ item: FormTreeItem;
190
+ depth: number;
191
+ isSelected: boolean;
192
+ onClick: () => void;
193
+ }) {
194
+ return (
195
+ <div
196
+ className={cn(
197
+ 'flex items-center justify-between h-[34px] px-[10px] cursor-pointer rounded-[4px] shrink-0',
198
+ getDepthPadding(depth),
199
+ isSelected ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
200
+ transCls,
201
+ )}
202
+ onClick={onClick}
203
+ >
204
+ <span className="flex items-center gap-[6px] min-w-0 flex-1">
205
+ <i
206
+ className={getFormIconClass(item.icon)}
207
+ style={{ fontSize: 16, lineHeight: 1 }}
208
+ />
209
+ <span className="text-[14px] leading-[normal] truncate">
210
+ {item.name}
211
+ </span>
212
+ </span>
213
+ </div>
214
+ );
215
+ }
216
+
217
+ // ── Tree Item Node ────────────────────────────────────────────────
218
+
219
+ function FormTreeItemNode({
220
+ item,
221
+ selectedIds,
222
+ onSelect,
223
+ }: {
224
+ item: FormTreeItem;
225
+ selectedIds: Set<string>;
226
+ onSelect: (item: FormSelectItem) => void;
227
+ }) {
228
+ const [expanded, setExpanded] = useState(true);
229
+
230
+ if (item.itemType === 'group') {
231
+ return (
232
+ <div>
233
+ <div
234
+ className={cn(
235
+ 'flex items-center gap-[6px] h-[34px] px-[10px] cursor-pointer rounded-[4px]',
236
+ getDepthPadding(item.depth),
237
+ 'hover:bg-[#EFF0F6]',
238
+ transCls,
239
+ )}
240
+ onClick={() => setExpanded(!expanded)}
241
+ >
242
+ <span
243
+ className={cn(
244
+ 'shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
245
+ transCls,
246
+ expanded && 'rotate-90',
247
+ )}
248
+ >
249
+ <SolidArrowDownIcon className="-rotate-90" size={16} />
250
+ </span>
251
+ <FolderIcon />
252
+ <span className="text-[14px] text-[#030303] truncate">
253
+ {item.name}
254
+ </span>
255
+ </div>
256
+ {expanded &&
257
+ item.children.map((child) => (
258
+ <FormTreeItemNode
259
+ key={child.id}
260
+ item={child}
261
+ selectedIds={selectedIds}
262
+ onSelect={onSelect}
263
+ />
264
+ ))}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ return (
270
+ <FormEntryItem
271
+ item={item}
272
+ depth={item.depth}
273
+ isSelected={selectedIds.has(item.id)}
274
+ onClick={() => {
275
+ if (item.form) {
276
+ onSelect(item.form);
277
+ }
278
+ }}
279
+ />
280
+ );
281
+ }
282
+
283
+ // ── Popover ───────────────────────────────────────────────────────
284
+
285
+ function FormPopover({
286
+ searchQuery,
287
+ onSearchChange,
288
+ tree,
289
+ selectedIds,
290
+ onSelect,
291
+ hasSearch,
292
+ allowEmpty,
293
+ emptyLabel,
294
+ onEmptySelect,
295
+ }: {
296
+ searchQuery: string;
297
+ onSearchChange: (v: string) => void;
298
+ tree: FormTreeItem[];
299
+ selectedIds: Set<string>;
300
+ onSelect: (item: FormSelectItem) => void;
301
+ hasSearch: boolean;
302
+ allowEmpty: boolean;
303
+ emptyLabel: string;
304
+ onEmptySelect: () => void;
305
+ }) {
306
+ return (
307
+ <div
308
+ className={cn(
309
+ 'bg-white rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[4px]',
310
+ 'flex flex-col gap-[4px]',
311
+ )}
312
+ >
313
+ {/* Search input */}
314
+ <div className="flex items-center gap-[6px] border border-[#E0E0E0] rounded-[4px] h-[30px] px-[10px] py-[5px]">
315
+ <SearchIcon />
316
+ <input
317
+ type="text"
318
+ value={searchQuery}
319
+ onChange={(e) => onSearchChange(e.target.value)}
320
+ placeholder="输入表单名称来搜索"
321
+ className="flex-1 outline-none text-[14px] placeholder:text-[#999999] bg-transparent min-w-0 text-[#030303]"
322
+ />
323
+ </div>
324
+
325
+ {/* Empty option */}
326
+ {allowEmpty && !hasSearch && (
327
+ <div
328
+ className={cn(
329
+ 'flex items-center h-[34px] px-[10px] cursor-pointer rounded-[4px] shrink-0',
330
+ selectedIds.size === 0 ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
331
+ transCls,
332
+ )}
333
+ onClick={onEmptySelect}
334
+ >
335
+ <span className="text-[14px] leading-[normal] truncate">{emptyLabel}</span>
336
+ </div>
337
+ )}
338
+
339
+ {/* Form list */}
340
+ <div className="flex flex-col gap-0 max-h-[400px] overflow-y-auto">
341
+ {tree.length > 0 ? (
342
+ tree.map((node) => (
343
+ <FormTreeItemNode
344
+ key={node.id}
345
+ item={node}
346
+ selectedIds={selectedIds}
347
+ onSelect={onSelect}
348
+ />
349
+ ))
350
+ ) : hasSearch ? (
351
+ <div className="text-[12px] text-[#999999] py-[8px] text-center">
352
+ 无匹配结果
353
+ </div>
354
+ ) : (
355
+ <div className="text-[12px] text-[#999999] py-[8px] text-center">
356
+ 暂无表单
357
+ </div>
358
+ )}
359
+ </div>
360
+ </div>
361
+ );
362
+ }
363
+
364
+ // ── Main Component ────────────────────────────────────────────────
365
+
366
+ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
367
+ (
368
+ {
369
+ items,
370
+ multiple = false,
371
+ value: controlledValue,
372
+ defaultValue,
373
+ onChange,
374
+ placeholder = '请选择表单',
375
+ disabled = false,
376
+ className,
377
+ allowEmpty = false,
378
+ emptyLabel = '无',
379
+ maxVisibleChips = 4,
380
+ popoverOnly,
381
+ },
382
+ ref,
383
+ ) => {
384
+ const [isOpen, setIsOpen] = useState(false);
385
+ const [searchQuery, setSearchQuery] = useState('');
386
+ const [internalValue, setInternalValue] = useState<
387
+ string | string[] | undefined
388
+ >(defaultValue);
389
+ const triggerRef = useRef<HTMLDivElement>(null);
390
+ const popoverRef = useRef<HTMLDivElement>(null);
391
+ const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
392
+
393
+ const { popoverStyle, updatePopoverPosition } =
394
+ useSelectPopoverPosition(triggerRef);
395
+
396
+ const resolvedValue = controlledValue ?? internalValue;
397
+
398
+ const selectedIds = useMemo(() => {
399
+ if (Array.isArray(resolvedValue)) {
400
+ return new Set(resolvedValue);
401
+ }
402
+ return new Set(resolvedValue ? [resolvedValue] : []);
403
+ }, [resolvedValue]);
404
+
405
+ const selectedFormId = Array.isArray(resolvedValue)
406
+ ? undefined
407
+ : resolvedValue;
408
+
409
+ // Build the form tree
410
+ const tree = useMemo(() => {
411
+ return buildFormTree(items, searchQuery);
412
+ }, [items, searchQuery]);
413
+
414
+ // Find selected form (single mode trigger display)
415
+ const selectedForm = useMemo(() => {
416
+ if (!selectedFormId) return null;
417
+ return items.find((item) => item.id === selectedFormId && item.itemType !== 'group') ?? null;
418
+ }, [items, selectedFormId]);
419
+
420
+ // Selected forms (multiple mode chips)
421
+ const selectedForms = useMemo(() => {
422
+ if (!multiple) return [];
423
+ return items.filter(
424
+ (item) => selectedIds.has(item.id) && item.itemType !== 'group',
425
+ );
426
+ }, [multiple, items, selectedIds]);
427
+
428
+ const visibleChips = selectedForms.slice(0, maxVisibleChips);
429
+ const overflowCount = selectedForms.length - visibleChips.length;
430
+
431
+ const hasSearch = searchQuery.trim().length > 0;
432
+
433
+ const setTriggerRef = useCallback((node: HTMLDivElement | null) => {
434
+ (triggerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
435
+ if (!node) {
436
+ setPortalContainer(null);
437
+ return;
438
+ }
439
+ const rootNode = node.getRootNode();
440
+ if (rootNode instanceof ShadowRoot) {
441
+ const existing = rootNode.querySelector<HTMLElement>(
442
+ '[data-bs-form-select-portal]',
443
+ );
444
+ if (existing) {
445
+ setPortalContainer(existing);
446
+ return;
447
+ }
448
+ // Append portal inside the React root container so React event
449
+ // delegation sees events from portal content. The React root is
450
+ // the element that ReactDOM.createRoot() was called on.
451
+ const reactRoot = rootNode.getElementById('ai-office-container') || rootNode;
452
+ const container = document.createElement('div');
453
+ container.setAttribute('data-bs-form-select-portal', 'true');
454
+ reactRoot.appendChild(container);
455
+ setPortalContainer(container);
456
+ return;
457
+ }
458
+ setPortalContainer(null);
459
+ }, []);
460
+
461
+ // ── Handlers ──────────────────────────────────────────────────
462
+
463
+ const handleClose = useCallback(() => {
464
+ setIsOpen(false);
465
+ setSearchQuery('');
466
+ }, []);
467
+
468
+ const handleToggle = useCallback(() => {
469
+ if (disabled) return;
470
+ if (isOpen) {
471
+ handleClose();
472
+ } else {
473
+ setIsOpen(true);
474
+ updatePopoverPosition();
475
+ }
476
+ }, [disabled, isOpen, handleClose, updatePopoverPosition]);
477
+
478
+ // Single mode: pick one and close. Multiple mode: toggle and stay open.
479
+ const handleSelect = useCallback(
480
+ (form: FormSelectItem) => {
481
+ if (!multiple) {
482
+ setInternalValue(form.id);
483
+ onChange?.({ form });
484
+ handleClose();
485
+ return;
486
+ }
487
+ const nextIds = selectedIds.has(form.id)
488
+ ? [...selectedIds].filter((id) => id !== form.id)
489
+ : [...selectedIds, form.id];
490
+ setInternalValue(nextIds);
491
+ onChange?.({
492
+ forms: items.filter(
493
+ (item) => nextIds.includes(item.id) && item.itemType !== 'group',
494
+ ),
495
+ });
496
+ },
497
+ [multiple, selectedIds, items, onChange, handleClose],
498
+ );
499
+
500
+ const handleRemoveChip = useCallback(
501
+ (formId: string, e: React.MouseEvent) => {
502
+ e.stopPropagation();
503
+ if (disabled) return;
504
+ const nextIds = [...selectedIds].filter((id) => id !== formId);
505
+ setInternalValue(nextIds);
506
+ onChange?.({
507
+ forms: items.filter(
508
+ (item) => nextIds.includes(item.id) && item.itemType !== 'group',
509
+ ),
510
+ });
511
+ },
512
+ [disabled, selectedIds, items, onChange],
513
+ );
514
+
515
+ const handleEmptySelect = useCallback(() => {
516
+ setInternalValue('');
517
+ onChange?.({});
518
+ handleClose();
519
+ }, [onChange, handleClose]);
520
+
521
+ // ── Event listeners ───────────────────────────────────────────
522
+
523
+ // Click-outside: close popover when clicking outside both the trigger and popover.
524
+ // Uses native mousedown on the trigger's root node (ShadowRoot or document) so it
525
+ // works regardless of stacking contexts. Mousedown events on the popover itself are
526
+ // stopped from propagating, so this listener only sees external clicks.
527
+ useEffect(() => {
528
+ if (!isOpen) return;
529
+
530
+ const rootNode = triggerRef.current?.getRootNode() as Document | ShadowRoot | undefined;
531
+ const eventTarget = rootNode ?? document;
532
+
533
+ const handleClickOutside = (e: Event) => {
534
+ const target = e.target as HTMLElement;
535
+ if (
536
+ (popoverRef.current && popoverRef.current.contains(target)) ||
537
+ (triggerRef.current && triggerRef.current.contains(target))
538
+ ) {
539
+ return;
540
+ }
541
+ handleClose();
542
+ };
543
+
544
+ eventTarget.addEventListener('mousedown', handleClickOutside);
545
+
546
+ return () => {
547
+ eventTarget.removeEventListener('mousedown', handleClickOutside);
548
+ };
549
+ }, [isOpen, handleClose]);
550
+
551
+ // Keep popover positioned correctly when the viewport resizes
552
+ useEffect(() => {
553
+ if (!isOpen) return;
554
+
555
+ const handleResize = () => {
556
+ updatePopoverPosition();
557
+ };
558
+
559
+ window.addEventListener('resize', handleResize);
560
+ return () => window.removeEventListener('resize', handleResize);
561
+ }, [isOpen, updatePopoverPosition]);
562
+
563
+ // Poll popover position every frame to track scroll (scroll events aren't
564
+ // composed, so they don't cross shadow boundaries)
565
+ useLayoutEffect(() => {
566
+ if (!isOpen) return;
567
+
568
+ let rafId: number;
569
+ const poll = () => {
570
+ updatePopoverPosition();
571
+ rafId = requestAnimationFrame(poll);
572
+ };
573
+ rafId = requestAnimationFrame(poll);
574
+
575
+ return () => cancelAnimationFrame(rafId);
576
+ }, [isOpen, updatePopoverPosition]);
577
+
578
+ // Keyboard: close on Escape
579
+ useEffect(() => {
580
+ if (!isOpen) return;
581
+
582
+ const handleEscape = (e: Event) => {
583
+ if ((e as KeyboardEvent).key === 'Escape') {
584
+ handleClose();
585
+ }
586
+ };
587
+
588
+ const rootNode = triggerRef.current?.getRootNode() as Document | ShadowRoot | undefined;
589
+ const eventTarget = rootNode ?? document;
590
+ eventTarget.addEventListener('keydown', handleEscape);
591
+ return () => eventTarget.removeEventListener('keydown', handleEscape);
592
+ }, [isOpen, handleClose]);
593
+
594
+ // Prevent native events inside the popover from bubbling to document-
595
+ // level listeners (e.g. Radix DismissableLayer, react-remove-scroll).
596
+ // Without this, a parent Drawer would: (a) close on click, (b) capture
597
+ // wheel events making the popover list unscrollable.
598
+ useEffect(() => {
599
+ if (!isOpen) return;
600
+ const el = popoverRef.current;
601
+ if (!el) return;
602
+
603
+ const blockEvents = (e: Event) => e.stopPropagation();
604
+ el.addEventListener('mousedown', blockEvents);
605
+ el.addEventListener('pointerdown', blockEvents);
606
+ el.addEventListener('wheel', blockEvents);
607
+ return () => {
608
+ el.removeEventListener('mousedown', blockEvents);
609
+ el.removeEventListener('pointerdown', blockEvents);
610
+ el.removeEventListener('wheel', blockEvents);
611
+ };
612
+ }, [isOpen]);
613
+
614
+ // ── Render ────────────────────────────────────────────────────
615
+
616
+ const hasMultiSelection = multiple && selectedForms.length > 0;
617
+
618
+ if (popoverOnly) {
619
+ return (
620
+ <FormPopover
621
+ searchQuery={searchQuery}
622
+ onSearchChange={setSearchQuery}
623
+ tree={tree}
624
+ selectedIds={selectedIds}
625
+ onSelect={handleSelect}
626
+ hasSearch={hasSearch}
627
+ allowEmpty={allowEmpty && !multiple}
628
+ emptyLabel={emptyLabel}
629
+ onEmptySelect={handleEmptySelect}
630
+ />
631
+ );
632
+ }
633
+
634
+ return (
635
+ <div ref={ref} className={cn('relative inline-flex min-w-0 w-full', className)}>
636
+ {/* Trigger */}
637
+ <div
638
+ ref={setTriggerRef}
639
+ className={cn(
640
+ 'flex items-center gap-[10px] px-[10px] py-[7px] rounded-[4px] cursor-pointer select-none w-full',
641
+ 'bg-white border',
642
+ isOpen
643
+ ? 'border-[#0265FF]'
644
+ : 'border-[#E0E0E0] hover:border-[#0265FF]',
645
+ transCls,
646
+ disabled && 'opacity-50 cursor-not-allowed',
647
+ )}
648
+ onClick={handleToggle}
649
+ >
650
+ {hasMultiSelection ? (
651
+ <span className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
652
+ {visibleChips.map((form) => (
653
+ <span
654
+ key={form.id}
655
+ className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0 max-w-[160px]"
656
+ onClick={(e) => handleRemoveChip(form.id, e)}
657
+ >
658
+ <i
659
+ className={getFormIconClass(form.icon ?? 0)}
660
+ style={{ fontSize: 14, lineHeight: 1 }}
661
+ />
662
+ <span className="text-[14px] text-[#030303] truncate leading-[normal]">
663
+ {form.name}
664
+ </span>
665
+ <span className="cursor-pointer flex items-center shrink-0 hover:bg-[#EFF0F6]">
666
+ <CloseIcon />
667
+ </span>
668
+ </span>
669
+ ))}
670
+ {overflowCount > 0 && (
671
+ <span className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0">
672
+ <span className="text-[14px] text-[#030303] leading-[normal]">
673
+ +{overflowCount}
674
+ </span>
675
+ <MoreIcon />
676
+ </span>
677
+ )}
678
+ </span>
679
+ ) : (
680
+ <span className="flex items-center gap-[6px] min-w-0 flex-1">
681
+ {selectedForm ? (
682
+ <>
683
+ <i
684
+ className={getFormIconClass(selectedForm.icon ?? 0)}
685
+ style={{ fontSize: 16, lineHeight: 1 }}
686
+ />
687
+ <span className="text-[14px] text-[#030303] leading-[normal] truncate">
688
+ {selectedForm.name}
689
+ </span>
690
+ </>
691
+ ) : (
692
+ <span className="text-[14px] text-[#999999] leading-[normal] truncate">
693
+ {allowEmpty && !multiple ? emptyLabel : placeholder}
694
+ </span>
695
+ )}
696
+ </span>
697
+ )}
698
+ <span
699
+ className={cn(
700
+ 'shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
701
+ transCls,
702
+ isOpen && 'rotate-180',
703
+ )}
704
+ >
705
+ <SolidArrowDownIcon size={16} />
706
+ </span>
707
+ </div>
708
+
709
+ {/* Popover */}
710
+ {isOpen &&
711
+ createPortal(
712
+ <div ref={popoverRef} style={popoverStyle}>
713
+ <FormPopover
714
+ searchQuery={searchQuery}
715
+ onSearchChange={setSearchQuery}
716
+ tree={tree}
717
+ selectedIds={selectedIds}
718
+ onSelect={handleSelect}
719
+ hasSearch={hasSearch}
720
+ allowEmpty={allowEmpty && !multiple}
721
+ emptyLabel={emptyLabel}
722
+ onEmptySelect={handleEmptySelect}
723
+ />
724
+ </div>,
725
+ portalContainer || document.body,
726
+ )}
727
+ </div>
728
+ );
729
+ },
730
+ );
731
+
732
+ FormSelect.displayName = 'FormSelect';