@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.
- package/.storybook/main.ts +31 -31
- package/.storybook/preview.ts +16 -16
- package/CHANGELOG.md +512 -501
- package/components.json +22 -22
- package/package.json +9 -6
- package/scripts/strip-layers.cjs +41 -41
- package/src/components/abs-fullscreen-gradient-bg.tsx +35 -35
- package/src/components/agent-helper/agent-category-select-menu/index.tsx +57 -57
- package/src/components/agent-helper/agent-entry-card/index.tsx +94 -94
- package/src/components/agent-helper/agent-helper-card/index.tsx +179 -179
- package/src/components/agent-helper/agent-helper-card-list/index.tsx +59 -59
- package/src/components/agent-helper/agent-helper-main-layout/index.tsx +60 -60
- package/src/components/agent-helper/agent-sort-select/index.tsx +89 -89
- package/src/components/agent-helper/agent-tag/index.tsx +29 -29
- package/src/components/agent-helper/app-select/index.tsx +559 -559
- package/src/components/agent-helper/condition-group/index.tsx +75 -75
- package/src/components/agent-helper/condition-input/index.tsx +233 -233
- package/src/components/agent-helper/condition-item/index.tsx +237 -237
- package/src/components/agent-helper/condition-quantifier-select/index.tsx +142 -142
- package/src/components/agent-helper/condition-trigger-description/index.tsx +49 -49
- package/src/components/agent-helper/date-picker/calendar-panel.tsx +164 -164
- package/src/components/agent-helper/date-picker/date-input.tsx +91 -91
- package/src/components/agent-helper/date-picker/date-picker.tsx +327 -327
- package/src/components/agent-helper/date-picker/footer-panel.tsx +44 -44
- package/src/components/agent-helper/date-picker/index.ts +3 -3
- package/src/components/agent-helper/date-picker/month-panel.tsx +82 -82
- package/src/components/agent-helper/date-picker/time-panel.tsx +146 -146
- package/src/components/agent-helper/date-picker/types.ts +37 -37
- package/src/components/agent-helper/date-picker/utils.ts +121 -121
- package/src/components/agent-helper/date-picker/year-panel.tsx +86 -86
- package/src/components/agent-helper/exec-log-filter/index.tsx +232 -232
- package/src/components/agent-helper/field-edit-preference-select/index.tsx +291 -291
- package/src/components/agent-helper/field-select/index.tsx +466 -466
- package/src/components/agent-helper/field-value-input/china-address-data.ts +4117 -4117
- package/src/components/agent-helper/field-value-input/display.tsx +71 -71
- package/src/components/agent-helper/field-value-input/icons.tsx +38 -38
- package/src/components/agent-helper/field-value-input/index.tsx +144 -144
- package/src/components/agent-helper/field-value-input/inputs.tsx +247 -247
- package/src/components/agent-helper/field-value-input/picker-inputs.tsx +1141 -1141
- package/src/components/agent-helper/flow-node-action-trigger-select/index.tsx +57 -57
- package/src/components/agent-helper/form-action-trigger-select/index.tsx +60 -60
- package/src/components/agent-helper/form-divider/index.tsx +44 -44
- package/src/components/agent-helper/form-select/index.tsx +732 -732
- package/src/components/agent-helper/monthdays-select/index.tsx +378 -378
- package/src/components/agent-helper/offset-preference-select/index.tsx +268 -268
- package/src/components/agent-helper/offset-type-select/index.tsx +66 -66
- package/src/components/agent-helper/offset-unit-select/index.tsx +54 -54
- package/src/components/agent-helper/placeholder-card/index.tsx +75 -75
- package/src/components/agent-helper/prompt-input/index.tsx +646 -646
- package/src/components/agent-helper/relational-operator-select/index.tsx +179 -179
- package/src/components/agent-helper/repeat-by-type-select/index.tsx +53 -53
- package/src/components/agent-helper/repeat-preference-select/index.tsx +162 -162
- package/src/components/agent-helper/repeat-preference-type-select/index.tsx +58 -58
- package/src/components/agent-helper/search-input/index.tsx +35 -35
- package/src/components/agent-helper/table/index.tsx +114 -114
- package/src/components/agent-helper/trigger-select/index.tsx +121 -121
- package/src/components/agent-helper/trigger-time-preference-select/index.tsx +189 -189
- package/src/components/agent-helper/trigger-time-source-type-select/index.tsx +53 -53
- package/src/components/agent-helper/weekdays-select/index.tsx +230 -230
- package/src/components/agent-scroll-selector.tsx +193 -193
- package/src/components/agent-select.tsx +154 -154
- package/src/components/attachment-part-group.tsx +35 -35
- package/src/components/attachment-part.tsx +296 -296
- package/src/components/attachments-previewer.tsx +272 -272
- package/src/components/base-button.tsx +49 -49
- package/src/components/border-animation.tsx +107 -107
- package/src/components/border-color-animation.tsx +66 -66
- package/src/components/bot-avatar-name.tsx +36 -36
- package/src/components/bot-icon.tsx +23 -23
- package/src/components/bs-entry-select/app-group-folder.tsx +61 -61
- package/src/components/bs-entry-select/bs-entry-select.tsx +296 -296
- package/src/components/bs-entry-select/build-tree.ts +265 -265
- package/src/components/bs-entry-select/entry-item.tsx +45 -45
- package/src/components/bs-entry-select/entry-popover.tsx +99 -99
- package/src/components/bs-entry-select/index.ts +7 -7
- package/src/components/bs-entry-select/other-app-section.tsx +96 -96
- package/src/components/bs-entry-select/scroll-slider.tsx +872 -872
- package/src/components/bs-entry-select/search-input.tsx +4 -4
- package/src/components/bs-entry-select/tree-item-node.tsx +84 -84
- package/src/components/bs-entry-select/types.ts +46 -46
- package/src/components/bs-icons.tsx +1362 -1362
- package/src/components/card-field.tsx +23 -23
- package/src/components/card.tsx +36 -36
- package/src/components/chat-area-header.tsx +65 -65
- package/src/components/check-card.tsx +77 -77
- package/src/components/circle-checker.tsx +66 -66
- package/src/components/collapsible-txt-msg.tsx +75 -75
- package/src/components/collapsible.tsx +9 -9
- package/src/components/confirm-dialog.tsx +139 -139
- package/src/components/date-range-picker.tsx +91 -91
- package/src/components/fake-bot-msg.tsx +51 -51
- package/src/components/field-checker-list-msg.tsx +146 -146
- package/src/components/field-checker.tsx +185 -185
- package/src/components/field-icon.tsx +13 -13
- package/src/components/field-value-checker.tsx +116 -116
- package/src/components/fields-design-info-table.tsx +160 -160
- package/src/components/fields-generating-indicator.tsx +95 -95
- package/src/components/fields-portal.tsx +18 -18
- package/src/components/fields-previewer.tsx +216 -216
- package/src/components/font-icon.tsx +65 -65
- package/src/components/form-info-editor.tsx +263 -263
- package/src/components/formula-tag.tsx +32 -32
- package/src/components/generate-animation.tsx +56 -56
- package/src/components/icon-btn.tsx +43 -43
- package/src/components/icon-card.tsx +67 -67
- package/src/components/icons/calendar-icon.tsx +5 -5
- package/src/components/icons/clear-all-icon.tsx +5 -5
- package/src/components/icons/empty-agent-icon.tsx +93 -93
- package/src/components/icons/empty-table-icon.tsx +104 -104
- package/src/components/icons/grids-icon.tsx +5 -5
- package/src/components/img-part.tsx +27 -27
- package/src/components/legacy-slot.tsx +110 -110
- package/src/components/line-checker.tsx +168 -168
- package/src/components/linear-gradient-border-btn.tsx +48 -48
- package/src/components/linear-gradient-color-bg-animation.tsx +103 -103
- package/src/components/memoized-markdown.tsx +32 -32
- package/src/components/message-content.tsx +21 -21
- package/src/components/number-part.tsx +46 -46
- package/src/components/office-container.tsx +289 -289
- package/src/components/option-item.tsx +27 -27
- package/src/components/output-config-editor.tsx +478 -478
- package/src/components/previewer-header.tsx +270 -270
- package/src/components/primary-confirm-btn.tsx +58 -58
- package/src/components/primary-entry-btn.tsx +48 -48
- package/src/components/radio-card.tsx +63 -63
- package/src/components/scroll-to-bottom-btn.tsx +28 -28
- package/src/components/split-line.tsx +9 -9
- package/src/components/square-checker.tsx +87 -87
- package/src/components/subform-fields-value-checker.tsx +184 -184
- package/src/components/tab-radio-group.tsx +83 -83
- package/src/components/tooltip.tsx +53 -53
- package/src/components/ui/button.tsx +51 -51
- package/src/components/ui/calendar.tsx +57 -57
- package/src/components/ui/dialog.tsx +104 -104
- package/src/components/ui/drawer.tsx +279 -279
- package/src/components/ui/form.tsx +202 -202
- package/src/components/ui/icons.tsx +1267 -1267
- package/src/components/ui/input.tsx +28 -28
- package/src/components/ui/label.tsx +30 -30
- package/src/components/ui/popover.tsx +85 -85
- package/src/components/ui/select.tsx +236 -236
- package/src/components/ui/spinner.tsx +17 -17
- package/src/components/ui/switch.tsx +33 -33
- package/src/components/ui/tabs.tsx +62 -62
- package/src/components/ui/textarea.tsx +28 -28
- package/src/components/ui/tooltip.tsx +53 -53
- package/src/components/user-txt-msg-bubble.tsx +47 -47
- package/src/components/warning-msg.tsx +27 -27
- package/src/const/ui.ts +119 -119
- package/src/css.d.ts +4 -4
- package/src/hooks/use-column-appear.ts +78 -78
- package/src/hooks/use-portal-container.ts +27 -27
- package/src/hooks/use-resizable-width.ts +129 -129
- package/src/hooks/use-select-popover-position.ts +74 -74
- package/src/hooks/use-shadow.ts +127 -127
- package/src/index.ts +226 -226
- package/src/lib/utils.ts +138 -138
- package/src/stories/AbsFullscreenGradientBg.stories.tsx +36 -36
- package/src/stories/AgentCategorySelectMenu.stories.tsx +33 -33
- package/src/stories/AgentEntryCard.stories.tsx +90 -90
- package/src/stories/AgentHelperCard.stories.tsx +105 -105
- package/src/stories/AgentHelperCardList.stories.tsx +120 -120
- package/src/stories/AgentHelperMainLayout.stories.tsx +71 -71
- package/src/stories/AgentScrollSelector.stories.tsx +95 -95
- package/src/stories/AgentSelect.stories.tsx +134 -134
- package/src/stories/AgentTag.stories.tsx +44 -44
- package/src/stories/AppSelect.stories.tsx +250 -250
- package/src/stories/AttachmentPart.stories.tsx +122 -122
- package/src/stories/AttachmentPartGroup.stories.tsx +152 -152
- package/src/stories/AttachmentsPreviewer.stories.tsx +306 -306
- package/src/stories/BaseButton.stories.tsx +33 -33
- package/src/stories/BorderAnimation.stories.tsx +116 -116
- package/src/stories/BorderColorAnimation.stories.tsx +63 -63
- package/src/stories/BotAvatarAndName.stories.ts +24 -24
- package/src/stories/BotIcon.stories.ts +22 -22
- package/src/stories/BsConditionInput.stories.tsx +460 -460
- package/src/stories/BsEntrySelect.stories.tsx +343 -343
- package/src/stories/BsTooltip.stories.tsx +32 -32
- package/src/stories/Card.stories.tsx +28 -28
- package/src/stories/CardField.stories.tsx +87 -87
- package/src/stories/ChatAreaHeader.stories.ts +26 -26
- package/src/stories/CheckCard.stories.tsx +114 -114
- package/src/stories/CircleChecker.stories.tsx +89 -89
- package/src/stories/CollapsibleTextMsg.stories.ts +44 -44
- package/src/stories/ConditionGroup.stories.tsx +216 -216
- package/src/stories/ConditionInput.stories.tsx +502 -502
- package/src/stories/ConditionItem.stories.tsx +163 -163
- package/src/stories/ConditonQuantifierSelect.stories.tsx +54 -54
- package/src/stories/ConditonTriggerDescription.stories.tsx +61 -61
- package/src/stories/ConfirmDialog.stories.tsx +123 -123
- package/src/stories/DataTable.stories.tsx +191 -191
- package/src/stories/DatePicker.stories.tsx +174 -174
- package/src/stories/ExecLogFilter.stories.tsx +145 -145
- package/src/stories/FakeBotMsg.stories.tsx +36 -36
- package/src/stories/FieldEditPreferenceSelect.stories.tsx +161 -161
- package/src/stories/FieldSelect.stories.tsx +228 -228
- package/src/stories/FieldValueInput.stories.tsx +442 -442
- package/src/stories/FlowNodeActionTriggerSelect.stories.tsx +80 -80
- package/src/stories/Form.stories.tsx +302 -302
- package/src/stories/FormActionTriggerSelect.stories.tsx +80 -80
- package/src/stories/FormDivider.stories.tsx +31 -31
- package/src/stories/FormInfoEditor.stories.tsx +173 -173
- package/src/stories/FormSelect.stories.tsx +222 -222
- package/src/stories/FormulaTag.stories.tsx +68 -68
- package/src/stories/GenerateAnimation.stories.ts +20 -20
- package/src/stories/IconBtn.stories.tsx +32 -32
- package/src/stories/IconCard.stories.tsx +35 -35
- package/src/stories/IconLib.tsx +45 -45
- package/src/stories/Icons.stories.ts +22 -22
- package/src/stories/ImgMsgPart.stories.tsx +42 -42
- package/src/stories/Input.stories.tsx +102 -102
- package/src/stories/Label.stories.tsx +66 -66
- package/src/stories/LegacySlot.stories.tsx +56 -56
- package/src/stories/LineChecker.stories.tsx +133 -133
- package/src/stories/LinearGradientBorderBtn.stories.tsx +86 -86
- package/src/stories/LinearGradientColorBgAnimation.stories.tsx +60 -60
- package/src/stories/MemoizedMarkdown.stories.tsx +102 -102
- package/src/stories/MessageContent.stories.tsx +36 -36
- package/src/stories/MonthdaysSelect.stories.tsx +78 -78
- package/src/stories/NumbersMsgPart.stories.tsx +42 -42
- package/src/stories/OfficeContainer.stories.tsx +287 -287
- package/src/stories/OffsetPreferenceSelect.stories.tsx +118 -118
- package/src/stories/OffsetTypeSelect.stories.tsx +75 -75
- package/src/stories/OffsetUnitSelect.stories.tsx +61 -61
- package/src/stories/OptionItem.stories.tsx +77 -77
- package/src/stories/OutputConfigEditor.stories.tsx +96 -96
- package/src/stories/PlaceholderCard.stories.tsx +35 -35
- package/src/stories/PreviewerHeader.stories.tsx +91 -91
- package/src/stories/PrimaryConfirmBtn.stories.tsx +60 -60
- package/src/stories/PrimaryEntryBtn.stories.tsx +74 -74
- package/src/stories/PromptInput.stories.tsx +228 -228
- package/src/stories/RadioCard.stories.tsx +103 -103
- package/src/stories/RelationalOperatorSelect.stories.tsx +61 -61
- package/src/stories/RepeatByTypeSelect.stories.tsx +58 -58
- package/src/stories/RepeatPreferenceSelect.stories.tsx +107 -107
- package/src/stories/RepeatPreferenceTypeSelect.stories.tsx +58 -58
- package/src/stories/ScrollToBottom.stories.tsx +24 -24
- package/src/stories/SearchInput.stories.tsx +50 -50
- package/src/stories/Select.stories.tsx +128 -128
- package/src/stories/SplitLine.stories.tsx +40 -40
- package/src/stories/SquareChecker.stories.tsx +96 -96
- package/src/stories/TabRadioGroup.stories.tsx +84 -84
- package/src/stories/Textarea.stories.tsx +67 -67
- package/src/stories/TriggerSelect.stories.tsx +48 -48
- package/src/stories/TriggerTimePreferenceSelect.stories.tsx +106 -106
- package/src/stories/TriggerTimeSourceTypeSelect.stories.tsx +68 -68
- package/src/stories/UserTextMsgBubble.stories.ts +24 -24
- package/src/stories/WarningMessage.stories.tsx +23 -23
- package/src/stories/WeekdaysSelect.stories.tsx +66 -66
- package/src/stories/assets/demo.css +539 -539
- package/src/stories/assets/demo_index.html +7988 -7988
- package/src/stories/assets/iconfont.css +1377 -1377
- package/src/stories/assets/iconfont.json +2382 -2382
- package/src/stories/assets/iconfont.svg +697 -697
- package/src/stories/collapsible.stories.tsx +47 -47
- package/src/stories/drawer.stories.tsx +196 -196
- package/src/stories/field-checker-list-msg.stories.tsx +95 -95
- package/src/stories/field-checker.stories.tsx +117 -117
- package/src/stories/field-icon.stories.tsx +70 -70
- package/src/stories/field-value-checker.stories.tsx +113 -113
- package/src/stories/fields-design-info-table.stories.tsx +203 -203
- package/src/stories/fields-generating.stories.tsx +41 -41
- package/src/stories/fields-previewer.stories.tsx +77 -77
- package/src/stories/subform-fields-value-checker.stories.tsx +98 -98
- package/src/style.css +226 -226
- package/src/types.ts +8 -8
- package/style.css +3652 -3652
- package/tsconfig.json +13 -13
- 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';
|