@baishuyun/ai-design 2.0.19 → 2.0.21

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @baishuyun/ai-design
2
2
 
3
+ ## 2.0.21
4
+
5
+ ### Patch Changes
6
+
7
+ - fix chevron icon
8
+
9
+ ## 2.0.20
10
+
11
+ ### Patch Changes
12
+
13
+ - multiple select
14
+
3
15
  ## 2.0.19
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baishuyun/ai-design",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "AI 功能 UI 组件库 - 所有 AI 功能的 UI 表现唯一真相来源",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -60,8 +60,8 @@
60
60
  "tw-animate-css": "^1.4.0",
61
61
  "vaul": "^1.1.2",
62
62
  "zod": "^4.1.13",
63
- "@baishuyun/types": "3.0.19",
64
- "@baishuyun/ai-feat-utils": "2.0.19"
63
+ "@baishuyun/ai-feat-utils": "2.0.19",
64
+ "@baishuyun/types": "3.0.19"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "react": "^19.0.0",
@@ -83,8 +83,8 @@
83
83
  "typescript": "^5.5.0",
84
84
  "vite": "^5.1.4",
85
85
  "vite-plugin-dts": "^4.5.4",
86
- "@baishuyun/types": "3.0.19",
87
86
  "@baishuyun/typescript-config": "2.0.9",
87
+ "@baishuyun/types": "3.0.19",
88
88
  "@baishuyun/tailwind-config": "3.0.9"
89
89
  },
90
90
  "keywords": [],
@@ -19,6 +19,22 @@ const FolderIcon = () => (
19
19
  </svg>
20
20
  );
21
21
 
22
+ /** Close X icon (12x12) for chip dismiss in multiple mode */
23
+ const CloseIcon = () => (
24
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0">
25
+ <path d="M3 3L9 9M9 3L3 9" stroke="#999999" strokeWidth="1.2" strokeLinecap="round" />
26
+ </svg>
27
+ );
28
+
29
+ /** More dots icon (12x12) for overflow chip in multiple mode */
30
+ const MoreIcon = () => (
31
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0">
32
+ <circle cx="2" cy="6" r="1" fill="#999999" />
33
+ <circle cx="6" cy="6" r="1" fill="#999999" />
34
+ <circle cx="10" cy="6" r="1" fill="#999999" />
35
+ </svg>
36
+ );
37
+
22
38
  // ── Types ─────────────────────────────────────────────────────────
23
39
 
24
40
  export interface FormSelectItem {
@@ -31,21 +47,30 @@ export interface FormSelectItem {
31
47
  }
32
48
 
33
49
  export interface FormSelectChangeResult {
50
+ /** Set in single-select mode. */
34
51
  form?: FormSelectItem;
52
+ /** Set in multiple mode — the full selection after the change. */
53
+ forms?: FormSelectItem[];
35
54
  }
36
55
 
37
56
  export interface FormSelectProps {
38
57
  items: FormSelectItem[];
39
- value?: string;
40
- defaultValue?: string;
58
+ /** Enable multi-select mode. Default false (single-select). */
59
+ multiple?: boolean;
60
+ /** Controlled value — form id in single mode, form id array in multiple mode. */
61
+ value?: string | string[];
62
+ /** Uncontrolled default value — same shape as `value`. */
63
+ defaultValue?: string | string[];
41
64
  onChange?: (result: FormSelectChangeResult) => void;
42
65
  placeholder?: string;
43
66
  disabled?: boolean;
44
67
  className?: string;
45
- /** Allow selecting an empty value as the first option. */
68
+ /** Allow selecting an empty value as the first option. Only effective in single mode. */
46
69
  allowEmpty?: boolean;
47
70
  /** Label for the empty option. Default "无". */
48
71
  emptyLabel?: string;
72
+ /** Max visible chips in the trigger in multiple mode (default 4). */
73
+ maxVisibleChips?: number;
49
74
  }
50
75
 
51
76
  // ── Tree types ────────────────────────────────────────────────────
@@ -192,12 +217,12 @@ function FormEntryItem({
192
217
 
193
218
  function FormTreeItemNode({
194
219
  item,
195
- selectedFormId,
220
+ selectedIds,
196
221
  onSelect,
197
222
  }: {
198
223
  item: FormTreeItem;
199
- selectedFormId: string | undefined;
200
- onSelect: (result: FormSelectChangeResult) => void;
224
+ selectedIds: Set<string>;
225
+ onSelect: (item: FormSelectItem) => void;
201
226
  }) {
202
227
  const [expanded, setExpanded] = useState(true);
203
228
 
@@ -232,7 +257,7 @@ function FormTreeItemNode({
232
257
  <FormTreeItemNode
233
258
  key={child.id}
234
259
  item={child}
235
- selectedFormId={selectedFormId}
260
+ selectedIds={selectedIds}
236
261
  onSelect={onSelect}
237
262
  />
238
263
  ))}
@@ -244,10 +269,10 @@ function FormTreeItemNode({
244
269
  <FormEntryItem
245
270
  item={item}
246
271
  depth={item.depth}
247
- isSelected={selectedFormId === item.id}
272
+ isSelected={selectedIds.has(item.id)}
248
273
  onClick={() => {
249
274
  if (item.form) {
250
- onSelect({ form: item.form });
275
+ onSelect(item.form);
251
276
  }
252
277
  }}
253
278
  />
@@ -260,7 +285,7 @@ function FormPopover({
260
285
  searchQuery,
261
286
  onSearchChange,
262
287
  tree,
263
- selectedFormId,
288
+ selectedIds,
264
289
  onSelect,
265
290
  hasSearch,
266
291
  allowEmpty,
@@ -270,8 +295,8 @@ function FormPopover({
270
295
  searchQuery: string;
271
296
  onSearchChange: (v: string) => void;
272
297
  tree: FormTreeItem[];
273
- selectedFormId: string | undefined;
274
- onSelect: (result: FormSelectChangeResult) => void;
298
+ selectedIds: Set<string>;
299
+ onSelect: (item: FormSelectItem) => void;
275
300
  hasSearch: boolean;
276
301
  allowEmpty: boolean;
277
302
  emptyLabel: string;
@@ -301,7 +326,7 @@ function FormPopover({
301
326
  <div
302
327
  className={cn(
303
328
  'flex items-center h-[34px] px-[10px] cursor-pointer rounded-[4px] shrink-0',
304
- !selectedFormId ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
329
+ selectedIds.size === 0 ? 'bg-[#F0F6FF] text-[#0265FF]' : 'text-[#030303] hover:bg-[#EFF0F6]',
305
330
  transCls,
306
331
  )}
307
332
  onClick={onEmptySelect}
@@ -317,7 +342,7 @@ function FormPopover({
317
342
  <FormTreeItemNode
318
343
  key={node.id}
319
344
  item={node}
320
- selectedFormId={selectedFormId}
345
+ selectedIds={selectedIds}
321
346
  onSelect={onSelect}
322
347
  />
323
348
  ))
@@ -341,6 +366,7 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
341
366
  (
342
367
  {
343
368
  items,
369
+ multiple = false,
344
370
  value: controlledValue,
345
371
  defaultValue,
346
372
  onChange,
@@ -349,14 +375,15 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
349
375
  className,
350
376
  allowEmpty = false,
351
377
  emptyLabel = '无',
378
+ maxVisibleChips = 4,
352
379
  },
353
380
  ref,
354
381
  ) => {
355
382
  const [isOpen, setIsOpen] = useState(false);
356
383
  const [searchQuery, setSearchQuery] = useState('');
357
- const [internalValue, setInternalValue] = useState<string | undefined>(
358
- defaultValue,
359
- );
384
+ const [internalValue, setInternalValue] = useState<
385
+ string | string[] | undefined
386
+ >(defaultValue);
360
387
  const triggerRef = useRef<HTMLDivElement>(null);
361
388
  const popoverRef = useRef<HTMLDivElement>(null);
362
389
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
@@ -364,19 +391,41 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
364
391
  const { popoverStyle, updatePopoverPosition } =
365
392
  useSelectPopoverPosition(triggerRef);
366
393
 
367
- const selectedFormId = controlledValue ?? internalValue;
394
+ const resolvedValue = controlledValue ?? internalValue;
395
+
396
+ const selectedIds = useMemo(() => {
397
+ if (Array.isArray(resolvedValue)) {
398
+ return new Set(resolvedValue);
399
+ }
400
+ return new Set(resolvedValue ? [resolvedValue] : []);
401
+ }, [resolvedValue]);
402
+
403
+ const selectedFormId = Array.isArray(resolvedValue)
404
+ ? undefined
405
+ : resolvedValue;
368
406
 
369
407
  // Build the form tree
370
408
  const tree = useMemo(() => {
371
409
  return buildFormTree(items, searchQuery);
372
410
  }, [items, searchQuery]);
373
411
 
374
- // Find selected form
412
+ // Find selected form (single mode trigger display)
375
413
  const selectedForm = useMemo(() => {
376
414
  if (!selectedFormId) return null;
377
415
  return items.find((item) => item.id === selectedFormId && item.itemType !== 'group') ?? null;
378
416
  }, [items, selectedFormId]);
379
417
 
418
+ // Selected forms (multiple mode chips)
419
+ const selectedForms = useMemo(() => {
420
+ if (!multiple) return [];
421
+ return items.filter(
422
+ (item) => selectedIds.has(item.id) && item.itemType !== 'group',
423
+ );
424
+ }, [multiple, items, selectedIds]);
425
+
426
+ const visibleChips = selectedForms.slice(0, maxVisibleChips);
427
+ const overflowCount = selectedForms.length - visibleChips.length;
428
+
380
429
  const hasSearch = searchQuery.trim().length > 0;
381
430
 
382
431
  const setTriggerRef = useCallback((node: HTMLDivElement | null) => {
@@ -424,13 +473,41 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
424
473
  }
425
474
  }, [disabled, isOpen, handleClose, updatePopoverPosition]);
426
475
 
476
+ // Single mode: pick one and close. Multiple mode: toggle and stay open.
427
477
  const handleSelect = useCallback(
428
- (result: FormSelectChangeResult) => {
429
- setInternalValue(result.form?.id || '');
430
- onChange?.(result);
431
- handleClose();
478
+ (form: FormSelectItem) => {
479
+ if (!multiple) {
480
+ setInternalValue(form.id);
481
+ onChange?.({ form });
482
+ handleClose();
483
+ return;
484
+ }
485
+ const nextIds = selectedIds.has(form.id)
486
+ ? [...selectedIds].filter((id) => id !== form.id)
487
+ : [...selectedIds, form.id];
488
+ setInternalValue(nextIds);
489
+ onChange?.({
490
+ forms: items.filter(
491
+ (item) => nextIds.includes(item.id) && item.itemType !== 'group',
492
+ ),
493
+ });
494
+ },
495
+ [multiple, selectedIds, items, onChange, handleClose],
496
+ );
497
+
498
+ const handleRemoveChip = useCallback(
499
+ (formId: string, e: React.MouseEvent) => {
500
+ e.stopPropagation();
501
+ if (disabled) return;
502
+ const nextIds = [...selectedIds].filter((id) => id !== formId);
503
+ setInternalValue(nextIds);
504
+ onChange?.({
505
+ forms: items.filter(
506
+ (item) => nextIds.includes(item.id) && item.itemType !== 'group',
507
+ ),
508
+ });
432
509
  },
433
- [onChange, handleClose],
510
+ [disabled, selectedIds, items, onChange],
434
511
  );
435
512
 
436
513
  const handleEmptySelect = useCallback(() => {
@@ -534,6 +611,8 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
534
611
 
535
612
  // ── Render ────────────────────────────────────────────────────
536
613
 
614
+ const hasMultiSelection = multiple && selectedForms.length > 0;
615
+
537
616
  return (
538
617
  <div ref={ref} className={cn('relative inline-flex min-w-0 w-full', className)}>
539
618
  {/* Trigger */}
@@ -550,23 +629,54 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
550
629
  )}
551
630
  onClick={handleToggle}
552
631
  >
553
- <span className="flex items-center gap-[6px] min-w-0 flex-1">
554
- {selectedForm ? (
555
- <>
556
- <i
557
- className={getFormIconClass(selectedForm.icon ?? 0)}
558
- style={{ fontSize: 16, lineHeight: 1 }}
559
- />
560
- <span className="text-[14px] text-[#030303] leading-[normal] truncate">
561
- {selectedForm.name}
632
+ {hasMultiSelection ? (
633
+ <span className="flex items-center gap-[4px] min-w-0 flex-1 overflow-x-auto scrollbar-hide">
634
+ {visibleChips.map((form) => (
635
+ <span
636
+ key={form.id}
637
+ className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0 max-w-[160px]"
638
+ onClick={(e) => handleRemoveChip(form.id, e)}
639
+ >
640
+ <i
641
+ className={getFormIconClass(form.icon ?? 0)}
642
+ style={{ fontSize: 14, lineHeight: 1 }}
643
+ />
644
+ <span className="text-[14px] text-[#030303] truncate leading-[normal]">
645
+ {form.name}
646
+ </span>
647
+ <span className="cursor-pointer flex items-center shrink-0 hover:bg-[#EFF0F6]">
648
+ <CloseIcon />
649
+ </span>
562
650
  </span>
563
- </>
564
- ) : (
565
- <span className="text-[14px] text-[#999999] leading-[normal] truncate">
566
- {allowEmpty ? emptyLabel : placeholder}
567
- </span>
568
- )}
569
- </span>
651
+ ))}
652
+ {overflowCount > 0 && (
653
+ <span className="flex items-center gap-[4px] bg-[#EEEEEE] rounded-[2px] h-[26px] px-[6px] shrink-0">
654
+ <span className="text-[14px] text-[#030303] leading-[normal]">
655
+ +{overflowCount}
656
+ </span>
657
+ <MoreIcon />
658
+ </span>
659
+ )}
660
+ </span>
661
+ ) : (
662
+ <span className="flex items-center gap-[6px] min-w-0 flex-1">
663
+ {selectedForm ? (
664
+ <>
665
+ <i
666
+ className={getFormIconClass(selectedForm.icon ?? 0)}
667
+ style={{ fontSize: 16, lineHeight: 1 }}
668
+ />
669
+ <span className="text-[14px] text-[#030303] leading-[normal] truncate">
670
+ {selectedForm.name}
671
+ </span>
672
+ </>
673
+ ) : (
674
+ <span className="text-[14px] text-[#999999] leading-[normal] truncate">
675
+ {allowEmpty && !multiple ? emptyLabel : placeholder}
676
+ </span>
677
+ )}
678
+ </span>
679
+ )}
570
680
  <span
571
681
  className={cn(
572
682
  'shrink-0 flex items-center justify-center w-[16px] h-[16px] text-[#666666]',
@@ -586,10 +696,10 @@ export const FormSelect = forwardRef<HTMLDivElement, FormSelectProps>(
586
696
  searchQuery={searchQuery}
587
697
  onSearchChange={setSearchQuery}
588
698
  tree={tree}
589
- selectedFormId={selectedFormId}
699
+ selectedIds={selectedIds}
590
700
  onSelect={handleSelect}
591
701
  hasSearch={hasSearch}
592
- allowEmpty={allowEmpty}
702
+ allowEmpty={allowEmpty && !multiple}
593
703
  emptyLabel={emptyLabel}
594
704
  onEmptySelect={handleEmptySelect}
595
705
  />
@@ -352,7 +352,11 @@ export const ChevronIcon = ({ isOpen }: { isOpen?: boolean }) => (
352
352
  viewBox="0 0 16 16"
353
353
  fill="none"
354
354
  stroke="currentColor"
355
- className={cn(transCls, isOpen ? '' : 'rotate-180')}
355
+ // className={cn(transCls, isOpen ? '' : 'rotate-180')}
356
+ style={{
357
+ transform: isOpen ? undefined : 'rotate(180deg)',
358
+ transition: 'transform 0.2s ease-out',
359
+ }}
356
360
  >
357
361
  <path
358
362
  d="M3.28125 9.65625L7.58125 5.35625C7.63125 5.30625 7.69375 5.28125 7.75625 5.28125C7.81875 5.28125 7.88125 5.30625 7.93125 5.35625L12.2312 9.65625"
package/src/lib/utils.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { clsx, type ClassValue } from 'clsx';
2
2
  import { twMerge } from 'tailwind-merge';
3
- import { FieldType, IAddress, ILocation, IField } from '@baishuyun/types';
3
+ import { FieldType, IAddress, ILocation, IField, ILinkedData } from '@baishuyun/types';
4
4
 
5
5
  export function cn(...inputs: ClassValue[]) {
6
6
  return twMerge(clsx(inputs));
@@ -80,6 +80,10 @@ export const toHumanReadable = (value: unknown, type: FieldType): string | unkno
80
80
  return `经度: ${lnglatXY[0] || ''}, 纬度: ${lnglatXY[1] || ''}`;
81
81
  }
82
82
 
83
+ if (type === 'linkdata') {
84
+ return (value as ILinkedData).label;
85
+ }
86
+
83
87
  if (Array.isArray(value)) {
84
88
  return value.join(', ');
85
89
  }
@@ -140,3 +140,83 @@ export const Interactive: Story = {
140
140
  placeholder: '请选择表单',
141
141
  },
142
142
  };
143
+
144
+ export const Multiple: Story = {
145
+ args: {
146
+ items: mockFormItems,
147
+ multiple: true,
148
+ placeholder: '请选择表单(可多选)',
149
+ },
150
+ };
151
+
152
+ export const MultipleWithSelection: Story = {
153
+ args: {
154
+ items: mockFormItems,
155
+ multiple: true,
156
+ defaultValue: ['form3', 'form4', 'form7'],
157
+ placeholder: '请选择表单(可多选)',
158
+ },
159
+ };
160
+
161
+ export const MultipleWithOverflow: Story = {
162
+ args: {
163
+ items: manyFormItems,
164
+ multiple: true,
165
+ defaultValue: ['form_0', 'form_1', 'form_2', 'form_3', 'form_4', 'form_5'],
166
+ maxVisibleChips: 2,
167
+ placeholder: '请选择表单(可多选)',
168
+ },
169
+ };
170
+
171
+ export const MultipleDisabled: Story = {
172
+ args: {
173
+ items: mockFormItems,
174
+ multiple: true,
175
+ defaultValue: ['form1', 'form4'],
176
+ disabled: true,
177
+ placeholder: '请选择表单(可多选)',
178
+ },
179
+ };
180
+
181
+ export const MultipleInteractive: Story = {
182
+ render: (args) => {
183
+ const [value, setValue] = useState<string[]>(['form1']);
184
+ const [selected, setSelected] = useState<FormSelectChangeResult | null>(null);
185
+
186
+ return (
187
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
188
+ <FormSelect
189
+ {...args}
190
+ multiple
191
+ value={value}
192
+ onChange={(result) => {
193
+ setSelected(result);
194
+ setValue(result.forms?.map((f) => f.id) ?? []);
195
+ }}
196
+ />
197
+ <div
198
+ style={{
199
+ padding: 12,
200
+ background: '#F5F7FA',
201
+ borderRadius: 8,
202
+ fontSize: 14,
203
+ }}
204
+ >
205
+ <div>
206
+ <strong>已选数量:</strong> {selected?.forms?.length ?? 1}
207
+ </div>
208
+ <div>
209
+ <strong>已选表单:</strong>{' '}
210
+ {(selected?.forms ?? [])
211
+ .map((f) => `${f.name}(${f.id})`)
212
+ .join('、') || 'form1'}
213
+ </div>
214
+ </div>
215
+ </div>
216
+ );
217
+ },
218
+ args: {
219
+ items: mockFormItems,
220
+ placeholder: '请选择表单(可多选)',
221
+ },
222
+ };