@baishuyun/ai-design 2.1.0-alpha.4 → 2.1.3

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 (72) hide show
  1. package/.storybook/preview.ts +2 -0
  2. package/CHANGELOG.md +197 -28
  3. package/package.json +11 -11
  4. package/scripts/post-build.cjs +32 -0
  5. package/src/components/agent-drawer.tsx +119 -0
  6. package/src/components/agent-helper/agent-entry-card/index.tsx +3 -8
  7. package/src/components/agent-helper/agent-helper-card/index.tsx +3 -3
  8. package/src/components/agent-helper/app-select/index.tsx +119 -84
  9. package/src/components/agent-helper/condition-input/index.tsx +18 -21
  10. package/src/components/agent-helper/condition-item/index.tsx +1 -0
  11. package/src/components/agent-helper/field-edit-preference-select/index.tsx +31 -4
  12. package/src/components/agent-helper/field-value-input/dept-select.tsx +447 -0
  13. package/src/components/agent-helper/field-value-input/index.tsx +3 -2
  14. package/src/components/agent-helper/field-value-input/picker-inputs.tsx +20 -18
  15. package/src/components/agent-helper/form-select/index.tsx +12 -25
  16. package/src/components/agent-helper/relational-operator-select/index.tsx +37 -23
  17. package/src/components/agent-helper/table/index.tsx +6 -2
  18. package/src/components/agent-helper/team-wide-form-select/data-adapter.ts +109 -0
  19. package/src/components/agent-helper/team-wide-form-select/index.tsx +289 -0
  20. package/src/components/agent-helper/team-wide-form-select/tree-panel.tsx +180 -0
  21. package/src/components/agent-helper/team-wide-form-select/types.ts +36 -0
  22. package/src/components/agent-helper/team-wide-form-select/use-form-tree.ts +108 -0
  23. package/src/components/agent-helper/trigger-select/index.tsx +1 -1
  24. package/src/components/agent-select.tsx +115 -35
  25. package/src/components/bot-avatar-name.tsx +1 -1
  26. package/src/components/bs-entry-select/build-tree.ts +0 -6
  27. package/src/components/bs-entry-select/entry-item.tsx +7 -11
  28. package/src/components/bs-entry-select/other-app-section.tsx +2 -4
  29. package/src/components/bs-entry-select/scroll-slider.tsx +6 -11
  30. package/src/components/bs-icons.tsx +752 -206
  31. package/src/components/card.tsx +55 -10
  32. package/src/components/circle-checker.tsx +18 -3
  33. package/src/components/collapsible-txt-msg.tsx +18 -13
  34. package/src/components/fake-bot-msg.tsx +18 -7
  35. package/src/components/field-checker-list-msg.tsx +4 -3
  36. package/src/components/fields-design-info-table.tsx +9 -26
  37. package/src/components/fields-portal.tsx +2 -2
  38. package/src/components/fields-previewer.tsx +1 -0
  39. package/src/components/font-icon.tsx +20 -9
  40. package/src/components/form-info-editor.tsx +21 -6
  41. package/src/components/legacy-slot.tsx +11 -16
  42. package/src/components/memoized-markdown.tsx +32 -19
  43. package/src/components/message-content.tsx +7 -3
  44. package/src/components/output-config-editor.tsx +39 -25
  45. package/src/components/primary-confirm-btn.tsx +6 -1
  46. package/src/components/scroll-to-bottom-btn.tsx +3 -2
  47. package/src/components/shadow-content.tsx +19 -0
  48. package/src/components/shadow-portal.tsx +23 -0
  49. package/src/components/ui/dialog.tsx +4 -1
  50. package/src/components/ui/drawer.tsx +3 -1
  51. package/src/components/ui/enhanced-drawer.tsx +134 -0
  52. package/src/components/ui/popover.tsx +3 -2
  53. package/src/components/ui/select.tsx +3 -2
  54. package/src/components/ui/tooltip.tsx +3 -2
  55. package/src/components/user-txt-msg-bubble.tsx +41 -19
  56. package/src/components/warning-msg.tsx +29 -12
  57. package/src/const/shadow.ts +17 -0
  58. package/src/const/ui.ts +2 -2
  59. package/src/hooks/use-column-appear.ts +1 -1
  60. package/src/hooks/use-portal-container.ts +6 -3
  61. package/src/hooks/use-shadow-boundary.ts +67 -0
  62. package/src/hooks/use-shadow.ts +24 -7
  63. package/src/index.ts +21 -0
  64. package/src/stories/AppSelect.stories.tsx +44 -0
  65. package/src/stories/FieldValueInput.stories.tsx +6 -3
  66. package/src/stories/FormSelect.stories.tsx +40 -0
  67. package/src/stories/IconfontPicker.stories.tsx +106 -0
  68. package/src/stories/RelationalOperatorSelect.stories.tsx +27 -0
  69. package/src/stories/TeamWideFormSelect.stories.tsx +362 -0
  70. package/src/stories/UserTextMsgBubble.stories.ts +28 -0
  71. package/src/style.css +30 -1
  72. package/style.css +172 -130
@@ -1,32 +1,45 @@
1
- import { marked } from "marked";
2
- import { memo, useMemo } from "react";
3
- import ReactMarkdown from "react-markdown";
1
+ import { marked } from 'marked';
2
+ import { CSSProperties, memo, useMemo } from 'react';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import remarkBreaks from 'remark-breaks';
4
5
 
5
6
  function parseMarkdownIntoBlocks(markdown: string): string[] {
6
7
  const tokens = marked.lexer(markdown);
7
8
  return tokens.map((token) => token.raw);
8
9
  }
9
10
 
10
- const MemoizedMarkdownBlock = memo(
11
- ({ content }: { content: string }) => {
12
- return <ReactMarkdown>{content}</ReactMarkdown>;
13
- },
14
- (prevProps, nextProps) => {
15
- if (prevProps.content !== nextProps.content) return false;
16
- return true;
17
- },
18
- );
11
+ const MemoizedMarkdownBlock = memo(({ content, breaks }: { content: string; breaks?: boolean }) => {
12
+ return (
13
+ <ReactMarkdown remarkPlugins={breaks ? [remarkBreaks] : undefined}>{content}</ReactMarkdown>
14
+ );
15
+ });
16
+
17
+ MemoizedMarkdownBlock.displayName = 'MemoizedMarkdownBlock';
18
+
19
+ type MarkdownProps = { content: string; id: string; breaks?: boolean };
19
20
 
20
- MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
21
+ const Prose = memo(function Prose({
22
+ content,
23
+ id,
24
+ breaks,
25
+ style,
26
+ }: MarkdownProps & { style?: CSSProperties }) {
27
+ const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
21
28
 
22
- export const MemoizedMarkdown = memo(
23
- ({ content, id }: { content: string; id: string }) => {
29
+ return blocks.map((block, index) => (
30
+ <article className="prose" key={`${id}-block_${index}`} style={style}>
31
+ <MemoizedMarkdownBlock breaks={breaks} content={block} />
32
+ </article>
33
+ ));
34
+ });
35
+
36
+ export const MemoizedMarkdown = Object.assign(
37
+ memo(function MemoizedMarkdown({ content, id, breaks }: MarkdownProps) {
24
38
  const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);
25
39
 
26
40
  return blocks.map((block, index) => (
27
- <MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />
41
+ <MemoizedMarkdownBlock breaks={breaks} content={block} key={`${id}-block_${index}`} />
28
42
  ));
29
- },
43
+ }),
44
+ { Prose },
30
45
  );
31
-
32
- MemoizedMarkdown.displayName = "MemoizedMarkdown";
@@ -3,16 +3,20 @@ import { cn } from '../lib/utils';
3
3
 
4
4
  export type MessageContentProps = HTMLAttributes<HTMLDivElement> & {
5
5
  compact?: boolean;
6
+ /** 移动端消息内容文字 16px(PC 端 14px) */
7
+ isMobile?: boolean;
6
8
  };
7
9
 
8
- export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
10
+ export const MessageContent = ({ children, className, isMobile, ...props }: MessageContentProps) => (
9
11
  <div
10
12
  className={cn(
11
- 'flex flex-col gap-2.5 overflow-hidden p-0 text-foreground text-[14px]',
13
+ 'flex flex-col gap-2.5 overflow-hidden p-0 text-foreground',
12
14
  'group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground',
13
15
  'group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground',
14
16
  'is-user:dark overflow-visible',
15
- className
17
+ className,
18
+ // 字号放在最后,保证 MessageContent 自身字号权威,外部 className 传入的 text-[*] 无法覆盖
19
+ isMobile ? 'text-[16px]' : 'text-[14px]'
16
20
  )}
17
21
  {...props}
18
22
  >
@@ -1,10 +1,11 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useMemo } from 'react';
2
2
  import type { FieldType, IAgentHelperOutputConfig, OutputParam, IField, ISubformField } from '@baishuyun/types';
3
3
  import { cn } from '../lib/utils';
4
4
  import { transCls } from '../const/ui';
5
5
  import { TrashIcon, SearchIcon } from '../components/bs-icons';
6
6
  import { Popover, PopoverTrigger, PopoverContent } from './ui/popover';
7
7
  import { usePortalContainer } from '../hooks/use-portal-container';
8
+ import { BsTooltip } from './tooltip';
8
9
 
9
10
  const PlusIcon = () => (
10
11
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -110,17 +111,16 @@ const VALID_TYPE_MAP: Partial<Record<FieldType, Set<FieldType>>> = {
110
111
  'image': new Set(['signature', 'image', 'upload']),
111
112
  }
112
113
 
113
- const EMPTY_SET = new Set();
114
+ function isCompatibleType(sourceType?: FieldType | '', targetType?: FieldType | ''): boolean {
115
+ return !!sourceType && !!targetType && sourceType !== 'subform' && targetType !== 'subform' &&
116
+ (sourceType === targetType || !!VALID_TYPE_MAP[sourceType]?.has(targetType));
117
+ }
114
118
 
115
- function filterFieldsByType(fields: OutputFieldOption[], targetType: FieldType | ''): OutputFieldOption[] {
119
+ function filterFieldsByType(fields: OutputFieldOption[], sourceType?: FieldType | ''): OutputFieldOption[] {
116
120
  return fields.reduce<OutputFieldOption[]>((acc, f) => {
117
- const selfMatches = f.type === targetType;
118
- const validTypeSet = VALID_TYPE_MAP[targetType as FieldType] ?? EMPTY_SET;
119
- const generalMatches = validTypeSet.has(f.type);
120
-
121
- const filteredChildren = f.children ? filterFieldsByType(f.children, targetType) : [];
122
- if (selfMatches || generalMatches || filteredChildren.length > 0) {
123
- acc.push({ ...f, children: filteredChildren.length > 0 ? filteredChildren : f.children });
121
+ const filteredChildren = f.children ? filterFieldsByType(f.children, sourceType) : [];
122
+ if (filteredChildren.length > 0 || (!f.children?.length && isCompatibleType(sourceType, f.type))) {
123
+ acc.push({ ...f, children: filteredChildren.length > 0 ? filteredChildren : undefined });
124
124
  }
125
125
  return acc;
126
126
  }, []);
@@ -133,8 +133,8 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
133
133
 
134
134
  const { portalContainer, setTriggerRef } = usePortalContainer('data-bs-output-config-portal');
135
135
 
136
- const sourceOptions = sourceFields ? outputParamsToOptions(sourceFields) : [];
137
- const targetOptions = targetFields ? fieldsToOptions(targetFields) : [];
136
+ const sourceOptions = useMemo(() => outputParamsToOptions(sourceFields ?? []), [sourceFields]);
137
+ const targetOptions = useMemo(() => fieldsToOptions(targetFields ?? []), [targetFields]);
138
138
 
139
139
  const handleDropdownOpenChange = useCallback((index: number, field: 'source' | 'target', open: boolean) => {
140
140
  if (open) {
@@ -161,7 +161,7 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
161
161
  const newItem = { ...newValue[index], sourceField: { fieldName: field.fieldName, fieldDesc: field.fieldDesc } };
162
162
  if (newItem.targetField.fieldName) {
163
163
  const targetOption = findFieldByName(targetOptions, newItem.targetField.fieldName);
164
- if (targetOption && targetOption.type !== field.type) {
164
+ if (!isCompatibleType(field.type, targetOption?.type)) {
165
165
  newItem.targetField = { fieldName: '', fieldDesc: '' };
166
166
  }
167
167
  }
@@ -172,12 +172,21 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
172
172
  }, [value, onChange, targetOptions]);
173
173
 
174
174
  const handleSelectTargetField = useCallback((index: number, field: OutputFieldOption) => {
175
+ const sourceOption = flattenSourceFields(sourceOptions).find(
176
+ option => option.fieldName === value[index].sourceField.fieldName
177
+ );
178
+ if (!isCompatibleType(sourceOption?.type, field.type)) {
179
+ if (import.meta.env.DEV) {
180
+ console.warn('[OutputConfigEditor] 不兼容的输出映射', { sourceType: sourceOption?.type, targetType: field.type });
181
+ }
182
+ return;
183
+ }
175
184
  const newValue = [...value];
176
185
  newValue[index] = { ...newValue[index], targetField: { fieldName: field.fieldName, fieldDesc: field.fieldDesc } };
177
186
  onChange?.(newValue);
178
187
  setDropdownTarget(null);
179
188
  setSearchText('');
180
- }, [value, onChange]);
189
+ }, [value, onChange, sourceOptions]);
181
190
 
182
191
  const toggleParent = useCallback((fieldName: string) => {
183
192
  setCollapsedParents(prev => {
@@ -209,6 +218,10 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
209
218
  (f) => (matchSearch(f.fieldDesc) || matchSearch(f.fieldName)) && f.type !== 'subform'
210
219
  );
211
220
 
221
+ const usedSourceFieldNames = new Set(
222
+ value.map(item => item.sourceField.fieldName).filter(Boolean)
223
+ );
224
+
212
225
  const searchedTargetFields = filterFields(targetOptions);
213
226
 
214
227
  const fieldRowCls = cn(
@@ -253,9 +266,10 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
253
266
  const selectedSourceType = item.sourceField.fieldName
254
267
  ? flatSourceFields.find(f => f.fieldName === item.sourceField.fieldName)?.type
255
268
  : undefined;
256
- const rowTargetFields = selectedSourceType
257
- ? filterFieldsByType(searchedTargetFields, selectedSourceType)
258
- : searchedTargetFields;
269
+ const rowSourceFields = filteredSourceFields.filter(
270
+ f => !usedSourceFieldNames.has(f.fieldName) || f.fieldName === item.sourceField.fieldName
271
+ );
272
+ const rowTargetFields = filterFieldsByType(searchedTargetFields, selectedSourceType);
259
273
  return (
260
274
  <div key={index} className="flex gap-[10px] items-center">
261
275
  {/* Source field selector (JSON output field) */}
@@ -269,9 +283,9 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
269
283
  isDropdownOpen(index, 'source') ? 'border-[#0265ff]' : 'border-[#e0e0e0]',
270
284
  )}
271
285
  >
272
- <span className="text-[14px] text-[#030303] leading-normal truncate">
273
- {item.sourceField.fieldDesc || <span className="text-[#999]">请选择字段</span>}
274
- </span>
286
+ <span className="text-[14px] text-[#030303] leading-normal truncate" title={item.sourceField.fieldDesc ?? "" }>
287
+ {item.sourceField.fieldDesc || <span className="text-[#999]">请选择字段</span>}
288
+ </span>
275
289
  <CaretIcon className={cn(
276
290
  'shrink-0 text-[#666]',
277
291
  isDropdownOpen(index, 'source') && 'rotate-180',
@@ -303,7 +317,7 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
303
317
  />
304
318
  </div>
305
319
  <div className="max-h-[210px] overflow-auto">
306
- {filteredSourceFields.map((field) => (
320
+ {rowSourceFields.map((field) => (
307
321
  <button
308
322
  key={field.fieldName}
309
323
  type="button"
@@ -319,7 +333,7 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
319
333
  <span className="truncate">{field.fieldDesc}</span>
320
334
  </button>
321
335
  ))}
322
- {filteredSourceFields.length === 0 && (
336
+ {rowSourceFields.length === 0 && (
323
337
  <div className="px-[10px] py-[7px] text-[14px] text-[#999] leading-normal">
324
338
  无匹配结果
325
339
  </div>
@@ -344,9 +358,9 @@ export function OutputConfigEditor({ value, onChange, sourceFields, targetFields
344
358
  isDropdownOpen(index, 'target') ? 'border-[#0265ff]' : 'border-[#e0e0e0]',
345
359
  )}
346
360
  >
347
- <span className="text-[14px] text-[#030303] leading-normal truncate">
348
- {item.targetField.fieldDesc || <span className="text-[#999]">请选择表单字段</span>}
349
- </span>
361
+ <span className="text-[14px] text-[#030303] leading-normal truncate" title={item.targetField.fieldDesc ?? ""}>
362
+ {item.targetField.fieldDesc || <span className="text-[#999]">请选择表单字段</span>}
363
+ </span>
350
364
  <CaretIcon className={cn(
351
365
  'shrink-0 text-[#666]',
352
366
  isDropdownOpen(index, 'target') && 'rotate-180',
@@ -8,6 +8,8 @@ export interface PrimaryConfirmBtnProps extends ButtonProps {
8
8
  icon?: React.ReactNode;
9
9
  disabledWithInfo?: boolean;
10
10
  light?: boolean;
11
+ /** 移动端按钮高度 40px、文本 16px(PC 端 34px / 14px) */
12
+ isMobile?: boolean;
11
13
  }
12
14
 
13
15
  export const PrimaryConfirmBtn = ({
@@ -18,15 +20,18 @@ export const PrimaryConfirmBtn = ({
18
20
  light,
19
21
  icon,
20
22
  children,
23
+ isMobile,
21
24
  ...rest
22
25
  }: PrimaryConfirmBtnProps) => {
26
+ const sizeCls = isMobile ? 'h-[40px] text-[16px]' : 'h-[34px] text-[14px]';
23
27
  const btn = (
24
28
  <Button
25
29
  {...rest}
26
30
  variant="default"
27
31
  disabled={disabled}
28
32
  className={cn(
29
- 'px-[10px] py-0 rounded-[100px] text-[14px] leading-normal h-[34px] min-w-[100px] cursor-pointer w-full disabled:opacity-100 gap-[6px] shadow-none',
33
+ 'px-[10px] py-0 rounded-[100px] leading-normal min-w-[100px] cursor-pointer w-full disabled:opacity-100 gap-[6px] shadow-none',
34
+ sizeCls,
30
35
  disabled
31
36
  ? 'bg-[#f0f0f0] text-[#999] cursor-not-allowed shadow-none'
32
37
  : light
@@ -5,9 +5,10 @@ import { ScrollArrowDownIcon } from './bs-icons';
5
5
  export interface ScrollToBottomProps {
6
6
  onClick?: () => void;
7
7
  className?: string;
8
+ isMobile?: boolean;
8
9
  }
9
10
 
10
- export const ScrollToBottomBtn = ({ onClick, className }: ScrollToBottomProps) => {
11
+ export const ScrollToBottomBtn = ({ onClick, className, isMobile }: ScrollToBottomProps) => {
11
12
  return (
12
13
  <button
13
14
  type="button"
@@ -22,7 +23,7 @@ export const ScrollToBottomBtn = ({ onClick, className }: ScrollToBottomProps) =
22
23
  className
23
24
  )}
24
25
  >
25
- <ScrollArrowDownIcon />
26
+ <ScrollArrowDownIcon size={isMobile ? 20 : 24 } />
26
27
  </button>
27
28
  );
28
29
  };
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useShadowBoundary } from '../hooks/use-shadow-boundary';
4
+ import type { ShadowStyle, ShadowStyleMode } from '../hooks/use-shadow-boundary';
5
+
6
+ export interface ShadowContentProps {
7
+ children: ReactNode;
8
+ mode?: ShadowStyleMode;
9
+ styles?: ShadowStyle;
10
+ }
11
+
12
+ export const ShadowContent = ({ children, mode = 'sdk', styles }: ShadowContentProps) => {
13
+ const { setHostRef, container } = useShadowBoundary(mode, styles);
14
+ return (
15
+ <div ref={setHostRef} data-bs-style-mode={mode} style={{ display: 'contents' }}>
16
+ {container && createPortal(children, container)}
17
+ </div>
18
+ );
19
+ };
@@ -0,0 +1,23 @@
1
+ import { useLayoutEffect, type ElementType, type ReactNode } from 'react';
2
+ import { usePortalContainer } from '../hooks/use-portal-container';
3
+
4
+ export const ShadowPortal = ({ component: Portal, container, ...props }: {
5
+ component: ElementType;
6
+ container?: Element | DocumentFragment | null;
7
+ children?: ReactNode;
8
+ forceMount?: true;
9
+ 'data-slot'?: string;
10
+ }) => {
11
+ const { portalContainer, setTriggerRef, portalReady } = usePortalContainer('data-bs-portal-container');
12
+ useLayoutEffect(() => {
13
+ if (container instanceof Element && container.parentNode instanceof ShadowRoot) {
14
+ container.setAttribute('data-bs-portal-container', '');
15
+ }
16
+ }, [container]);
17
+ return (
18
+ <>
19
+ <span ref={setTriggerRef} style={{ display: 'none' }} aria-hidden />
20
+ {portalReady && <Portal {...props} container={container === undefined ? portalContainer ?? undefined : container} />}
21
+ </>
22
+ );
23
+ };
@@ -2,12 +2,15 @@ import * as React from 'react';
2
2
  import * as DialogPrimitive from '@radix-ui/react-dialog';
3
3
 
4
4
  import { cn } from '../../lib/utils';
5
+ import { ShadowPortal } from '../shadow-portal';
5
6
 
6
7
  const Dialog = DialogPrimitive.Root;
7
8
 
8
9
  const DialogTrigger = DialogPrimitive.Trigger;
9
10
 
10
- const DialogPortal = DialogPrimitive.Portal;
11
+ const DialogPortal = (props: React.ComponentProps<typeof DialogPrimitive.Portal>) => (
12
+ <ShadowPortal component={DialogPrimitive.Portal} {...props} />
13
+ );
11
14
 
12
15
  const DialogClose = DialogPrimitive.Close;
13
16
 
@@ -4,6 +4,7 @@ import { Drawer as DrawerPrimitive } from "vaul"
4
4
  import { cn } from "../../lib/utils"
5
5
  import { IconBtn } from "../icon-btn"
6
6
  import { CloseIcon } from "../bs-icons"
7
+ import { ShadowPortal } from '../shadow-portal';
7
8
 
8
9
  function Drawer({
9
10
  ...props
@@ -24,7 +25,8 @@ function DrawerPortal({
24
25
  container?: HTMLElement | null;
25
26
  }) {
26
27
  return (
27
- <DrawerPrimitive.Portal
28
+ <ShadowPortal
29
+ component={DrawerPrimitive.Portal}
28
30
  data-slot="drawer-portal"
29
31
  container={container ?? undefined}
30
32
  {...props}
@@ -0,0 +1,134 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { cn } from '../../lib/utils';
4
+ import { Drawer, DrawerContent, DrawerTitle } from './drawer';
5
+
6
+ export type EnhancedDrawerDirection = 'left' | 'right' | 'top' | 'bottom';
7
+
8
+ export interface EnhancedDrawerProps {
9
+ /** 受控开关 */
10
+ open?: boolean;
11
+ /** 非受控默认开关 */
12
+ defaultOpen?: boolean;
13
+ onOpenChange?: (open: boolean) => void;
14
+ /** 滑入方向,默认 right */
15
+ direction?: EnhancedDrawerDirection;
16
+ /** 侧边抽屉宽度(left/right 生效),默认 300 */
17
+ width?: number;
18
+ /** 无障碍标题(sr-only 展示) */
19
+ title?: string;
20
+ /** 是否显示遮罩,默认 true */
21
+ showOverlay?: boolean;
22
+ /** 点击遮罩是否关闭,默认 true */
23
+ overlayClosable?: boolean;
24
+ /** 遮罩点击回调(额外行为,如打点) */
25
+ onOverlayClick?: () => void;
26
+ /** 遮罩附加样式 */
27
+ overlayStyle?: React.CSSProperties;
28
+ /** 抽屉面板附加 className */
29
+ className?: string;
30
+ children?: React.ReactNode;
31
+ }
32
+
33
+ /**
34
+ * 增强版抽屉(基于 vaul,面向 shadow DOM / WebView 环境的封装)。
35
+ *
36
+ * 统一处理了以下兼容问题,业务组件无需再各自处理:
37
+ * 1. `modal={false}` —— 关闭 Radix Dialog 的 body 滚动锁(react-remove-scroll 的
38
+ * touchmove preventDefault 在 shadow DOM 里因事件目标 retarget 无法识别抽屉内
39
+ * 滚动容器,会锁死滚动);同时 vaul 的 Overlay 在 modal=false 时返回 null。
40
+ * 2. `dismissible={false}` —— 关闭 vaul 拖拽关闭,避免 onPress 每次 pointerdown 都
41
+ * setPointerCapture 禁掉原生触摸滚动。
42
+ * 3. 手动渲染遮罩(vaul 遮罩因 modal=false 消失),点遮罩关闭走受控路径
43
+ * (绕过被 dismissible 拦掉的 vaul 关闭路径)。
44
+ *
45
+ * 内容区若需滚动,业务组件在自身滚动容器上加 `data-vaul-no-drag`(保险,
46
+ * dismissible=false 已阻止拖拽,此标记可防未来语义变化)。
47
+ */
48
+ export const EnhancedDrawer = ({
49
+ open: controlledOpen,
50
+ defaultOpen,
51
+ onOpenChange,
52
+ direction = 'right',
53
+ width = 300,
54
+ title,
55
+ showOverlay = true,
56
+ overlayClosable = true,
57
+ onOverlayClick,
58
+ overlayStyle,
59
+ className,
60
+ children,
61
+ }: EnhancedDrawerProps) => {
62
+ const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
63
+ const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
64
+
65
+ const setOpen = useCallback(
66
+ (next: boolean) => {
67
+ if (controlledOpen === undefined) setInternalOpen(next);
68
+ onOpenChange?.(next);
69
+ },
70
+ [controlledOpen, onOpenChange]
71
+ );
72
+
73
+ // 遮罩 portal 容器:抽屉内容经 vaul portal 进所在 shadow root,遮罩需与之同层
74
+ // 才能正确叠在抽屉下方(z 更低)且覆盖全屏
75
+ const [maskContainer, setMaskContainer] = useState<Element | null>(null);
76
+ const maskHostRef = useCallback((node: HTMLSpanElement | null) => {
77
+ if (!node) {
78
+ setMaskContainer(null);
79
+ return;
80
+ }
81
+ const root = node.getRootNode();
82
+ setMaskContainer(
83
+ root instanceof ShadowRoot ? (root as unknown as Element) : document.body
84
+ );
85
+ }, []);
86
+
87
+ const handleOverlayClick = useCallback(() => {
88
+ onOverlayClick?.();
89
+ if (overlayClosable) setOpen(false);
90
+ }, [onOverlayClick, overlayClosable, setOpen]);
91
+
92
+ const isHorizontal = direction === 'left' || direction === 'right';
93
+
94
+ return (
95
+ <>
96
+ <span ref={maskHostRef} style={{ display: 'none' }} />
97
+
98
+ {showOverlay &&
99
+ open &&
100
+ maskContainer &&
101
+ createPortal(
102
+ <div
103
+ onClick={handleOverlayClick}
104
+ aria-hidden="true"
105
+ style={{
106
+ position: 'fixed',
107
+ inset: 0,
108
+ background: 'rgba(0,0,0,0.3)',
109
+ zIndex: 40,
110
+ ...overlayStyle,
111
+ }}
112
+ />,
113
+ maskContainer
114
+ )}
115
+
116
+ <Drawer
117
+ open={open}
118
+ onOpenChange={setOpen}
119
+ direction={direction}
120
+ modal={false}
121
+ dismissible={false}
122
+ >
123
+ <DrawerContent
124
+ className={cn(className)}
125
+ hideOverlay
126
+ style={isHorizontal ? { width: `${width}px`, maxWidth: `${width}px` } : undefined}
127
+ >
128
+ {title != null && <DrawerTitle className="sr-only">{title}</DrawerTitle>}
129
+ {children}
130
+ </DrawerContent>
131
+ </Drawer>
132
+ </>
133
+ );
134
+ };
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
  import * as PopoverPrimitive from '@radix-ui/react-popover';
3
3
 
4
4
  import { cn } from '../../lib/utils';
5
+ import { ShadowPortal } from '../shadow-portal';
5
6
 
6
7
  const Popover = PopoverPrimitive.Root;
7
8
 
@@ -52,7 +53,7 @@ const PopoverContent = React.forwardRef<
52
53
  });
53
54
 
54
55
  return (
55
- <PopoverPrimitive.Portal container={portalContainer ?? undefined}>
56
+ <ShadowPortal component={PopoverPrimitive.Portal} container={portalContainer ?? undefined}>
56
57
  <PopoverPrimitive.Content
57
58
  ref={ref}
58
59
  align={align}
@@ -76,7 +77,7 @@ const PopoverContent = React.forwardRef<
76
77
  }}
77
78
  {...props}
78
79
  />
79
- </PopoverPrimitive.Portal>
80
+ </ShadowPortal>
80
81
  );
81
82
  }
82
83
  );
@@ -3,6 +3,7 @@ import * as SelectPrimitive from '@radix-ui/react-select';
3
3
  import { ChevronDown, ChevronUp } from 'lucide-react';
4
4
 
5
5
  import { cn } from '../../lib/utils';
6
+ import { ShadowPortal } from '../shadow-portal';
6
7
 
7
8
  const Select = SelectPrimitive.Root;
8
9
 
@@ -112,7 +113,7 @@ const SelectContent = React.forwardRef<
112
113
  {containerProp === undefined && (
113
114
  <span ref={setDetectorRef} style={{ display: 'none' }} aria-hidden />
114
115
  )}
115
- <SelectPrimitive.Portal container={effectiveContainer ?? undefined}>
116
+ <ShadowPortal component={SelectPrimitive.Portal} container={effectiveContainer ?? undefined}>
116
117
  <SelectPrimitive.Content
117
118
  ref={ref}
118
119
  className={cn(
@@ -161,7 +162,7 @@ const SelectContent = React.forwardRef<
161
162
  </SelectPrimitive.Viewport>
162
163
  <SelectScrollDownButton />
163
164
  </SelectPrimitive.Content>
164
- </SelectPrimitive.Portal>
165
+ </ShadowPortal>
165
166
  </>
166
167
  );
167
168
  }
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
  import * as TooltipPrimitive from '@radix-ui/react-tooltip';
3
3
 
4
4
  import { cn } from '../../lib/utils';
5
+ import { ShadowPortal } from '../shadow-portal';
5
6
 
6
7
  const TooltipZIndexContext = React.createContext<number | undefined>(undefined);
7
8
 
@@ -30,7 +31,7 @@ const TooltipContent = React.forwardRef<
30
31
  const zIndexFromProvider = React.useContext(TooltipZIndexContext);
31
32
 
32
33
  return (
33
- <TooltipPrimitive.Portal container={portalContainer ?? undefined}>
34
+ <ShadowPortal component={TooltipPrimitive.Portal} container={portalContainer ?? undefined}>
34
35
  <TooltipPrimitive.Content
35
36
  ref={ref}
36
37
  sideOffset={sideOffset}
@@ -44,7 +45,7 @@ const TooltipContent = React.forwardRef<
44
45
  {props.children}
45
46
  <TooltipPrimitive.Arrow className="fill-[#525867] stroke-[#525867] stroke-[2px] stroke-linejoin-round [paint-order:stroke_fill]" />
46
47
  </TooltipPrimitive.Content>
47
- </TooltipPrimitive.Portal>
48
+ </ShadowPortal>
48
49
  );
49
50
  });
50
51