比ant更豐富Modal組件功能實現(xiàn)示例詳解
有哪些比ant更豐富的功能
普通的modal組件如下:

我們寫的modal額外支持,后面沒有蒙版,并且Modal框能夠拖拽


還支持渲染在文檔流里,上面的都是fixed布局,我們這個正常渲染到文檔下面:

render部分
<RenderDialog
{...restState}
visible={visible}
prefixCls={prefixCls}
header={renderHeader}
attach={attach}
closeBtn={renderCloseIcon()}
classPrefix={classPrefix}
onClose={onClose}
onConfirm={onConfirm}
footer={footer === true ? defaultFooter() : footer}
ref={dialogDom}
/>
大家記住這個RenderDialog,接下來都是上面?zhèn)鲄⒌慕忉專?/p>
resetState: 是對象,一堆屬性的集合,哪些屬性呢,我們往下看
// 其實默認(rèn)參數(shù)寫到這里并不科學(xué),因為react有個靜態(tài)屬性defaultProps屬性支持合并props
const [state, setState] = useSetState<DialogProps>({
width: 520, // 默認(rèn)寬度是520
visible: false, // 默認(rèn)visible是false
zIndex: 2500, // 默認(rèn)zIndex 2500
placement: 'center', // 默認(rèn)渲染到屏幕中間
mode: 'modal', // 默認(rèn)的模式是modal是ant那種渲染結(jié)果,其他模式我們下面談
showOverlay: true, // 是否展示透明黑色蒙版
destroyOnClose: false, // 關(guān)閉彈窗的時候是否銷毀里面的內(nèi)容
draggable: false, // 是否能拖拽modal
preventScrollThrough: true, // 防止?jié)L動穿透
...props,
});
restState在下面,除了state上某些屬性。
const {
visible, // 控制對話框是否顯示
attach, // 對話框掛載的節(jié)點(diǎn),默認(rèn)掛在組件本身的位置。數(shù)據(jù)類型為 String 時,會被當(dāng)作選擇器處理,進(jìn)行節(jié)點(diǎn)查詢。示例:'body' 或 () => document.body
closeBtn, // 關(guān)閉按鈕,可以自定義。值為 true 顯示默認(rèn)關(guān)閉按鈕,值為 false 不顯示關(guān)閉按鈕。值類型為 string 則直接顯示值,// 底部操作欄,默認(rèn)會有“確認(rèn)”和“取消”兩個按鈕。值為 true 顯示默認(rèn)操作按鈕,值為 false 不顯示任何內(nèi)容,值類型為 Function 表示自定義底部內(nèi)容
footer = true, // 如果“取消”按鈕存在,則點(diǎn)擊“取消”按鈕時觸發(fā),同時觸發(fā)關(guān)閉事件
onCancel = noop, // 如果“確認(rèn)”按鈕存在,則點(diǎn)擊“確認(rèn)”按鈕時觸發(fā),或者鍵盤按下回車鍵時觸發(fā)
onConfirm = noop, // 如果“確認(rèn)”按鈕存在,則點(diǎn)擊“確認(rèn)”按鈕時觸發(fā),或者鍵盤按下回車鍵時觸發(fā)
cancelBtn = cancelText, // 取消按鈕,可自定義。值為 null 則不顯示取消按鈕。值類型為字符串,則表示自定義按鈕文本,值類型為 Object 則表示透傳 Button 組件屬性。
confirmBtn = confirmText, // 確認(rèn)按鈕。值為 null 則不顯示確認(rèn)按鈕。值類型為字符串,則表示自定義按鈕文本,值類型為 Object 則表示透傳 Button 組件屬性。
onClose = noop, // 關(guān)閉事件,點(diǎn)擊取消按鈕、點(diǎn)擊關(guān)閉按鈕、點(diǎn)擊蒙層、按下 ESC 等場景下觸發(fā)
...restState
} = state;
說了這么多,我們接著看RenderDialog組件上傳入的屬性。
prefixCls不講了,是css屬性前綴,一個字符串,接著看header屬性被包裝為renderHeader
const renderHeader = useMemo(() => {
if (!state.header) return null;
const iconMap = {
info: <InfoCircleFilledIcon className={`${classPrefix}-is-info`} />,
warning: <InfoCircleFilledIcon className={`${classPrefix}-is-warning`} />,
error: <InfoCircleFilledIcon className={`${classPrefix}-is-error`} />,
success: <CheckCircleFilledIcon className={`${classPrefix}-is-success`} />,
};
return (
<div className={`${prefixCls}__header-content`}>
{iconMap[state.theme]}
{state.header}
</div>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.header, state.theme, prefixCls, classPrefix]);
其實就是在header的文字前面多了一個icon,比如成功的彈窗如下:

接著看closeBtn屬性
const renderCloseIcon = () => {
if (closeBtn === false) return null;
if (closeBtn === true) return <CloseIcon style={{ verticalAlign: 'unset' }} />;
return closeBtn || <CloseIcon style={{ verticalAlign: 'unset' }} />;
};
這個是右上角關(guān)閉按鈕的Icon,很簡單,如果是false,什么都不許安然,如果是undefined或者true渲染這個icon。
好了,我們把整個代碼放到下面,有代碼注釋,沒寫注釋的是上面咋們已經(jīng)講過的內(nèi)容,接著就要進(jìn)入RenderDialog這個組件內(nèi)部了。
import 的部分省略了
// 渲染 footer的button方法
const renderDialogButton = (btn: TdDialogProps['cancelBtn'], defaultProps: ButtonProps) => {
let result = null;
if (isString(btn)) {
result = <Button {...defaultProps}>{btn}</Button>;
} else if (isFunction(btn)) {
result = btn();
}
return result;
};
const Dialog = forwardRef((props: DialogProps, ref: React.Ref<DialogInstance>) => {
// 這部分忽略就好,用來獲取全局配置的css前綴字符串
const { classPrefix } = useConfig();
// 這個也忽略,獲取icon組件的
const { CloseIcon, InfoCircleFilledIcon, CheckCircleFilledIcon } = useGlobalIcon({
CloseIcon: TdCloseIcon,
InfoCircleFilledIcon: TdInfoCircleFilledIcon,
CheckCircleFilledIcon: TdCheckCircleFilledIcon,
});
// 用來引用dialog彈框的dom
const dialogDom = useRef<HTMLDivElement>();
const [state, setState] = useSetState<DialogProps>({
width: 520,
visible: false,
zIndex: 2500,
placement: 'center',
mode: 'modal',
showOverlay: true,
destroyOnClose: false,
draggable: false,
preventScrollThrough: true,
...props,
});
// 國際化有關(guān)的
const [local, t] = useLocaleReceiver('dialog');
const confirmText = t(local.confirm);
const cancelText = t(local.cancel);
const {
visible,
attach,
closeBtn,
footer = true,
onCancel = noop,
onConfirm = noop,
cancelBtn = cancelText,
confirmBtn = confirmText,
onClose = noop,
...restState
} = state;
useEffect(() => {
setState((prevState) => ({
...prevState,
...props,
}));
}, [props, setState, isPlugin]);
const prefixCls = `${classPrefix}-dialog`;
const renderCloseIcon = () => {
if (closeBtn === false) return null;
if (closeBtn === true) return <CloseIcon style={{ verticalAlign: 'unset' }} />;
return closeBtn || <CloseIcon style={{ verticalAlign: 'unset' }} />;
};
// 這里把一些外部方法暴露給調(diào)用者,只需要傳入ref就可以獲取
React.useImperativeHandle(ref, () => ({
show() {
setState({ visible: true });
},
hide() {
setState({ visible: false });
},
destroy() {
setState({ visible: false, destroyOnClose: true });
},
update(newOptions) {
setState((prevState) => ({
...prevState,
...(newOptions as DialogProps),
}));
},
}));
const renderHeader = useMemo(() => {
if (!state.header) return null;
const iconMap = {
info: <InfoCircleFilledIcon className={`${classPrefix}-is-info`} />,
warning: <InfoCircleFilledIcon className={`${classPrefix}-is-warning`} />,
error: <InfoCircleFilledIcon className={`${classPrefix}-is-error`} />,
success: <CheckCircleFilledIcon className={`${classPrefix}-is-success`} />,
};
return (
<div className={`${prefixCls}__header-content`}>
{iconMap[state.theme]}
{state.header}
</div>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.header, state.theme, prefixCls, classPrefix]);
// 渲染footer的時候,點(diǎn)擊取消按鈕會用到
const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
onCancel({ e });
onClose({ e, trigger: 'cancel' });
};
// 渲染footer的時候,點(diǎn)擊確認(rèn)按鈕會用到
const handleConfirm = (e: React.MouseEvent<HTMLButtonElement>) => {
onConfirm({ e });
};
const defaultFooter = () => {
const renderCancelBtn = renderDialogButton(cancelBtn, { variant: 'outline' });
const renderConfirmBtn = renderDialogButton(confirmBtn, { theme: 'primary' });
return (
<>
{renderCancelBtn &&
React.cloneElement(renderCancelBtn, {
onClick: handleCancel,
...renderCancelBtn.props,
})}
{renderConfirmBtn &&
React.cloneElement(renderConfirmBtn, {
onClick: handleConfirm,
...renderConfirmBtn.props,
})}
</>
);
};
return (
<RenderDialog
{...restState}
visible={visible}
prefixCls={prefixCls}
header={renderHeader}
attach={attach}
closeBtn={renderCloseIcon()}
classPrefix={classPrefix}
onClose={onClose}
onConfirm={onConfirm}
footer={footer === true ? defaultFooter() : footer}
ref={dialogDom}
/>
);
});
Dialog.displayName = 'Dialog';
Dialog.defaultProps = dialogDefaultProps;
export default Dialog;
接著,我們要渲染的部分其實很簡單,包括
- 背后的黑色蒙層
- 彈框
- 彈框的標(biāo)題
- 彈框的內(nèi)容區(qū)域
- 彈框的footer
- 還需要彈框動畫,比如zoom或者fade
渲染黑色蒙層
代碼如下,很簡單
const renderMask = () => {
let maskElement;
if (showOverlay) {
maskElement = (
<CSSTransition
in={visible}
appear
timeout={transitionTime}
classNames={`${prefixCls}-fade`}
mountOnEnter
unmountOnExit
nodeRef={maskRef}
>
<div ref={maskRef} className={`${prefixCls}__mask`} />
</CSSTransition>
);
}
return maskElement;
};
首先介紹一下CSSTransition,這是react-transition-group動畫庫的一個組件,用來幫助我們實現(xiàn)css動畫的。 其中一些屬性說明如下:
- in: ture就是開始動畫,false就是停止動畫
- appear:boolean,為
false時當(dāng)CSSTransition控件加載完畢后不執(zhí)行動畫,為true時控件加載完畢則立即執(zhí)行動畫。如果要組件初次渲染就有動畫,則需要設(shè)成true。 - timeout 動畫時間
- classNames:動畫的類名,比如classNames:'demo',會自動在進(jìn)入動畫的時候幫你把類名改為 demo-enter-active, demo-enter-done, 在退出動畫同樣會有類名的改變。
- mountOnEnter:一進(jìn)來的時候不顯示dom元素
- unmountOnExit:boolean,為
true時組件將移除處于隱藏狀態(tài)的元素,為false時組件保持動畫結(jié)束時的狀態(tài)而不移除元素。一般要設(shè)成true。 - nodeRef,獲取蒙層的ref
蒙層主要靠css實現(xiàn),我們看下css
position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; background: var(--td-mask-active); pointer-events: auto;
渲染彈框主體
也非常簡單啊,我們把注釋寫在下面的代碼里了,其中有一個需要小小注意的功能就是拖拽功能
// 渲染Dialog主體
const renderDialog = () => {
const dest: any = {};
// 把width變?yōu)橛衟x結(jié)尾的字符串
if (props.width !== undefined) {
dest.width = GetCSSValue(props.width);
}
// normal 場景下,需要設(shè)置 zindex 為auto 避免出現(xiàn)多個 dialog,normal 出現(xiàn)在最上層
if (props.mode === 'normal') {
dest.zIndex = 'auto';
}
// 獲取footer
const footer = props.footer ? <div className={`${prefixCls}__footer`}>{props.footer}</div> : null;
// 獲取header
const { header } = props;
// 獲取Dialog body
const body = <div className={`${prefixCls}__body`}>{props.body || props.children}</div>;
// 關(guān)閉按鈕,可以自定義。值為 true 顯示默認(rèn)關(guān)閉按鈕,值為 false 不顯示關(guān)閉按鈕。值類型為 string 則直接顯示值,如:“關(guān)閉”。
const closer = closeBtn && (
<span onClick={handleCloseBtnClick} className={`${prefixCls}__close`}>
{closeBtn}
</span>
);
const validWindow = typeof window === 'object';
// 獲取屏幕高度
const screenHeight = validWindow ? window.innerHeight || document.documentElement.clientHeight : undefined;
// 獲取屏幕寬度
const screenWidth = validWindow ? window.innerWidth || document.documentElement.clientWidth : undefined;
// 設(shè)置style
const style = { ...dest, ...props.style };
let dialogOffset = { x: 0, y: 0 };
// 拖拽代碼實現(xiàn)部分
const onDialogMove = (e: MouseEvent) => {
// offsetWidth是指元素的寬 + padding + border的總和
const { style, offsetWidth, offsetHeight } = dialog.current;
// diffX是指彈框部分距離body左邊部分
let diffX = e.clientX - dialogOffset.x;
let diffY = e.clientY - dialogOffset.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`;
};
const onDialogMoveEnd = () => {
// 恢復(fù)指針樣式為默認(rèn),并且注銷mousemove, mouseup事件
dialog.current.style.cursor = 'default';
document.removeEventListener('mousemove', onDialogMove);
document.removeEventListener('mouseup', onDialogMoveEnd);
};
// 拖拽開始,對應(yīng)mouseDown事件
const onDialogMoveStart = (e: React.MouseEvent<HTMLDivElement>) => {
contentClickRef.current = true;
// 阻止事件冒泡, mode === 'modeless才能拖拽
if (canDraggable && e.currentTarget === e.target) {
const { offsetLeft, offsetTop, offsetHeight, offsetWidth } = dialog.current;
// 如果彈出框超出屏幕范圍 不能進(jìn)行拖拽
if (offsetWidth > screenWidth || offsetHeight > screenHeight) return;
// 拖拽樣式設(shè)置為move
dialog.current.style.cursor = 'move';
// 計算鼠標(biāo) e.clientX是鼠標(biāo)在屏幕的坐標(biāo),offsetLeft是Dialog主體跟body的距離
// 所以e.clientX - offsetLeft就是鼠標(biāo)在是Dialog主體上的橫坐標(biāo)
const diffX = e.clientX - offsetLeft;
const diffY = e.clientY - offsetTop;
dialogOffset = {
x: diffX,
y: diffY,
};
// 此時把mousemove和mouseup事件也綁定一下,其實不建議綁定在這里直接操作dom
document.addEventListener('mousemove', onDialogMove);
document.addEventListener('mouseup', onDialogMoveEnd);
}
};
// 頂部定位實現(xiàn)
const positionStyle: any = {};
if (props.top) {
const topValue = GetCSSValue(props.top);
positionStyle.paddingTop = topValue;
}
// 此處獲取定位方式 top 優(yōu)先級較高 存在時 默認(rèn)使用 top 定位
const positionClass = classnames(
`${prefixCls}__position`,
{ [`${prefixCls}--top`]: !!props.top },
`${props.placement && !props.top ? `${prefixCls}--${props.placement}` : ''}`,
);
// 然后就是用css去渲染header body和footer
const dialogElement = (
<div className={isNormal ? '' : `${prefixCls}__wrap`}>
<div className={isNormal ? '' : positionClass} style={positionStyle} onClick={onMaskClick} ref={dialogPosition}>
<div
ref={dialog}
style={style}
className={classnames(`${prefixCls}`, `${prefixCls}--default`)}
onMouseDown={onDialogMoveStart}
>
<div className={classnames(`${prefixCls}__header`)}>
{header}
{closer}
</div>
{body}
{footer}
</div>
</div>
</div>
);
return (
<CSSTransition
in={props.visible}
appear
mountOnEnter
unmountOnExit={destroyOnClose}
timeout={transitionTime}
classNames={`${prefixCls}-zoom`}
onEntered={props.onOpened}
onExited={onAnimateLeave}
nodeRef={dialog}
>
{dialogElement}
</CSSTransition>
);
};
我們這里貼一下css部分:
header:
.t-dialog__header {
color: var(--td-text-color-primary);
font: var(--td-font-title-medium);
font-weight: 600;
display: flex;
align-items: flex-start;
word-break: break-word;
}
這里注意下:word-wrap:break-word
它會把整個單詞看成一個整體,如果該行末端寬度不夠顯示整個單詞,它會自動把整個單詞放到下一行,而不會把單詞截斷掉的。
body
.t-dialog__body {
padding: 16px 0;
color: var(--td-text-color-secondary);
font: var(--td-font-body-medium);
overflow: auto;
word-break: break-word;
}
footer
width: 100%; text-align: right; padding: 16px 0 0 0;
好了,我們結(jié)合一下彈框和蒙層,看下render函數(shù)
const render = () => {
// 。。。省略css部分
// 如果不是 modal 模式 默認(rèn)沒有 mask 也就沒有相關(guān)點(diǎn)擊 mask 事件
const dialog = (
<div ref={wrap} className={wrapClass} style={wrapStyle} onKeyDown={handleKeyDown} tabIndex={0}>
{mode === 'modal' && renderMask()}
{dialogBody} // 這里就是我們上面講的renderDialog
</div>
);
return dialog;
};
設(shè)置body overflow:hiiden
為啥要設(shè)置body overflow:hiiden這個屬性呢,你打開modal彈窗的時候,如果此時body還有滾動條,那么你滾動鼠標(biāo)滾輪還可以向下滑動,但是一般情況下,我們打開彈框,是希望用戶目標(biāo)鎖定在當(dāng)前交互,此時最好不要允許用戶滾動界面。
當(dāng)然你也可以允許用戶滾動,我們用一個preventScrollThrough參數(shù)控制。
先記住當(dāng)前body的css樣式,以及body的overflow的值,代碼如下
useLayoutEffect(() => {
bodyOverflow.current = document.body.style.overflow;
bodyCssTextRef.current = document.body.style.cssText;
}, []);
const isModal = mode === 'modal';
useLayoutEffect(() => {
// 只有modal數(shù)量小于1的時候才重置樣式,因為可能出現(xiàn)多個彈框,那么關(guān)閉一個彈框就出現(xiàn)滾動條明顯不對
if (isModal) {
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (openDialogDom.length < 1) {
document.body.style.cssText = bodyCssTextRef.current;
}
// 組件銷毀后重置 body 樣式
return () => {
if (isModal) {
// 此處只能查詢 mode 模式的 dialog 個數(shù) 因為 modeless 會點(diǎn)擊透傳 normal 是正常文檔流
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (openDialogDom.length < 1) {
document.body.style.cssText = bodyCssTextRef.current;
document.body.style.overflow = bodyOverflow.current;
}
}
};
}, [preventScrollThrough, attach, visible, mode, isModal, showInAttachedElement, prefixCls]);
上面的代碼還有一個問題,就是我們需要preventScrollThrough這個參數(shù)去控制是否可以body滾動頁面,這個也是算比ant更豐富的功能。
const isModal = mode === 'modal';
useLayoutEffect(() => {
// 處于顯示態(tài)
if (visible) {
// isModal表示是否是普通彈框,就是帶黑色蒙層的
// bodyOverflow.current 引用的是body的overflow屬性
// preventScrollThrough是代表是否可以滾動body
// !showInAttachedElement表示不掛載到其他dom上
if (isModal && bodyOverflow.current !== 'hidden' && preventScrollThrough && !showInAttachedElement) {
// 求出滾動條的寬度
const scrollWidth = window.innerWidth - document.body.offsetWidth;
// 減少回流
if (bodyCssTextRef.current === '') {
let bodyCssText = 'overflow: hidden;';
if (scrollWidth > 0) {
bodyCssText += `position: relative;width: calc(100% - ${scrollWidth}px);`;
}
document.body.style.cssText = bodyCssText;
} else {
if (scrollWidth > 0) {
document.body.style.width = `calc(100% - ${scrollWidth}px)`;
document.body.style.position = 'relative';
}
document.body.style.overflow = 'hidden';
}
}
// 剛進(jìn)頁面就focus到彈框組件上
if (wrap.current) {
wrap.current.focus();
}
} else if (isModal) {
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (openDialogDom.length < 1) {
document.body.style.cssText = bodyCssTextRef.current;
}
}
// 組件銷毀后重置 body 樣式
return () => {
if (isModal) {
// 此處只能查詢 mode 模式的 dialog 個數(shù) 因為 modeless 會點(diǎn)擊透傳 normal 是正常文檔流
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (openDialogDom.length < 1) {
document.body.style.cssText = bodyCssTextRef.current;
document.body.style.overflow = bodyOverflow.current;
}
} else {
document.body.style.cssText = bodyCssTextRef.current;
document.body.style.overflow = bodyOverflow.current;
}
};
}, [preventScrollThrough, attach, visible, mode, isModal, showInAttachedElement, prefixCls]);
其實還有一個邏輯,是把彈窗渲染到任意dom里,需要一個Portal組件,我們這里就不說了,后續(xù)將Popup或者叫trigger組件的時候我們講吧。一篇文檔內(nèi)容太多不好消化。
好了,主邏輯已經(jīng)寫完了,很簡單吧!
接下來看下完整代碼,沒有注釋的部分是上面已經(jīng)講過的
//省去了import
// 把css的數(shù)字轉(zhuǎn)為有px結(jié)尾的字符串,,這里其實應(yīng)該寫到一個utils文件夾里,不應(yīng)該跟主代碼混在一起
function GetCSSValue(v: string | number) {
return Number.isNaN(Number(v)) ? v : `${Number(v)}px`;
}
// 動畫執(zhí)行時間,這里其實應(yīng)該寫到一個constants文件里,不應(yīng)該跟主代碼混在一起
const transitionTime = 300;
const RenderDialog = forwardRef((props: RenderDialogProps, ref: React.Ref<HTMLDivElement>) => {
// 這里不用看,跟國際化有關(guān)
const [local] = useLocaleReceiver('dialog');
const {
prefixCls,
attach, // 對話框掛載的節(jié)點(diǎn),默認(rèn)掛在組件本身的位置。數(shù)據(jù)類型為 String 時,會被當(dāng)作選擇器處理,進(jìn)行節(jié)點(diǎn)查詢。示例:'body' 或 () => document.body
visible, // 控制對話框是否顯示
mode, // 對話框類型,有三種:模態(tài)對話框、非模態(tài)對話框和普通對話框。彈出「模態(tài)對話框」時,只能操作對話框里面的內(nèi)容,不能操作其他內(nèi)容。彈出「非模態(tài)對話框」時,則可以操作頁面內(nèi)所有內(nèi)容?!钙胀▽υ捒颉故侵笡]有脫離文檔流的對話框,可以在這個基礎(chǔ)上開發(fā)更多的插件
zIndex, // 對話框?qū)蛹?,Web 側(cè)樣式默認(rèn)為 2500,移動端和小程序樣式默認(rèn)為 1500
showOverlay, // 是否顯示遮罩層
onEscKeydown = noop,// 按下 ESC 時觸發(fā)事件
onClosed = noop, // 對話框消失動畫效果結(jié)束后觸發(fā)
onClose = noop, // 關(guān)閉事件,點(diǎn)擊取消按鈕、點(diǎn)擊關(guān)閉按鈕、點(diǎn)擊蒙層、按下 ESC 等場景下觸發(fā)
onCloseBtnClick = noop, // 點(diǎn)擊右上角關(guān)閉按鈕時觸發(fā)
onOverlayClick = noop, // 如果蒙層存在,點(diǎn)擊蒙層時觸發(fā)
onConfirm = noop, // 如果“確認(rèn)”按鈕存在,則點(diǎn)擊“確認(rèn)”按鈕時觸發(fā),或者鍵盤按下回車鍵時觸發(fā)
preventScrollThrough, // 防止?jié)L動穿透
closeBtn, // 關(guān)閉按鈕,可以自定義。值為 true 顯示默認(rèn)關(guān)閉按鈕,值為 false 不顯示關(guān)閉按鈕。值類型為 string 則直接顯示值,如:“關(guān)閉”。值類型為 TNode,則表示呈現(xiàn)自定義按鈕示例
closeOnEscKeydown, // 按下 ESC 時是否觸發(fā)對話框關(guān)閉事件
confirmOnEnter, // 是否在按下回車鍵時,觸發(fā)確認(rèn)事件
closeOnOverlayClick, // 點(diǎn)擊蒙層時是否觸發(fā)關(guān)閉事件
destroyOnClose, // 是否在關(guān)閉彈框的時候銷毀子元素
showInAttachedElement, // 僅在掛載元素中顯示抽屜,默認(rèn)在瀏覽器可視區(qū)域顯示。父元素需要有定位屬性,如:position: relative
} = props;
const wrap = useRef<HTMLDivElement>(); // 掛載到包裹彈框的dom上,包裹了好幾層。。。
const dialog = useRef<HTMLDivElement>(); // 引用彈窗dom
const dialogPosition = useRef<HTMLDivElement>(); // 包裹彈窗,用于定位的dom引用
const maskRef = useRef<HTMLDivElement>(); // 蒙層的dom引用
const bodyOverflow = useRef<string>();
const bodyCssTextRef = useRef<string>();
const contentClickRef = useRef(false);
const isModal = mode === 'modal';
const isNormal = mode === 'normal';
const canDraggable = props.draggable && mode === 'modeless';
const dialogOpenClass = `${prefixCls}__${mode}`;
useLayoutEffect(() => {
bodyOverflow.current = document.body.style.overflow;
bodyCssTextRef.current = document.body.style.cssText;
}, []);
useLayoutEffect(() => {
if (visible) {
if (isModal && bodyOverflow.current !== 'hidden' && preventScrollThrough && !showInAttachedElement) {
const scrollWidth = window.innerWidth - document.body.offsetWidth;
// 減少回流
if (bodyCssTextRef.current === '') {
let bodyCssText = 'overflow: hidden;';
if (scrollWidth > 0) {
bodyCssText += `position: relative;width: calc(100% - ${scrollWidth}px);`;
}
document.body.style.cssText = bodyCssText;
} else {
if (scrollWidth > 0) {
document.body.style.width = `calc(100% - ${scrollWidth}px)`;
document.body.style.position = 'relative';
}
document.body.style.overflow = 'hidden';
}
}
if (wrap.current) {
wrap.current.focus();
}
} else if (isModal) {
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (openDialogDom.length < 1) {
document.body.style.cssText = bodyCssTextRef.current;
}
}
// 組件銷毀后重置 body 樣式
return () => {
if (isModal) {
// 此處只能查詢 mode 模式的 dialog 個數(shù) 因為 modeless 會點(diǎn)擊透傳 normal 是正常文檔流
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (openDialogDom.length < 1) {
document.body.style.cssText = bodyCssTextRef.current;
document.body.style.overflow = bodyOverflow.current;
}
} else {
document.body.style.cssText = bodyCssTextRef.current;
document.body.style.overflow = bodyOverflow.current;
}
};
}, [preventScrollThrough, attach, visible, mode, isModal, showInAttachedElement, prefixCls]);
const onAnimateLeave = () => {
if (wrap.current) {
wrap.current.style.display = 'none';
}
if (isModal && preventScrollThrough) {
// 還原 body 的滾動條
const openDialogDom = document.querySelectorAll(`${prefixCls}__mode`);
if (isModal && openDialogDom.length < 1) {
document.body.style.overflow = bodyOverflow.current;
}
}
if (!isModal) {
// 關(guān)閉彈窗 清空拖拽設(shè)置的相關(guān) css
const { style } = dialog.current;
style.position = 'relative';
style.left = 'unset';
style.top = 'unset';
}
onClosed && onClosed();
};
const onMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (showOverlay && (closeOnOverlayClick ?? local.closeOnOverlayClick)) {
// 判斷點(diǎn)擊事件初次點(diǎn)擊是否為內(nèi)容區(qū)域
if (contentClickRef.current) {
contentClickRef.current = false;
} else if (e.target === dialogPosition.current) {
onOverlayClick({ e });
onClose({ e, trigger: 'overlay' });
}
}
};
const handleCloseBtnClick = (e: React.MouseEvent<HTMLDivElement>) => {
onCloseBtnClick({ e });
onClose({ e, trigger: 'close-btn' });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
if (e.key === 'Escape') {
e.stopPropagation();
onEscKeydown({ e });
if (closeOnEscKeydown ?? local.closeOnEscKeydown) {
onClose({ e, trigger: 'esc' });
}
} else if (e.key === 'Enter' || e.key === 'NumpadEnter') {
// 回車鍵觸發(fā)點(diǎn)擊確認(rèn)事件
e.stopPropagation();
if (confirmOnEnter) {
onConfirm({ e });
}
}
};
// 渲染Dialog主體
const renderDialog = () => {
const dest: any = {};
// 把width變?yōu)橛衟x結(jié)尾的字符串
if (props.width !== undefined) {
dest.width = GetCSSValue(props.width);
}
// normal 場景下,需要設(shè)置 zindex 為auto 避免出現(xiàn)多個 dialog,normal 出現(xiàn)在最上層
if (props.mode === 'normal') {
dest.zIndex = 'auto';
}
// 獲取footer
const footer = props.footer ? <div className={`${prefixCls}__footer`}>{props.footer}</div> : null;
// 獲取header
const { header } = props;
// 獲取Dialog body
const body = <div className={`${prefixCls}__body`}>{props.body || props.children}</div>;
// 關(guān)閉按鈕,可以自定義。值為 true 顯示默認(rèn)關(guān)閉按鈕,值為 false 不顯示關(guān)閉按鈕。值類型為 string 則直接顯示值,如:“關(guān)閉”。
const closer = closeBtn && (
<span onClick={handleCloseBtnClick} className={`${prefixCls}__close`}>
{closeBtn}
</span>
);
const validWindow = typeof window === 'object';
// 獲取屏幕高度
const screenHeight = validWindow ? window.innerHeight || document.documentElement.clientHeight : undefined;
// 獲取屏幕寬度
const screenWidth = validWindow ? window.innerWidth || document.documentElement.clientWidth : undefined;
// 設(shè)置style
const style = { ...dest, ...props.style };
let dialogOffset = { x: 0, y: 0 };
// 拖拽代碼實現(xiàn)部分
const onDialogMove = (e: MouseEvent) => {
// offsetWidth是指元素的寬 + padding + border的總和
const { style, offsetWidth, offsetHeight } = dialog.current;
// diffX是指彈框部分距離body左邊部分
let diffX = e.clientX - dialogOffset.x;
let diffY = e.clientY - dialogOffset.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`;
};
const onDialogMoveEnd = () => {
// 恢復(fù)指針樣式為默認(rèn),并且注銷mousemove, mouseup事件
dialog.current.style.cursor = 'default';
document.removeEventListener('mousemove', onDialogMove);
document.removeEventListener('mouseup', onDialogMoveEnd);
};
// 拖拽開始,對應(yīng)mouseDown事件
const onDialogMoveStart = (e: React.MouseEvent<HTMLDivElement>) => {
contentClickRef.current = true;
// 阻止事件冒泡, mode === 'modeless才能拖拽
if (canDraggable && e.currentTarget === e.target) {
const { offsetLeft, offsetTop, offsetHeight, offsetWidth } = dialog.current;
// 如果彈出框超出屏幕范圍 不能進(jìn)行拖拽
if (offsetWidth > screenWidth || offsetHeight > screenHeight) return;
// 拖拽樣式設(shè)置為move
dialog.current.style.cursor = 'move';
// 計算鼠標(biāo) e.clientX是鼠標(biāo)在屏幕的坐標(biāo),offsetLeft是Dialog主體跟body的距離
// 所以e.clientX - offsetLeft就是鼠標(biāo)在是Dialog主體上的橫坐標(biāo)
const diffX = e.clientX - offsetLeft;
const diffY = e.clientY - offsetTop;
dialogOffset = {
x: diffX,
y: diffY,
};
// 此時把mousemove和mouseup事件也綁定一下,其實不建議綁定在這里直接操作dom
document.addEventListener('mousemove', onDialogMove);
document.addEventListener('mouseup', onDialogMoveEnd);
}
};
// 頂部定位實現(xiàn)
const positionStyle: any = {};
if (props.top) {
const topValue = GetCSSValue(props.top);
positionStyle.paddingTop = topValue;
}
// 此處獲取定位方式 top 優(yōu)先級較高 存在時 默認(rèn)使用 top 定位
const positionClass = classnames(
`${prefixCls}__position`,
{ [`${prefixCls}--top`]: !!props.top },
`${props.placement && !props.top ? `${prefixCls}--${props.placement}` : ''}`,
);
const dialogElement = (
<div className={isNormal ? '' : `${prefixCls}__wrap`}>
<div className={isNormal ? '' : positionClass} style={positionStyle} onClick={onMaskClick} ref={dialogPosition}>
<div
ref={dialog}
style={style}
className={classnames(`${prefixCls}`, `${prefixCls}--default`)}
onMouseDown={onDialogMoveStart}
>
<div className={classnames(`${prefixCls}__header`)}>
{header}
{closer}
</div>
{body}
{footer}
</div>
</div>
</div>
);
return (
<CSSTransition
in={props.visible}
appear
mountOnEnter
unmountOnExit={destroyOnClose}
timeout={transitionTime}
classNames={`${prefixCls}-zoom`}
onEntered={props.onOpened}
onExited={onAnimateLeave}
nodeRef={dialog}
>
{dialogElement}
</CSSTransition>
);
};
const renderMask = () => {
let maskElement;
if (showOverlay) {
maskElement = (
<CSSTransition
in={visible}
appear
timeout={transitionTime}
classNames={`${prefixCls}-fade`}
mountOnEnter
unmountOnExit
nodeRef={maskRef}
>
<div ref={maskRef} className={`${prefixCls}__mask`} />
</CSSTransition>
);
}
return maskElement;
};
const render = () => {
const style: CSSProperties = {};
if (visible) {
style.display = 'block';
}
const wrapStyle = {
...style,
zIndex,
};
const dialogBody = renderDialog();
const wrapClass = classnames(
props.className,
`${prefixCls}__ctx`,
!isNormal ? `${prefixCls}__ctx--fixed` : '',
visible ? dialogOpenClass : '',
isModal && showInAttachedElement ? `${prefixCls}__ctx--absolute` : '',
props.mode === 'modeless' ? `${prefixCls}__ctx--modeless` : '',
);
// 如果不是 modal 模式 默認(rèn)沒有 mask 也就沒有相關(guān)點(diǎn)擊 mask 事件
const dialog = (
<div ref={wrap} className={wrapClass} style={wrapStyle} onKeyDown={handleKeyDown} tabIndex={0}>
{mode === 'modal' && renderMask()}
{dialogBody}
</div>
);
let dom = null;
if (visible || wrap.current) {
// normal 模式 attach 無效
if (attach === '' || isNormal) {
dom = dialog;
} else {
dom = (
<CSSTransition
in={visible}
appear
timeout={transitionTime}
mountOnEnter
unmountOnExit={destroyOnClose}
nodeRef={portalRef}
>
<Portal attach={attach} ref={portalRef}>
{dialog}
</Portal>
</CSSTransition>
);
}
}
return dom;
};
return render();
});
RenderDialog.defaultProps = dialogDefaultProps;
export default RenderDialog;
結(jié)束,react組件庫繼續(xù)搞起!
以上就是比ant更豐富Modal組件功能實現(xiàn)示例詳解的詳細(xì)內(nèi)容,更多關(guān)于ant Modal組件功能的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
解決React報錯Property 'X' does not 
這篇文章主要為大家介紹了解決React報錯Property 'X' does not exist on type 'HTMLElement',有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12
React native ListView 增加頂部下拉刷新和底下點(diǎn)擊刷新示例
這篇文章主要介紹了React native ListView 增加頂部下拉刷新和底下點(diǎn)擊刷新示例,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-04-04
解讀react的onClick自動觸發(fā)等相關(guān)問題
這篇文章主要介紹了解讀react的onClick自動觸發(fā)等相關(guān)問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-02-02
React useMemo與useCallabck有什么區(qū)別
useCallback和useMemo是一樣的東西,只是入?yún)⒂兴煌?,useCallback緩存的是回調(diào)函數(shù),如果依賴項沒有更新,就會使用緩存的回調(diào)函數(shù);useMemo緩存的是回調(diào)函數(shù)的return,如果依賴項沒有更新,就會使用緩存的return2022-12-12
React?TypeScript?應(yīng)用中便捷使用Redux?Toolkit方法詳解
這篇文章主要為大家介紹了React?TypeScript?應(yīng)用中便捷使用Redux?Toolkit方法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-11-11
react render props模式實現(xiàn)組件復(fù)用示例
本文主要介紹了react render props模式實現(xiàn)組件復(fù)用示例,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-07-07
react ant Design手動設(shè)置表單的值操作
這篇文章主要介紹了react ant Design手動設(shè)置表單的值操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-10-10

