diff --git a/packages/components/dialog/Dialog.tsx b/packages/components/dialog/Dialog.tsx index 1dbf51b8e6..d6969dc20f 100644 --- a/packages/components/dialog/Dialog.tsx +++ b/packages/components/dialog/Dialog.tsx @@ -1,9 +1,11 @@ -import React, { forwardRef, useEffect, useRef, useImperativeHandle, useState } from 'react'; +import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { CSSTransition } from 'react-transition-group'; import classNames from 'classnames'; import { isUndefined } from 'lodash-es'; + import log from '@tdesign/common-js/log/index'; import { pxCompat } from '@tdesign/common-js/utils/helper'; +import { canUseDocument } from '../_util/dom'; import Portal from '../common/Portal'; import useAttach from '../hooks/useAttach'; import useConfig from '../hooks/useConfig'; @@ -13,10 +15,11 @@ import useSetState from '../hooks/useSetState'; import { useLocaleReceiver } from '../locale/LocalReceiver'; import { dialogDefaultProps } from './defaultProps'; import DialogCard from './DialogCard'; +import useDialogDrag from './hooks/useDialogDrag'; import useDialogEsc from './hooks/useDialogEsc'; +import useDialogPosition from './hooks/useDialogPosition'; import useLockStyle from './hooks/useLockStyle'; -import useDialogDrag from './hooks/useDialogDrag'; -import { canUseDocument } from '../_util/dom'; + import type { StyledProps } from '../common'; import type { DialogInstance, TdDialogProps } from './type'; @@ -49,15 +52,17 @@ export interface DialogProps extends TdDialogProps, StyledProps { const Dialog = forwardRef((originalProps, ref) => { const props = useDefaultProps(originalProps, dialogDefaultProps); const { children, ...restProps } = props; - const { classPrefix } = useConfig(); + const { classPrefix } = useConfig(); const componentCls = `${classPrefix}-dialog`; + const wrapRef = useRef(null); const maskRef = useRef(null); const contentClickRef = useRef(false); const dialogCardRef = useRef(null); const dialogPosition = useRef(null); const portalRef = useRef(null); + const [state, setState] = useSetState({ isPlugin: false, ...restProps }); const [local] = useLocaleReceiver('dialog'); @@ -94,15 +99,19 @@ const Dialog = forwardRef((originalProps, ref) => { ...restState } = state; + const isModeless = mode === 'modeless'; + const isFullScreen = mode === 'full-screen'; + const dialogAttach = useAttach('dialog', attach); const [animationVisible, setAnimationVisible] = useState(visible); const [dialogAnimationVisible, setDialogAnimationVisible] = useState(false); useLockStyle({ preventScrollThrough, visible, mode, showInAttachedElement }); useDialogEsc(visible, wrapRef); - useDialogDrag({ + useDialogPosition(visible, dialogCardRef); + const { isInputInteracting } = useDialogDrag({ dialogCardRef, - canDraggable: draggable && mode === 'modeless', + canDraggable: !isFullScreen && draggable, }); useDeepEffect(() => { @@ -148,6 +157,8 @@ const Dialog = forwardRef((originalProps, ref) => { } const onMaskClick = (e: React.MouseEvent) => { + if (isModeless || isInputInteracting) return; + if (showOverlay && (closeOnOverlayClick ?? local.closeOnOverlayClick)) { // 判断点击事件初次点击是否为内容区域 if (contentClickRef.current) { @@ -249,6 +260,7 @@ const Dialog = forwardRef((originalProps, ref) => { className={classNames(className, `${componentCls}__ctx`, `${componentCls}__${mode}`, { [`${componentCls}__ctx--fixed`]: !showInAttachedElement, [`${componentCls}__ctx--absolute`]: showInAttachedElement, + [`${componentCls}__ctx--modeless`]: isModeless, })} style={{ zIndex, display: animationVisible ? undefined : 'none' }} onKeyDown={handleKeyDown} @@ -258,11 +270,14 @@ const Dialog = forwardRef((originalProps, ref) => {
((originalProps, ref) => { { children?: React.ReactNode; + mode?: TdDialogProps['mode']; } const renderDialogButton = (btn: DialogCardProps['cancelBtn'], defaultProps: ButtonProps) => { @@ -28,7 +31,7 @@ const renderDialogButton = (btn: DialogCardProps['cancelBtn'], defaultProps: But } else if (isValidElement(btn)) { result = btn; } else if (isObject(btn)) { - result = - - + + + + + + - { - console.log('on click close btn'); - }} - onOpened={() => { - console.log('dialog is open'); - }} - > + setModal(false)}>

This is a dialog

- { - console.log('dialog is open'); - }} - > - + + setDraggableModal(false)}> +

This is a dialog

+
+ + setModeless(false)}> +

This is a dialog

{ - console.log('dialog is open'); - }} + header="非模态对话框(可拖拽)" + draggable + visible={draggableModeless} + onClose={() => setDraggableModeless(false)} >

This is a dialog

- -

This is a dialog

-
); } diff --git a/packages/components/dialog/_usage/props.json b/packages/components/dialog/_usage/props.json index 0e663a1a46..1852cc13e7 100644 --- a/packages/components/dialog/_usage/props.json +++ b/packages/components/dialog/_usage/props.json @@ -55,8 +55,8 @@ "value": "modeless" }, { - "label": "normal", - "value": "normal" + "label": "full-screen", + "value": "full-screen" } ] }, @@ -119,11 +119,5 @@ "value": "success" } ] - }, - { - "name": "visible", - "type": "Boolean", - "defaultValue": false, - "options": [] } ] \ No newline at end of file diff --git a/packages/components/dialog/dialog.en-US.md b/packages/components/dialog/dialog.en-US.md index 8b25de31eb..8729f1e2e0 100644 --- a/packages/components/dialog/dialog.en-US.md +++ b/packages/components/dialog/dialog.en-US.md @@ -1,6 +1,7 @@ :: BASE_DOC :: ## API + ### DialogCard Props name | type | default | description | required @@ -28,9 +29,8 @@ confirmLoading | Boolean | undefined | confirm button loading status | N confirmOnEnter | Boolean | - | confirm on enter | N destroyOnClose | Boolean | false | \- | N dialogClassName | String | - | \- | N -draggable | Boolean | false | \- | N +draggable | Boolean | false | not effective in `full-screen` mode | N footer | TNode | true | Typescript: `boolean \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N -forceRender | Boolean | false | to force render Dialog, deprecated, please use `lazy` compatibility support | N header | TNode | true | Typescript: `string \| boolean \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N lazy | Boolean | true | Enable Dialog lazy loading, the contents of the dialog box are not rendered when enable | N mode | String | modal | options: modal/modeless/full-screen | N @@ -79,8 +79,6 @@ name | params | default | description -- | -- | -- | -- options | \- | - | Typescript: `DialogOptions` -插件返回值:`DialogInstance` - ### dialog.confirm 或 DialogPlugin.confirm name | params | default | description diff --git a/packages/components/dialog/dialog.md b/packages/components/dialog/dialog.md index bf479a31de..633d6b8a6d 100644 --- a/packages/components/dialog/dialog.md +++ b/packages/components/dialog/dialog.md @@ -22,6 +22,7 @@ {{ plugin }} ## API + ### DialogCard Props 名称 | 类型 | 默认值 | 描述 | 必传 @@ -49,9 +50,8 @@ confirmLoading | Boolean | undefined | 确认按钮加载状态 | N confirmOnEnter | Boolean | - | 是否在按下回车键时,触发确认事件 | N destroyOnClose | Boolean | false | 是否在关闭弹框的时候销毁子元素 | N dialogClassName | String | - | 弹框元素类名,示例:'t-class-dialog-first t-class-dialog-second' | N -draggable | Boolean | false | 对话框是否可以拖拽(仅在非模态对话框时有效) | N +draggable | Boolean | false | 是否可以拖拽(对全屏对话框无效) | N footer | TNode | true | 底部操作栏,默认会有“确认”和“取消”两个按钮。值为 true 显示默认操作按钮,值为 false 不显示任何内容,值类型为 Function 表示自定义底部内容。TS 类型:`boolean \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N -forceRender | Boolean | false | 是否强制渲染Dialog,已废弃,请更为使用 `lazy` 兼容支持 | N header | TNode | true | 头部内容。值为 true 显示空白头部,值为 false 不显示任何内容,值类型为 string 则直接显示值,值类型为 Function 表示自定义头部内容。TS 类型:`string \| boolean \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/common.ts) | N lazy | Boolean | true | 是否启用对话框懒加载,启用时对话框内的内容不渲染 | N mode | String | modal | 对话框类型,有 3 种:模态对话框、非模态对话框、全屏对话框。弹出「模态对话框」时,只能操作对话框里面的内容,不能操作其他内容。弹出「非模态对话框」时,则可以操作页面内所有内容。「普通对话框」是指没有脱离文档流的对话框,可以在这个基础上开发更多的插件。可选项:modal/modeless/full-screen | N @@ -100,8 +100,6 @@ update | `(props: DialogOptions)` | \- | 必需。更新弹框内容 -- | -- | -- | -- options | \- | - | TS 类型:`DialogOptions` -插件返回值:`DialogInstance` - ### dialog.confirm 或 DialogPlugin.confirm 参数名称 | 参数类型 | 参数默认值 | 参数描述 diff --git a/packages/components/dialog/hooks/useDialogDrag.ts b/packages/components/dialog/hooks/useDialogDrag.ts index decff73b17..e2ef370d74 100644 --- a/packages/components/dialog/hooks/useDialogDrag.ts +++ b/packages/components/dialog/hooks/useDialogDrag.ts @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import useMouseEvent from '../../hooks/useMouseEvent'; interface DialogDragProps { @@ -9,17 +9,60 @@ interface DialogDragProps { const useDialogDrag = (props: DialogDragProps) => { const { dialogCardRef, canDraggable } = props; - const validWindow = typeof window === 'object'; - const screenHeight = validWindow ? window.innerHeight || document.documentElement.clientHeight : undefined; - const screenWidth = validWindow ? window.innerWidth || document.documentElement.clientWidth : undefined; - + const isInputInteracting = useRef(false); const dragOffset = useRef({ x: 0, y: 0 }); + /** + * Ensure the dialog stays within viewport bounds when window is resized + */ + const clampPosition = () => { + if (!dialogCardRef.current) return; + const { offsetWidth, offsetHeight, style } = dialogCardRef.current; + + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + + let left = parseFloat(style.left || '0'); + let top = parseFloat(style.top || '0'); + + if (isNaN(left)) left = 0; + if (isNaN(top)) top = 0; + + let newLeft = left; + let newTop = top; + + if (newLeft < 0) newLeft = 0; + if (newTop < 0) newTop = 0; + + if (newLeft + offsetWidth > screenWidth) { + newLeft = screenWidth - offsetWidth; + } + + if (newTop + offsetHeight > screenHeight) { + newTop = screenHeight - offsetHeight; + } + + style.left = `${newLeft}px`; + style.top = `${newTop}px`; + }; + useMouseEvent(dialogCardRef, { enabled: canDraggable, onDown: (e) => { + const target = e.target as HTMLElement; + // 避免无法复制输入框内容 + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + isInputInteracting.current = true; + return; + } + isInputInteracting.current = false; + const { offsetLeft, offsetTop, offsetWidth, offsetHeight, style } = dialogCardRef.current; + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + if (offsetWidth > screenWidth || offsetHeight > screenHeight) return; + style.cursor = 'move'; dragOffset.current = { x: e.clientX - offsetLeft, @@ -27,14 +70,21 @@ const useDialogDrag = (props: DialogDragProps) => { }; }, onMove: (e) => { + if (isInputInteracting.current) return; + const { offsetWidth, offsetHeight, style } = dialogCardRef.current; + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + let diffX = e.clientX - dragOffset.current.x; let diffY = e.clientY - dragOffset.current.y; - // 拖拽上左边界限制 + if (diffX < 0) diffX = 0; if (diffY < 0) diffY = 0; + if (screenWidth - offsetWidth - diffX < 0) diffX = screenWidth - offsetWidth; if (screenHeight - offsetHeight - diffY < 0) diffY = screenHeight - offsetHeight; + style.position = 'absolute'; style.left = `${diffX}px`; style.top = `${diffY}px`; @@ -43,6 +93,17 @@ const useDialogDrag = (props: DialogDragProps) => { dialogCardRef.current.style.cursor = 'default'; }, }); + + useEffect(() => { + if (!canDraggable) return; + window.addEventListener('resize', clampPosition); + return () => window.removeEventListener('resize', clampPosition); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canDraggable]); + + return { + isInputInteracting, + }; }; export default useDialogDrag; diff --git a/packages/components/dialog/hooks/useDialogEsc.ts b/packages/components/dialog/hooks/useDialogEsc.ts index 4e01697318..394ddd480c 100644 --- a/packages/components/dialog/hooks/useDialogEsc.ts +++ b/packages/components/dialog/hooks/useDialogEsc.ts @@ -1,4 +1,4 @@ -import { useEffect, MutableRefObject } from 'react'; +import { MutableRefObject, useEffect } from 'react'; const dialogSet: Set> = new Set(); @@ -8,7 +8,10 @@ const useDialogEsc = (visible: boolean, dialog: MutableRefObject // 将 dialog 添加至 Set 对象 if (dialog?.current) { dialogSet.add(dialog); - dialog?.current?.focus(); + // 避免 CSSTransition 的动画效果,导致首次打开弹窗时 focus 失败 + setTimeout(() => { + dialog?.current?.focus(); + }, 0); } } else if (dialogSet.has(dialog)) { // 将 dialog 从 Set 对象删除 diff --git a/packages/components/dialog/type.ts b/packages/components/dialog/type.ts index 61e082508d..1030c7cd3f 100644 --- a/packages/components/dialog/type.ts +++ b/packages/components/dialog/type.ts @@ -61,7 +61,7 @@ export interface TdDialogProps { */ dialogClassName?: string; /** - * 对话框是否可以拖拽(仅在非模态对话框时有效) + * 是否可以拖拽(对全屏对话框无效) * @default false */ draggable?: boolean; diff --git a/packages/components/hooks/useDialogEsc.ts b/packages/components/hooks/useDialogEsc.ts deleted file mode 100644 index 4e01697318..0000000000 --- a/packages/components/hooks/useDialogEsc.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, MutableRefObject } from 'react'; - -const dialogSet: Set> = new Set(); - -const useDialogEsc = (visible: boolean, dialog: MutableRefObject) => { - useEffect(() => { - if (visible) { - // 将 dialog 添加至 Set 对象 - if (dialog?.current) { - dialogSet.add(dialog); - dialog?.current?.focus(); - } - } else if (dialogSet.has(dialog)) { - // 将 dialog 从 Set 对象删除 - dialogSet.delete(dialog); - const dialogList = [...dialogSet]; - // 将 Set 对象中最后一个 dialog 设置为 focus - dialogList[dialogList.length - 1]?.current?.focus(); - } - return () => { - // 从 Set 对象删除无效的 dialog - dialogSet.forEach((item) => { - if (item.current === null) { - dialogSet.delete(item); - } - }); - }; - }, [visible, dialog]); -}; - -export default useDialogEsc; diff --git a/packages/components/textarea/Textarea.tsx b/packages/components/textarea/Textarea.tsx index 9ac9a6f550..4043d61580 100644 --- a/packages/components/textarea/Textarea.tsx +++ b/packages/components/textarea/Textarea.tsx @@ -72,7 +72,7 @@ const Textarea = forwardRef((originalProps, const { classPrefix } = useConfig(); - const textareaPropsNames = Object.keys(otherProps).filter((key) => !/^on[A-Z]/.test(key)); + const textareaPropsNames = Object.keys(otherProps).filter((key) => !/^on[A-Z]/.test(key) && key !== 'defaultValue'); const textareaProps = textareaPropsNames.reduce( (textareaProps, key) => Object.assign(textareaProps, { [key]: props[key] }), {}, diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 6dcffd97f9..c385791ede 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -43688,104 +43688,63 @@ exports[`csr snapshot test > csr test packages/components/dialog/_example/icon.t exports[`csr snapshot test > csr test packages/components/dialog/_example/modal.tsx 1`] = `
- - -
-
- 普通对话框 -
- - - - - - - + 普通对话框(不可拖拽) + +
-

- This is a dialog -

+
+
@@ -150568,7 +150527,7 @@ exports[`ssr snapshot test > ssr test packages/components/dialog/_example/custom exports[`ssr snapshot test > ssr test packages/components/dialog/_example/icon.tsx 1`] = `""`; -exports[`ssr snapshot test > ssr test packages/components/dialog/_example/modal.tsx 1`] = `"
普通对话框

This is a dialog

"`; +exports[`ssr snapshot test > ssr test packages/components/dialog/_example/modal.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/dialog/_example/plugin.tsx 1`] = `"

函数调用方式一:DialogPlugin(options)

函数调用方式二:DialogPlugin.confirm(options)

函数调用方式三:DialogPlugin.alert(options)

"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index 417f0e3235..13dcf338a0 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -340,7 +340,7 @@ exports[`ssr snapshot test > ssr test packages/components/dialog/_example/custom exports[`ssr snapshot test > ssr test packages/components/dialog/_example/icon.tsx 1`] = `""`; -exports[`ssr snapshot test > ssr test packages/components/dialog/_example/modal.tsx 1`] = `"
普通对话框

This is a dialog

"`; +exports[`ssr snapshot test > ssr test packages/components/dialog/_example/modal.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test packages/components/dialog/_example/plugin.tsx 1`] = `"

函数调用方式一:DialogPlugin(options)

函数调用方式二:DialogPlugin.confirm(options)

函数调用方式三:DialogPlugin.alert(options)

"`;