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