@tfdesign/b-end 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/AI_READ_FIRST.md +131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +353 -0
  4. package/package.json +67 -0
  5. package/scripts/check-tfds-contract.mjs +334 -0
  6. package/scripts/check-tfds-integration.mjs +263 -0
  7. package/scripts/postinstall-cursor-skill.mjs +382 -0
  8. package/scripts/setup.mjs +520 -0
  9. package/skills/tfds/CHECKLIST.md +205 -0
  10. package/skills/tfds/COMMON_FAILURES.md +238 -0
  11. package/skills/tfds/DESIGN_PRINCIPLES.md +477 -0
  12. package/skills/tfds/GLOBAL_DESIGN_RULES.md +636 -0
  13. package/skills/tfds/LAYOUT_RECIPES.md +140 -0
  14. package/skills/tfds/LAYOUT_RULES.md +1355 -0
  15. package/skills/tfds/PAGE_ARCHETYPES.md +201 -0
  16. package/skills/tfds/SKILL.md +188 -0
  17. package/skills/tfds/components.index.json +7305 -0
  18. package/skills/tfds/components.summary.json +1809 -0
  19. package/src/_b_end_runtime/components/AiSuggestionShared.jsx +166 -0
  20. package/src/_b_end_runtime/components/Avatar.jsx +325 -0
  21. package/src/_b_end_runtime/components/Avatar.tokens.js +76 -0
  22. package/src/_b_end_runtime/components/AvatarGridPreview.jsx +56 -0
  23. package/src/_b_end_runtime/components/AvatarGroup.jsx +80 -0
  24. package/src/_b_end_runtime/components/AvatarGroup.tokens.js +28 -0
  25. package/src/_b_end_runtime/components/Button.jsx +144 -0
  26. package/src/_b_end_runtime/components/Button.tokens.js +90 -0
  27. package/src/_b_end_runtime/components/Card.jsx +460 -0
  28. package/src/_b_end_runtime/components/Card.tokens.js +124 -0
  29. package/src/_b_end_runtime/components/CardPreview.jsx +51 -0
  30. package/src/_b_end_runtime/components/ChatBubble.jsx +384 -0
  31. package/src/_b_end_runtime/components/ChatBubble.tokens.js +60 -0
  32. package/src/_b_end_runtime/components/ChatBubblePreview.jsx +129 -0
  33. package/src/_b_end_runtime/components/ChatInput.jsx +1399 -0
  34. package/src/_b_end_runtime/components/ChatInput.tokens.js +75 -0
  35. package/src/_b_end_runtime/components/ChatMessage.jsx +2215 -0
  36. package/src/_b_end_runtime/components/ChatMessage.tokens.js +257 -0
  37. package/src/_b_end_runtime/components/ChatMessagePreview.jsx +388 -0
  38. package/src/_b_end_runtime/components/Checkbox.jsx +317 -0
  39. package/src/_b_end_runtime/components/Checkbox.tokens.js +59 -0
  40. package/src/_b_end_runtime/components/ConversationList.jsx +1264 -0
  41. package/src/_b_end_runtime/components/ConversationList.tokens.js +135 -0
  42. package/src/_b_end_runtime/components/ConversationListPreview.jsx +108 -0
  43. package/src/_b_end_runtime/components/CustomerServiceWorkspaceFrame.jsx +324 -0
  44. package/src/_b_end_runtime/components/CustomerServiceWorkspaceFrame.tokens.js +69 -0
  45. package/src/_b_end_runtime/components/DatePicker.jsx +739 -0
  46. package/src/_b_end_runtime/components/DatePicker.tokens.js +99 -0
  47. package/src/_b_end_runtime/components/Empty.jsx +141 -0
  48. package/src/_b_end_runtime/components/Empty.tokens.js +40 -0
  49. package/src/_b_end_runtime/components/Form.jsx +609 -0
  50. package/src/_b_end_runtime/components/Form.tokens.js +77 -0
  51. package/src/_b_end_runtime/components/FormFieldStack.jsx +123 -0
  52. package/src/_b_end_runtime/components/FormFieldStack.tokens.js +12 -0
  53. package/src/_b_end_runtime/components/FormTitle.jsx +119 -0
  54. package/src/_b_end_runtime/components/FormTitle.tokens.js +87 -0
  55. package/src/_b_end_runtime/components/FullScreenPage.jsx +97 -0
  56. package/src/_b_end_runtime/components/FullScreenPage.tokens.js +19 -0
  57. package/src/_b_end_runtime/components/Icon.jsx +172 -0
  58. package/src/_b_end_runtime/components/Icon.tokens.js +26 -0
  59. package/src/_b_end_runtime/components/IconGridPreview.jsx +277 -0
  60. package/src/_b_end_runtime/components/InfoDisplayPanel.jsx +620 -0
  61. package/src/_b_end_runtime/components/InfoDisplayPanel.tokens.js +71 -0
  62. package/src/_b_end_runtime/components/InfoDisplayPanelPreview.jsx +133 -0
  63. package/src/_b_end_runtime/components/Input.jsx +258 -0
  64. package/src/_b_end_runtime/components/Input.tokens.js +68 -0
  65. package/src/_b_end_runtime/components/InputNumber.jsx +242 -0
  66. package/src/_b_end_runtime/components/InputNumber.tokens.js +55 -0
  67. package/src/_b_end_runtime/components/Modal.jsx +155 -0
  68. package/src/_b_end_runtime/components/Modal.tokens.js +73 -0
  69. package/src/_b_end_runtime/components/NavBar.jsx +842 -0
  70. package/src/_b_end_runtime/components/NavBar.tokens.js +97 -0
  71. package/src/_b_end_runtime/components/NavBarPreview.jsx +11 -0
  72. package/src/_b_end_runtime/components/Radio.jsx +227 -0
  73. package/src/_b_end_runtime/components/Radio.tokens.js +59 -0
  74. package/src/_b_end_runtime/components/Select.jsx +766 -0
  75. package/src/_b_end_runtime/components/Select.tokens.js +99 -0
  76. package/src/_b_end_runtime/components/Sheet.jsx +132 -0
  77. package/src/_b_end_runtime/components/Sheet.tokens.js +61 -0
  78. package/src/_b_end_runtime/components/Slider.jsx +346 -0
  79. package/src/_b_end_runtime/components/Slider.tokens.js +47 -0
  80. package/src/_b_end_runtime/components/Switch.jsx +124 -0
  81. package/src/_b_end_runtime/components/Switch.tokens.js +38 -0
  82. package/src/_b_end_runtime/components/Table.jsx +1338 -0
  83. package/src/_b_end_runtime/components/Table.tokens.js +147 -0
  84. package/src/_b_end_runtime/components/TablePreview.jsx +599 -0
  85. package/src/_b_end_runtime/components/Tabs.jsx +149 -0
  86. package/src/_b_end_runtime/components/Tabs.tokens.js +102 -0
  87. package/src/_b_end_runtime/components/Tag.jsx +199 -0
  88. package/src/_b_end_runtime/components/Tag.tokens.js +171 -0
  89. package/src/_b_end_runtime/components/TagBar.jsx +1134 -0
  90. package/src/_b_end_runtime/components/TagBar.tokens.js +75 -0
  91. package/src/_b_end_runtime/components/TagGridPreview.jsx +23 -0
  92. package/src/_b_end_runtime/components/TagInput.jsx +382 -0
  93. package/src/_b_end_runtime/components/TagInput.tokens.js +52 -0
  94. package/src/_b_end_runtime/components/TextArea.jsx +363 -0
  95. package/src/_b_end_runtime/components/TextArea.tokens.js +65 -0
  96. package/src/_b_end_runtime/components/TimePicker.jsx +444 -0
  97. package/src/_b_end_runtime/components/TimePicker.tokens.js +77 -0
  98. package/src/_b_end_runtime/components/Toast.jsx +120 -0
  99. package/src/_b_end_runtime/components/Toast.tokens.js +146 -0
  100. package/src/_b_end_runtime/components/Tooltip.jsx +282 -0
  101. package/src/_b_end_runtime/components/Tooltip.tokens.js +48 -0
  102. package/src/_b_end_runtime/components/TooltipPreview.jsx +50 -0
  103. package/src/_b_end_runtime/components/Upload.jsx +455 -0
  104. package/src/_b_end_runtime/components/Upload.tokens.js +47 -0
  105. package/src/_b_end_runtime/components/avatar-assets/avatar-default.png +0 -0
  106. package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-1.png +0 -0
  107. package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-2.png +0 -0
  108. package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-3.png +0 -0
  109. package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-4.png +0 -0
  110. package/src/_b_end_runtime/components/avatar-group-assets/avatar-group-5.png +0 -0
  111. package/src/_b_end_runtime/components/empty-assets/administrator-1.svg +40 -0
  112. package/src/_b_end_runtime/components/empty-assets/administrator-2.svg +33 -0
  113. package/src/_b_end_runtime/components/empty-assets/construction.svg +33 -0
  114. package/src/_b_end_runtime/components/empty-assets/failure.svg +49 -0
  115. package/src/_b_end_runtime/components/empty-assets/idle.svg +34 -0
  116. package/src/_b_end_runtime/components/empty-assets/no-access.svg +36 -0
  117. package/src/_b_end_runtime/components/empty-assets/no-content.svg +77 -0
  118. package/src/_b_end_runtime/components/empty-assets/no-result.svg +61 -0
  119. package/src/_b_end_runtime/components/empty-assets/not-found.svg +46 -0
  120. package/src/_b_end_runtime/components/empty-assets/success.svg +38 -0
  121. package/src/_b_end_runtime/components/file-type-assets/batch-report.png +0 -0
  122. package/src/_b_end_runtime/components/file-type-assets/catcat.svg +21 -0
  123. package/src/_b_end_runtime/components/file-type-assets/code.png +0 -0
  124. package/src/_b_end_runtime/components/file-type-assets/conversation.png +0 -0
  125. package/src/_b_end_runtime/components/file-type-assets/document.png +0 -0
  126. package/src/_b_end_runtime/components/file-type-assets/feishu-card.png +0 -0
  127. package/src/_b_end_runtime/components/file-type-assets/feishu-sheet.png +0 -0
  128. package/src/_b_end_runtime/components/file-type-assets/feishu.png +0 -0
  129. package/src/_b_end_runtime/components/file-type-assets/image.png +0 -0
  130. package/src/_b_end_runtime/components/file-type-assets/index.js +105 -0
  131. package/src/_b_end_runtime/components/file-type-assets/knowledge.png +0 -0
  132. package/src/_b_end_runtime/components/file-type-assets/pdf.png +0 -0
  133. package/src/_b_end_runtime/components/file-type-assets/pe.png +0 -0
  134. package/src/_b_end_runtime/components/file-type-assets/strategy.png +0 -0
  135. package/src/_b_end_runtime/components/file-type-assets/table.png +0 -0
  136. package/src/_b_end_runtime/components/file-type-assets/webpage.png +0 -0
  137. package/src/_b_end_runtime/components/file-type-assets/xmind.png +0 -0
  138. package/src/_b_end_runtime/components/icons/icon-data.js +12496 -0
  139. package/src/_b_end_runtime/components/nav-bar-assets/bytehi-logo-mark.svg +21 -0
  140. package/src/_b_end_runtime/components/table-assets/avatar.png +0 -0
  141. package/src/_b_end_runtime/components/table-assets/button.png +0 -0
  142. package/src/_b_end_runtime/components/table-assets/icon-chevron-down.png +0 -0
  143. package/src/_b_end_runtime/components/table-cell-assets/avatar.png +0 -0
  144. package/src/_b_end_runtime/components/table-cell-assets/button.png +0 -0
  145. package/src/_b_end_runtime/components/table-cell-assets/checkbox.png +0 -0
  146. package/src/_b_end_runtime/components/table-cell-assets/icon-chevron-right.png +0 -0
  147. package/src/_b_end_runtime/components/table-cell-assets/icon.png +0 -0
  148. package/src/_b_end_runtime/components/table-cell-assets/semi-icons-handle.png +0 -0
  149. package/src/_b_end_runtime/components/table-cell-assets/semi-icons-tree-triangle-right.png +0 -0
  150. package/src/_b_end_runtime/components/table-cell-assets/switch.png +0 -0
  151. package/src/_b_end_runtime/components/tagShared.js +3 -0
  152. package/src/_b_end_runtime/components/team-avatar-assets/chengcheng-murphy.png +0 -0
  153. package/src/_b_end_runtime/components/team-avatar-assets/duan-ran.png +0 -0
  154. package/src/_b_end_runtime/components/team-avatar-assets/guo-zhezhi.png +0 -0
  155. package/src/_b_end_runtime/components/team-avatar-assets/li-siru.png +0 -0
  156. package/src/_b_end_runtime/components/team-avatar-assets/liu-delin.png +0 -0
  157. package/src/_b_end_runtime/components.js +3499 -0
  158. package/src/_b_end_runtime/index.js +9 -0
  159. package/src/_b_end_runtime/page-patterns/BasePageFramePattern.jsx +395 -0
  160. package/src/_b_end_runtime/page-patterns/ChatConversationPattern.jsx +989 -0
  161. package/src/_b_end_runtime/page-patterns/ChatHomePagePattern.jsx +281 -0
  162. package/src/_b_end_runtime/page-patterns/CopilotPagePattern.jsx +380 -0
  163. package/src/_b_end_runtime/page-patterns/CustomerServiceWorkspaceFramePattern.jsx +392 -0
  164. package/src/_b_end_runtime/page-patterns/IMConversationPattern.jsx +590 -0
  165. package/src/_b_end_runtime/page-patterns/McpManagementPage.jsx +237 -0
  166. package/src/_b_end_runtime/page-patterns/StrategyListPage.jsx +189 -0
  167. package/src/_b_end_runtime/page-patterns/TabTopBarListPage.jsx +594 -0
  168. package/src/_b_end_runtime/page-patterns/VariableManagementPage.jsx +87 -0
  169. package/src/_b_end_runtime/page-patterns/pageListShared.jsx +177 -0
  170. package/src/_b_end_runtime/patterns.js +428 -0
  171. package/src/_b_end_runtime/preview-registry.jsx +4719 -0
  172. package/src/_b_end_runtime/teamMembers.js +56 -0
  173. package/src/_b_end_runtime/tokens.js +500 -0
  174. package/src/index.d.ts +1073 -0
  175. package/src/index.js +52 -0
  176. package/theme.css +350 -0
@@ -0,0 +1,766 @@
1
+ /**
2
+ * Select — 从预定义 options 中选择(触发器与 Input 同构)
3
+ *
4
+ * **何时用**:选项较多、需省纵向空间、或枚举会扩展;多选且以标签回显用 `mode="tag"`。
5
+ * **不用**:≤5 项且需并排通览比较 → `RadioGroup`;多选且要一眼看到全部选项 → `CheckboxGroup`。
6
+ *
7
+ * @prop {'default'|'error'} [status='default'] — 状态
8
+ * @prop {boolean} [disabled=false] — 是否禁用
9
+ * @prop {boolean} [allowClear=false] — 可清除
10
+ * @prop {'default'|'tag'} [mode='default'] — 选择器形态,tag 为标签选择
11
+ * @prop {string} [aiSuggestion] — Select 下方单条 AI 推荐,点击后映射为既有 option(仅 mode="default")
12
+ * @prop {Array<string|{label:string,value:string|number}>} [aiSuggestions] — Select 下方多条 AI 推荐(仅 mode="default")
13
+ * @prop {string} [placeholder='请选择'] — 占位
14
+ * @prop {Array<{value:string|number,label:string,disabled?:boolean,variant?:string}>|null} [options=null] — 选项
15
+ * @prop {string|number|Array} value — 当前值(受控)
16
+ * @prop {string|number|Array|null} [defaultValue=null] — 非受控初值
17
+ * @prop {(value: string|number|undefined, option?: object) => void} [onChange=null] — 变更回调
18
+ * @prop {() => void} [onClear=null] — 清除回调
19
+ * @prop {(value: string|number, option?: object) => void} [onAdoptSuggestion=null] — 采纳 AI 推荐回调(仅 mode="default")
20
+ * @prop {() => void} [onRefreshAiSuggestions=null] — 刷新 AI 推荐回调(仅 mode="default")
21
+ * @prop {import('react').ReactNode} [prefix=null] — 前置内容
22
+ * @prop {import('react').ReactNode} [indicator=null] — 自定义右侧指示器
23
+ * @prop {string} [className=''] — 类名
24
+ */
25
+
26
+ import {
27
+ useState,
28
+ useRef,
29
+ useCallback,
30
+ useEffect,
31
+ useLayoutEffect,
32
+ useMemo,
33
+ useId,
34
+ } from 'react';
35
+ import { createPortal } from 'react-dom';
36
+ import Tag from './Tag';
37
+ import { DEFAULT_TAG_RADIUS, DEFAULT_TAG_SIZE, DEFAULT_TAG_VARIANT } from './tagShared';
38
+ import Tooltip from './Tooltip';
39
+ import AiSuggestionPanel, { AiRefreshButton, rotateList } from './AiSuggestionShared';
40
+
41
+ /* ── 面板布局 ── */
42
+ const DEFAULT_PANEL_GAP = 4;
43
+ const PANEL_MAX = 240;
44
+ const PANEL_Z = 10000;
45
+
46
+ /* ── 触发器容器 ── */
47
+ const BASE = [
48
+ 'tfds-select',
49
+ /* 默认随父级撑满宽;需固定 300px 时在业务侧包一层 w-[300px](如 Select 详情预览) */
50
+ 'group relative flex w-full min-w-0 max-w-full items-center',
51
+ 'border border-solid rounded-md',
52
+ 'transition-all duration-150',
53
+ 'outline-none',
54
+ '[font-family:inherit]',
55
+ ].join(' ');
56
+
57
+ const WRAPPER_CLASS = 'group/select inline-flex w-full max-w-full flex-col items-start gap-1 [font-family:inherit]';
58
+
59
+ /* ── 触发器状态 ── */
60
+ const STATUS_CLASS = {
61
+ default: [
62
+ 'bg-surface border-border-default',
63
+ 'hover:border-border-strong',
64
+ 'data-[open=true]:border-primary',
65
+ 'data-[open=true]:hover:border-primary',
66
+ ].join(' '),
67
+ error: [
68
+ 'bg-red-50 border-border-default',
69
+ 'hover:bg-red-100',
70
+ 'data-[open=true]:border-red-500',
71
+ 'data-[open=true]:hover:border-red-500 data-[open=true]:hover:bg-red-50',
72
+ ].join(' '),
73
+ };
74
+
75
+ /* ── 触发器尺寸 ── */
76
+ const SIZE_CLASS = 'min-h-[36px] pl-3 pr-3 text-sm gap-2';
77
+
78
+ /* ── 触发器禁用 ── */
79
+ const DISABLED_CLASS = 'bg-disabled border-border-default cursor-not-allowed opacity-60';
80
+
81
+ /* ── 展示文字 ── */
82
+ const LABEL_CLASS = [
83
+ 'flex-1 min-w-0 text-left leading-[20px] truncate',
84
+ 'text-foreground',
85
+ ].join(' ');
86
+
87
+ const TAG_CONTENT_ROW = 'flex min-w-0 flex-1 items-center gap-1 overflow-hidden';
88
+ const MEASURE_ROW = 'pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap';
89
+
90
+ /* ── 占位样式 ── */
91
+ const PLACEHOLDER_CLASS = 'text-foreground-muted';
92
+ const PREFIX_CLASS = 'inline-flex shrink-0 items-center';
93
+
94
+ /* ── 下拉箭头 ── */
95
+ const CHEVRON_CLASS = [
96
+ 'inline-flex size-[16px] shrink-0 items-center justify-center text-foreground-muted transition-transform duration-150 [&>svg]:size-[16px]',
97
+ 'group-data-[open=true]:rotate-180',
98
+ ].join(' ');
99
+
100
+ /* ── 清除按钮 ── */
101
+ const CLEAR_CLASS = [
102
+ 'inline-flex items-center justify-center shrink-0 size-[16px]',
103
+ 'rounded-full cursor-pointer',
104
+ 'text-foreground-disabled hover:text-foreground-secondary',
105
+ 'opacity-0 group-hover/select:opacity-100 transition-opacity duration-150',
106
+ 'bg-transparent border-none p-0 mr-1',
107
+ ].join(' ');
108
+
109
+ const SELECT_AI_REFRESH_CLASS = [
110
+ 'inline-flex items-center justify-center shrink-0 size-[var(--spacing-4)]',
111
+ 'rounded-full cursor-pointer',
112
+ 'text-foreground-muted hover:text-foreground-secondary',
113
+ 'opacity-0 group-hover/select:opacity-100 transition-opacity duration-150',
114
+ 'bg-transparent border-none p-0',
115
+ 'transition-colors duration-150',
116
+ ].join(' ');
117
+
118
+ /* ── 下拉面板 ── */
119
+ const PANEL_CLASS = [
120
+ 'rounded-md border border-solid border-border-default',
121
+ 'bg-surface shadow-lg',
122
+ 'py-1 overflow-y-auto overflow-x-hidden',
123
+ '[font-family:inherit]',
124
+ ].join(' ');
125
+
126
+ /* ── 选项基础 ── */
127
+ const OPTION_BASE = [
128
+ 'px-3 py-2 cursor-pointer truncate',
129
+ 'text-foreground text-sm leading-[20px]',
130
+ 'transition-colors duration-100',
131
+ ].join(' ');
132
+
133
+ /* ── 选项悬浮 ── */
134
+ const OPTION_HOVER = 'hover:bg-fill';
135
+
136
+ /* ── 选项选中 ── */
137
+ const OPTION_ACTIVE = 'bg-brand-50 text-brand-500';
138
+
139
+ /* ── 选项禁用 ── */
140
+ const OPTION_DISABLED = 'opacity-50 cursor-not-allowed pointer-events-none text-foreground-disabled';
141
+ const TAG_OPTION_BASE = [
142
+ 'flex min-h-[36px] cursor-pointer items-center px-3 py-2',
143
+ 'transition-colors duration-100',
144
+ ].join(' ');
145
+ const TAG_OPTION_ACTIVE = 'bg-fill';
146
+
147
+ function getValueKey(value) {
148
+ return String(value);
149
+ }
150
+
151
+ function normalizeSelectedValues(raw) {
152
+ if (Array.isArray(raw)) return raw.map(getValueKey);
153
+ if (raw === null || raw === undefined || raw === '') return [];
154
+ return [getValueKey(raw)];
155
+ }
156
+
157
+ function valuesFromKeys(keys, options) {
158
+ return keys
159
+ .map((key) => options.find((opt) => getValueKey(opt.value) === key)?.value)
160
+ .filter((item) => item !== undefined);
161
+ }
162
+
163
+ function getVisibleCount(tagWidths, moreWidth, availableWidth) {
164
+ if (tagWidths.length === 0) return 0;
165
+ const gap = 4;
166
+ let used = 0;
167
+ for (let i = 0; i < tagWidths.length; i += 1) {
168
+ const remaining = tagWidths.length - i - 1;
169
+ const reserveMore = remaining > 0 ? moreWidth + gap : 0;
170
+ const nextUsed = used + tagWidths[i] + (i > 0 ? gap : 0);
171
+ if (nextUsed + reserveMore <= availableWidth) {
172
+ used = nextUsed;
173
+ continue;
174
+ }
175
+ return Math.max(0, i);
176
+ }
177
+ return tagWidths.length;
178
+ }
179
+
180
+ function normalizeSelectAiSuggestions(aiSuggestion, aiSuggestions, options) {
181
+ const source = Array.isArray(aiSuggestions) && aiSuggestions.length > 0
182
+ ? aiSuggestions
183
+ : (typeof aiSuggestion === 'string' && aiSuggestion.trim().length > 0 ? [aiSuggestion] : []);
184
+
185
+ return source
186
+ .map((item, index) => {
187
+ if (typeof item === 'string') {
188
+ const label = item.trim();
189
+ if (!label) return null;
190
+ const option = options.find((opt) => String(opt.label) === label);
191
+ if (!option) return null;
192
+ return {
193
+ id: `select-ai-suggestion-${index}-${getValueKey(option.value)}`,
194
+ label,
195
+ value: option.value,
196
+ option,
197
+ };
198
+ }
199
+
200
+ if (!item || typeof item !== 'object') return null;
201
+
202
+ const hasValue = item.value !== undefined && item.value !== null && item.value !== '';
203
+ const hasLabel = typeof item.label === 'string' && item.label.trim().length > 0;
204
+ const option = hasValue
205
+ ? options.find((opt) => getValueKey(opt.value) === getValueKey(item.value))
206
+ : options.find((opt) => String(opt.label) === item.label.trim());
207
+
208
+ if (!option) return null;
209
+
210
+ return {
211
+ id: `select-ai-suggestion-${index}-${getValueKey(option.value)}`,
212
+ label: hasLabel ? item.label.trim() : String(option.label),
213
+ value: option.value,
214
+ option,
215
+ };
216
+ })
217
+ .filter(Boolean);
218
+ }
219
+
220
+ function buildSelectSuggestionGroups(aiSuggestion, aiSuggestions, options) {
221
+ const hasSource = (typeof aiSuggestion === 'string' && aiSuggestion.trim().length > 0)
222
+ || (Array.isArray(aiSuggestions) && aiSuggestions.length > 0);
223
+ if (!hasSource) return [];
224
+
225
+ const baseGroup = normalizeSelectAiSuggestions(aiSuggestion, aiSuggestions, options);
226
+ const groups = [];
227
+ const serialized = new Set();
228
+
229
+ const pushGroup = (group) => {
230
+ if (!Array.isArray(group) || group.length === 0) return;
231
+ const key = JSON.stringify(group.map((item) => getValueKey(item.value)));
232
+ if (serialized.has(key)) return;
233
+ serialized.add(key);
234
+ groups.push(group);
235
+ };
236
+
237
+ pushGroup(baseGroup);
238
+
239
+ if (baseGroup.length > 1) {
240
+ for (let offset = 1; offset < baseGroup.length; offset += 1) {
241
+ pushGroup(rotateList(baseGroup, offset));
242
+ }
243
+ }
244
+
245
+ const optionSuggestions = options
246
+ .filter((opt) => !opt.disabled)
247
+ .map((option, index) => ({
248
+ id: `select-ai-option-${index}-${getValueKey(option.value)}`,
249
+ label: String(option.label),
250
+ value: option.value,
251
+ option,
252
+ }));
253
+ const groupSize = Math.max(1, Math.min(baseGroup.length || 1, optionSuggestions.length));
254
+
255
+ if (optionSuggestions.length > 0) {
256
+ for (let offset = 0; offset < optionSuggestions.length; offset += 1) {
257
+ pushGroup(rotateList(optionSuggestions, offset).slice(0, groupSize));
258
+ }
259
+ }
260
+
261
+ return groups;
262
+ }
263
+
264
+ /* ── 面板定位计算 ── */
265
+ function measurePanelPosition(triggerEl, maxHeightPref, gap = DEFAULT_PANEL_GAP, panelHeight) {
266
+ const rect = triggerEl.getBoundingClientRect();
267
+ const spaceBelow = window.innerHeight - rect.bottom - gap;
268
+ const spaceAbove = rect.top - gap;
269
+ const expectedHeight = Math.max(80, Math.min(maxHeightPref, panelHeight ?? maxHeightPref));
270
+ const threshold = Math.min(expectedHeight, 200);
271
+ const fitsBelow = spaceBelow >= threshold;
272
+ let top;
273
+ let maxHeight;
274
+ let placement;
275
+ if (fitsBelow) {
276
+ top = rect.bottom + gap;
277
+ maxHeight = Math.min(maxHeightPref, spaceBelow - 4);
278
+ placement = 'below';
279
+ } else {
280
+ maxHeight = Math.min(maxHeightPref, spaceAbove - 4);
281
+ top = rect.top - Math.min(expectedHeight, maxHeight) - gap;
282
+ placement = 'above';
283
+ }
284
+ return {
285
+ top,
286
+ left: rect.left,
287
+ width: rect.width,
288
+ maxHeight: Math.max(80, maxHeight),
289
+ placement,
290
+ };
291
+ }
292
+
293
+ export default function Select({
294
+ options = null,
295
+ value,
296
+ defaultValue = null,
297
+ onChange = null,
298
+ onClear = null,
299
+ aiSuggestion,
300
+ aiSuggestions,
301
+ placeholder = '请选择',
302
+ mode = 'default',
303
+ size: _size = 'md',
304
+ status = 'default',
305
+ disabled = false,
306
+ allowClear = false,
307
+ onAdoptSuggestion = null,
308
+ onRefreshAiSuggestions = null,
309
+ panelGap = DEFAULT_PANEL_GAP,
310
+ tagVariant: _legacyTagVariant = DEFAULT_TAG_VARIANT,
311
+ tagSize: _legacyTagSize = DEFAULT_TAG_SIZE,
312
+ prefix = null,
313
+ indicator = null,
314
+ className = '',
315
+ ...rest
316
+ }) {
317
+ const optionList = options ?? [];
318
+ const isTagMode = mode === 'tag';
319
+ const uid = useId();
320
+ const listboxId = `${uid}-listbox`;
321
+ const isControlled = value !== undefined;
322
+ const [innerValue, setInnerValue] = useState(defaultValue);
323
+ const [isSuggestionDismissed, setIsSuggestionDismissed] = useState(false);
324
+ const [suggestionIndex, setSuggestionIndex] = useState(0);
325
+ const currentValue = isControlled ? value : innerValue;
326
+ const selectedValueKeys = useMemo(() => normalizeSelectedValues(currentValue), [currentValue]);
327
+
328
+ const [open, setOpen] = useState(false);
329
+ const [highlight, setHighlight] = useState(-1);
330
+ const [visibleCount, setVisibleCount] = useState(null);
331
+ const [panelStyle, setPanelStyle] = useState({
332
+ top: 0, left: 0, width: 0, maxHeight: PANEL_MAX, placement: 'below',
333
+ });
334
+
335
+ const triggerRef = useRef(null);
336
+ const panelRef = useRef(null);
337
+ const measureRef = useRef(null);
338
+ const moreMeasureRef = useRef(null);
339
+
340
+ const selected = useMemo(
341
+ () => optionList.find((o) => getValueKey(o.value) === getValueKey(currentValue)),
342
+ [optionList, currentValue],
343
+ );
344
+ const selectedTagOptions = useMemo(
345
+ () => selectedValueKeys
346
+ .map((key) => optionList.find((o) => getValueKey(o.value) === key))
347
+ .filter(Boolean),
348
+ [optionList, selectedValueKeys],
349
+ );
350
+ const hasValue = isTagMode ? selectedTagOptions.length > 0 : selected != null;
351
+ const showClear = allowClear && !disabled && hasValue;
352
+ const hasAiSuggestionSource = !isTagMode && (
353
+ (typeof aiSuggestion === 'string' && aiSuggestion.trim().length > 0)
354
+ || (Array.isArray(aiSuggestions) && aiSuggestions.length > 0)
355
+ );
356
+ const suggestionGroups = useMemo(
357
+ () => (isTagMode ? [] : buildSelectSuggestionGroups(aiSuggestion, aiSuggestions, optionList)),
358
+ [aiSuggestion, aiSuggestions, isTagMode, optionList],
359
+ );
360
+ const normalizedAiSuggestions = suggestionGroups[suggestionIndex] || suggestionGroups[0] || [];
361
+ const canRefreshLocally = suggestionGroups.length > 1;
362
+ const showSuggestion = !disabled && !isTagMode && !isSuggestionDismissed && normalizedAiSuggestions.length > 0;
363
+ const showAiRefresh = !disabled && !isTagMode && hasAiSuggestionSource
364
+ && (canRefreshLocally || typeof onRefreshAiSuggestions === 'function');
365
+
366
+ const commit = useCallback((nextVal, opt) => {
367
+ if (!isControlled) setInnerValue(nextVal);
368
+ onChange?.(nextVal, opt);
369
+ }, [isControlled, onChange]);
370
+
371
+ const measureTags = useCallback(() => {
372
+ if (!isTagMode) return;
373
+ const root = triggerRef.current;
374
+ const row = measureRef.current;
375
+ if (!root || !row) return;
376
+ const reservedRight = 36 + (showClear ? 20 : 0) + (showAiRefresh ? 20 : 0);
377
+ const availableWidth = Math.max(0, root.clientWidth - reservedRight - 24);
378
+ const tagWidths = Array.from(row.querySelectorAll('[data-measure-tag]'))
379
+ .map((node) => Math.ceil(node.getBoundingClientRect().width));
380
+ const moreWidth = Math.ceil(moreMeasureRef.current?.getBoundingClientRect().width || 34);
381
+ setVisibleCount(getVisibleCount(tagWidths, moreWidth, availableWidth));
382
+ }, [isTagMode, showAiRefresh, showClear]);
383
+
384
+ useEffect(() => {
385
+ setIsSuggestionDismissed(false);
386
+ setSuggestionIndex(0);
387
+ }, [aiSuggestion, aiSuggestions]);
388
+
389
+ useLayoutEffect(() => {
390
+ measureTags();
391
+ }, [measureTags, selectedTagOptions, status]);
392
+
393
+ useEffect(() => {
394
+ if (!isTagMode || !triggerRef.current || typeof ResizeObserver === 'undefined') return undefined;
395
+ const observer = new ResizeObserver(() => measureTags());
396
+ observer.observe(triggerRef.current);
397
+ return () => observer.disconnect();
398
+ }, [isTagMode, measureTags]);
399
+
400
+ const updatePosition = useCallback(() => {
401
+ if (!triggerRef.current) return;
402
+ const actualPanelHeight = panelRef.current?.getBoundingClientRect().height;
403
+ setPanelStyle(measurePanelPosition(triggerRef.current, PANEL_MAX, panelGap, actualPanelHeight));
404
+ }, [panelGap]);
405
+
406
+ useLayoutEffect(() => {
407
+ if (!open) return;
408
+ updatePosition();
409
+ }, [open, updatePosition, status, optionList.length]);
410
+
411
+ useEffect(() => {
412
+ if (!open) return;
413
+ const onScrollOrResize = () => updatePosition();
414
+ window.addEventListener('scroll', onScrollOrResize, true);
415
+ window.addEventListener('resize', onScrollOrResize);
416
+ return () => {
417
+ window.removeEventListener('scroll', onScrollOrResize, true);
418
+ window.removeEventListener('resize', onScrollOrResize);
419
+ };
420
+ }, [open, updatePosition]);
421
+
422
+ useEffect(() => {
423
+ if (!open) return;
424
+ const handler = (e) => {
425
+ const t = e.target;
426
+ if (
427
+ triggerRef.current?.contains(t)
428
+ || panelRef.current?.contains(t)
429
+ ) return;
430
+ setOpen(false);
431
+ setHighlight(-1);
432
+ };
433
+ document.addEventListener('mousedown', handler);
434
+ return () => document.removeEventListener('mousedown', handler);
435
+ }, [open]);
436
+
437
+ useEffect(() => {
438
+ if (!open || highlight < 0) return;
439
+ const el = panelRef.current?.querySelector(`[data-option-index="${highlight}"]`);
440
+ el?.scrollIntoView({ block: 'nearest' });
441
+ }, [highlight, open]);
442
+
443
+ const toggleOpen = useCallback(() => {
444
+ if (disabled) return;
445
+ setOpen((o) => {
446
+ const next = !o;
447
+ if (next) {
448
+ const activeKey = isTagMode ? selectedValueKeys[0] : getValueKey(currentValue);
449
+ const si = optionList.findIndex((x) => getValueKey(x.value) === activeKey);
450
+ setHighlight(si >= 0 ? si : 0);
451
+ } else {
452
+ setHighlight(-1);
453
+ }
454
+ return next;
455
+ });
456
+ }, [disabled, optionList, currentValue, isTagMode, selectedValueKeys]);
457
+
458
+ const pick = useCallback((opt) => {
459
+ if (opt.disabled) return;
460
+ if (isTagMode) {
461
+ const key = getValueKey(opt.value);
462
+ const nextKeys = selectedValueKeys.includes(key)
463
+ ? selectedValueKeys.filter((item) => item !== key)
464
+ : [...selectedValueKeys, key];
465
+ commit(valuesFromKeys(nextKeys, optionList), opt);
466
+ return;
467
+ }
468
+ commit(opt.value, opt);
469
+ setOpen(false);
470
+ setHighlight(-1);
471
+ }, [commit, isTagMode, selectedValueKeys]);
472
+
473
+ const removeTag = useCallback((opt, event) => {
474
+ event?.stopPropagation();
475
+ if (disabled || !isTagMode) return;
476
+ const key = getValueKey(opt.value);
477
+ commit(valuesFromKeys(selectedValueKeys.filter((item) => item !== key), optionList), opt);
478
+ }, [commit, disabled, isTagMode, optionList, selectedValueKeys]);
479
+
480
+ const handleClear = useCallback((e) => {
481
+ e.stopPropagation();
482
+ if (disabled) return;
483
+ commit(isTagMode ? [] : undefined, undefined);
484
+ onClear?.();
485
+ setOpen(false);
486
+ setHighlight(-1);
487
+ }, [disabled, commit, isTagMode, onClear]);
488
+
489
+ const handleAdoptSuggestion = useCallback((suggestion) => {
490
+ if (!suggestion?.option || disabled || isTagMode) return;
491
+ commit(suggestion.value, suggestion.option);
492
+ onAdoptSuggestion?.(suggestion.value, suggestion.option);
493
+ setIsSuggestionDismissed(true);
494
+ setOpen(false);
495
+ setHighlight(-1);
496
+ triggerRef.current?.focus();
497
+ }, [commit, disabled, isTagMode, onAdoptSuggestion]);
498
+
499
+ const handleRefreshAiSuggestions = useCallback((event) => {
500
+ event?.stopPropagation();
501
+ if (disabled || isTagMode) return;
502
+ setOpen(false);
503
+ setHighlight(-1);
504
+ if (canRefreshLocally) {
505
+ setSuggestionIndex((prev) => (prev + 1) % suggestionGroups.length);
506
+ }
507
+ setIsSuggestionDismissed(false);
508
+ onRefreshAiSuggestions?.();
509
+ triggerRef.current?.focus();
510
+ }, [canRefreshLocally, disabled, isTagMode, onRefreshAiSuggestions, suggestionGroups.length]);
511
+
512
+ const onTriggerKeyDown = useCallback((e) => {
513
+ if (disabled) return;
514
+ if (e.key === 'Escape') {
515
+ if (open) {
516
+ e.preventDefault();
517
+ setOpen(false);
518
+ setHighlight(-1);
519
+ }
520
+ return;
521
+ }
522
+ if (e.key === 'Enter' || e.key === ' ') {
523
+ if (!open) {
524
+ e.preventDefault();
525
+ setOpen(true);
526
+ const activeKey = isTagMode ? selectedValueKeys[0] : getValueKey(currentValue);
527
+ const si = optionList.findIndex((x) => getValueKey(x.value) === activeKey);
528
+ setHighlight(si >= 0 ? si : 0);
529
+ } else if (highlight >= 0 && optionList[highlight] && !optionList[highlight].disabled) {
530
+ e.preventDefault();
531
+ pick(optionList[highlight]);
532
+ }
533
+ return;
534
+ }
535
+ if (e.key === 'ArrowDown') {
536
+ e.preventDefault();
537
+ if (!open) {
538
+ setOpen(true);
539
+ setHighlight(0);
540
+ return;
541
+ }
542
+ setHighlight((h) => {
543
+ let i = h < 0 ? 0 : h;
544
+ for (let step = 0; step < optionList.length; step += 1) {
545
+ i = (i + 1) % optionList.length;
546
+ if (!optionList[i]?.disabled) return i;
547
+ }
548
+ return h;
549
+ });
550
+ }
551
+ if (e.key === 'ArrowUp') {
552
+ e.preventDefault();
553
+ if (!open) {
554
+ setOpen(true);
555
+ setHighlight(optionList.length - 1);
556
+ return;
557
+ }
558
+ setHighlight((h) => {
559
+ let i = h < 0 ? optionList.length - 1 : h;
560
+ for (let step = 0; step < optionList.length; step += 1) {
561
+ i = (i - 1 + optionList.length) % optionList.length;
562
+ if (!optionList[i]?.disabled) return i;
563
+ }
564
+ return h;
565
+ });
566
+ }
567
+ if (e.key === 'Tab' && open) {
568
+ setOpen(false);
569
+ setHighlight(-1);
570
+ }
571
+ }, [disabled, open, optionList, highlight, currentValue, isTagMode, selectedValueKeys, pick]);
572
+
573
+ const containerCls = [
574
+ BASE,
575
+ disabled ? DISABLED_CLASS : STATUS_CLASS[status],
576
+ SIZE_CLASS,
577
+ className,
578
+ ].filter(Boolean).join(' ');
579
+
580
+ const safeVisibleCount = visibleCount == null
581
+ ? selectedTagOptions.length
582
+ : Math.min(visibleCount, selectedTagOptions.length);
583
+ const visibleTagOptions = selectedTagOptions.slice(0, safeVisibleCount);
584
+ const hiddenTagOptions = selectedTagOptions.slice(safeVisibleCount);
585
+
586
+ const renderTag = (opt, tagProps = {}) => (
587
+ <Tag
588
+ key={getValueKey(opt.value)}
589
+ variant={opt.variant || DEFAULT_TAG_VARIANT}
590
+ size={DEFAULT_TAG_SIZE}
591
+ radius={DEFAULT_TAG_RADIUS}
592
+ {...tagProps}
593
+ >
594
+ {opt.label}
595
+ </Tag>
596
+ );
597
+
598
+ const hiddenContent = hiddenTagOptions.length > 0 ? (
599
+ <div className="flex max-w-[248px] flex-wrap gap-1">
600
+ {hiddenTagOptions.map((opt) => renderTag(opt))}
601
+ </div>
602
+ ) : null;
603
+
604
+ const panel = open && !disabled ? (
605
+ <div
606
+ ref={panelRef}
607
+ id={listboxId}
608
+ role="listbox"
609
+ aria-multiselectable={isTagMode || undefined}
610
+ className={PANEL_CLASS}
611
+ style={{
612
+ position: 'fixed',
613
+ top: panelStyle.top,
614
+ left: panelStyle.left,
615
+ width: panelStyle.width,
616
+ maxHeight: panelStyle.maxHeight,
617
+ zIndex: PANEL_Z,
618
+ }}
619
+ >
620
+ {optionList.length === 0 ? (
621
+ <div className="px-3 py-2 text-foreground-muted text-sm">
622
+ 暂无数据
623
+ </div>
624
+ ) : (
625
+ optionList.map((opt, i) => {
626
+ const selectedOpt = isTagMode
627
+ ? selectedValueKeys.includes(getValueKey(opt.value))
628
+ : getValueKey(opt.value) === getValueKey(currentValue);
629
+ const hi = highlight === i;
630
+ const cls = [
631
+ isTagMode ? TAG_OPTION_BASE : OPTION_BASE,
632
+ !opt.disabled && OPTION_HOVER,
633
+ (selectedOpt || hi) && !opt.disabled ? (isTagMode ? TAG_OPTION_ACTIVE : OPTION_ACTIVE) : '',
634
+ opt.disabled ? OPTION_DISABLED : '',
635
+ ].filter(Boolean).join(' ');
636
+ return (
637
+ <div
638
+ key={`${String(opt.value)}-${i}`}
639
+ role="option"
640
+ aria-selected={selectedOpt}
641
+ data-option-index={i}
642
+ className={cls}
643
+ onMouseEnter={() => !opt.disabled && setHighlight(i)}
644
+ onMouseDown={(e) => e.preventDefault()}
645
+ onClick={() => pick(opt)}
646
+ >
647
+ {isTagMode ? (
648
+ <Tag
649
+ variant={opt.variant || DEFAULT_TAG_VARIANT}
650
+ size={DEFAULT_TAG_SIZE}
651
+ radius={DEFAULT_TAG_RADIUS}
652
+ className={selectedOpt ? 'ring-1 ring-primary/40' : ''}
653
+ >
654
+ {opt.label}
655
+ </Tag>
656
+ ) : opt.label}
657
+ </div>
658
+ );
659
+ })
660
+ )}
661
+ </div>
662
+ ) : null;
663
+
664
+ return (
665
+ <div className={WRAPPER_CLASS}>
666
+ <div
667
+ ref={triggerRef}
668
+ {...rest}
669
+ role="combobox"
670
+ aria-expanded={open}
671
+ aria-controls={listboxId}
672
+ aria-haspopup="listbox"
673
+ aria-disabled={disabled || undefined}
674
+ data-open={open}
675
+ data-tfds-component="Select"
676
+ tabIndex={disabled ? -1 : 0}
677
+ className={containerCls}
678
+ onClick={toggleOpen}
679
+ onKeyDown={onTriggerKeyDown}
680
+ >
681
+ {prefix ? <span className={PREFIX_CLASS}>{prefix}</span> : null}
682
+ {isTagMode ? (
683
+ <span className={TAG_CONTENT_ROW}>
684
+ {hasValue ? (
685
+ <>
686
+ {visibleTagOptions.map((opt) => (
687
+ renderTag(opt, {
688
+ closable: !disabled,
689
+ onClose: !disabled ? (event) => removeTag(opt, event) : undefined,
690
+ })
691
+ ))}
692
+ {hiddenTagOptions.length > 0 ? (
693
+ <Tooltip
694
+ content={hiddenContent}
695
+ tone="light"
696
+ placement="top"
697
+ triggerClassName="inline-flex shrink-0"
698
+ className="!max-w-[280px]"
699
+ >
700
+ <Tag variant={DEFAULT_TAG_VARIANT} size={DEFAULT_TAG_SIZE} radius={DEFAULT_TAG_RADIUS}>+{hiddenTagOptions.length}</Tag>
701
+ </Tooltip>
702
+ ) : null}
703
+ </>
704
+ ) : (
705
+ <span className={PLACEHOLDER_CLASS}>{placeholder}</span>
706
+ )}
707
+ </span>
708
+ ) : (
709
+ <span className={hasValue ? LABEL_CLASS : `${LABEL_CLASS} ${PLACEHOLDER_CLASS}`}>
710
+ {hasValue ? selected.label : placeholder}
711
+ </span>
712
+ )}
713
+ {showClear ? (
714
+ <button
715
+ type="button"
716
+ className={CLEAR_CLASS}
717
+ onClick={handleClear}
718
+ aria-label="清除"
719
+ tabIndex={-1}
720
+ >
721
+ <svg viewBox="0 0 16 16" fill="none" className="size-[16px]">
722
+ <circle cx="8" cy="8" r="7" fill="currentColor" fillOpacity="0.15" />
723
+ <path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
724
+ </svg>
725
+ </button>
726
+ ) : null}
727
+ {showAiRefresh ? (
728
+ <AiRefreshButton onClick={handleRefreshAiSuggestions} className={SELECT_AI_REFRESH_CLASS} />
729
+ ) : null}
730
+ {indicator ? (
731
+ <span className="inline-flex size-[16px] shrink-0 items-center justify-center [&>svg]:size-[16px]" aria-hidden>
732
+ {indicator}
733
+ </span>
734
+ ) : (
735
+ <span className={CHEVRON_CLASS} aria-hidden>
736
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
737
+ <path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
738
+ </svg>
739
+ </span>
740
+ )}
741
+ {isTagMode ? (
742
+ <span ref={measureRef} className={MEASURE_ROW} aria-hidden>
743
+ {selectedTagOptions.map((opt) => (
744
+ <span key={getValueKey(opt.value)} data-measure-tag>
745
+ {renderTag(opt, { closable: !disabled })}
746
+ </span>
747
+ ))}
748
+ <span ref={moreMeasureRef}>
749
+ <Tag variant={DEFAULT_TAG_VARIANT} size={DEFAULT_TAG_SIZE} radius={DEFAULT_TAG_RADIUS}>+99</Tag>
750
+ </span>
751
+ </span>
752
+ ) : null}
753
+ </div>
754
+ {showSuggestion ? (
755
+ <AiSuggestionPanel
756
+ suggestions={normalizedAiSuggestions}
757
+ onSelect={handleAdoptSuggestion}
758
+ getSuggestionKey={(suggestion) => suggestion.id}
759
+ getSuggestionLabel={(suggestion) => suggestion.label}
760
+ getSuggestionAriaLabel={(suggestion, index) => `采纳 AI 推荐选项 ${index + 1}:${suggestion.label}`}
761
+ />
762
+ ) : null}
763
+ {panel ? createPortal(panel, document.body) : null}
764
+ </div>
765
+ );
766
+ }