提醒框是一个很常用的交互组件,效果如下:

触发某事件后出现提示信息达到反馈效果,与前文Message效果类似。
组件库文档页面如下:



文档中可以看到,组件封装中提供了不同位置、不同类型、自定义操作按钮、自定义样式的支持,让组件更加灵活,同时提供了如下的API能力:

代码如下:
Notification.info({
title: 'Notification',
content: 'this is a Notification!',
duration: 3000,
});
因为是暴露给用户一个函数,因此组件的设计其实也是在监听到调用时,转入到组件内部自定义函数进行调用:
Notification.info = (props: string | NotificationProps<string>) => {
return addInstance('info', props);
};
Notification.success = (props: string | NotificationProps<string>) => {
return addInstance('success', props);
};
Notification.error = (props: string | NotificationProps<string>) => {
return addInstance('error', props);
};
Notification.normal = (props: string | NotificationProps<string>) => {
return addInstance('normal', props);
};
Notification.warning = (props: string | NotificationProps<string>) => {
return addInstance('warning', props);
};
Notification.loading = (props: string | NotificationProps<string>) => {
return addInstance('loading', props);
};
export default Notification;
可以看到,其实就是暴露出去了6个函数,再看一下addInstance函数吧:
//添加消息窗口
function addInstance(
type: 'info' | 'success' | 'warning' | 'error' | 'normal' | 'loading',
props: string | NotificationProps<string>,
) {
let style: CSSProperties = {},
duration: number = 3000,
title: string = '',
content: string = '',
position: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' = 'topRight',
clearable: boolean = false,
showFooter: boolean = false,
footerBtnVal: footerBtnVal = {
enter: 'OK',
exit: 'Cancel',
},
doneCallback: Function | undefined;
if (typeof props === 'object') {
title = props.title;
style = props.style || {};
duration = props.duration || 3000;
content = props.content as string;
doneCallback = props.doneCallback;
if (!props.position) {
position = 'topRight';
} else {
position = props.position;
}
clearable = props.clearable ? props.clearable : false;
showFooter = props.showFooter ? props.showFooter : false;
if (props.footerBtnVal) {
footerBtnVal = props.footerBtnVal as footerBtnVal;
}
} else if (typeof props === 'string') {
content = props;
}
const div = document.createElement('div');
const messageBoxId = String(Math.floor(Math.random() * 1000));
div.setAttribute('class', `${position}-${messageBoxId}`);
if (container) {
container.appendChild(div);
} else {
container = document.createElement('div');
container.setAttribute('class', 'notification-container');
document.body.appendChild(container);
container.appendChild(div);
}
setTimeout(() => {
if (Array.prototype.slice.call(container?.childNodes).includes(div)) {
changeHeight(Array.prototype.slice.call(container?.childNodes), position);
container?.removeChild(div);
if (position === 'topLeft') {
topLeftMessageNum--;
} else if (position === 'topRight') {
topRightMessageNum--;
} else if (position === 'bottomLeft') {
bottomLeftMessageNum--;
} else if (position === 'bottomRight') {
bottomRightMessageNum--;
}
}
}, duration + 200);
//挂载组件
ReactDOM.render(
<Notification
title={title}
style={style}
content={content}
type={type}
duration={duration}
position={position}
clearable={clearable}
showFooter={showFooter}
footerBtnVal={footerBtnVal}
doneCallback={doneCallback}
messageBoxId={messageBoxId}
/>,
div,
);
}
函数功能分两步:
在最后,ReactDOM.render挂载了这个组件,做到这一步,其实点击后的交互就出来了。
Notification组件代码如下:
const Notification = (props: NotificationProps<string>) => {
const {
style,
title,
content,
type,
duration,
position,
clearable,
showFooter,
footerBtnVal,
doneCallback,
messageBoxId,
} = props;
const [opac, setOpac] = useState(1);
const messageDom = useRef<any>(null);
useEffect(() => {
if (position === 'topLeft') {
topLeftMessageNum++;
} else if (position === 'topRight') {
topRightMessageNum++;
} else if (position === 'bottomLeft') {
bottomLeftMessageNum++;
} else if (position === 'bottomRight') {
bottomRightMessageNum++;
}
setTimeout(() => {
(messageDom.current as HTMLElement).style.transition = '0.2s linear';
(messageDom.current as HTMLElement).style.animation = 'none';
}, 500);
setTimeout(() => {
setOpac(0);
}, duration);
}, []);
useEffect(() => {
let transform;
if (position?.startsWith('top')) {
transform = 'top';
} else {
transform = 'bottom';
}
let defaultHeight = 0;
let avaHeight;
if (position === 'topLeft' && topLeftMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (topLeftMessageNum - 1);
avaHeight = topLeftMessageNum;
} else if (position === 'topRight' && topRightMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (topRightMessageNum - 1);
avaHeight = topRightMessageNum;
} else if (position === 'bottomLeft' && bottomLeftMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (bottomLeftMessageNum - 1);
avaHeight = bottomLeftMessageNum;
} else if (position === 'bottomRight' && bottomRightMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (bottomRightMessageNum - 1);
avaHeight = bottomRightMessageNum;
}
(messageDom?.current as HTMLElement).style[transform as 'top' | 'bottom'] =
(avaHeight as number) * 30 + defaultHeight + 'px';
}, [topLeftMessageNum, topRightMessageNum, bottomLeftMessageNum, bottomRightMessageNum]);
const messageIcon = useMemo(() => {
if (type === 'info') {
return <ExclamationCircleFilled style={{ color: '#1890ff', fontSize: '24px' }} />;
} else if (type === 'error') {
return <CloseCircleFilled style={{ color: '#f53f3f', fontSize: '24px' }} />;
} else if (type === 'normal') {
return <></>;
} else if (type === 'success') {
return <CheckCircleFilled style={{ color: '#19b42a', fontSize: '24px' }} />;
} else if (type === 'warning') {
return <ExclamationCircleFilled style={{ color: '#fa7d00', fontSize: '24px' }} />;
} else if (type === 'loading') {
return <LoadingOutlined style={{ color: '#1890ff', fontSize: '24px' }} />;
}
}, [type]);
const messageXtransform = useMemo(() => {
//提示框水平位置,居左/居右
if (position?.includes('Left')) {
return {
left: '20px',
};
} else {
return {
right: '20px',
};
}
}, [position]);
const closeMessage = () => {
//close按钮关闭
remove(messageBoxId as string, position as string, () => {
doneCallback && doneCallback(1);
});
};
const enter = () => {
//确认关闭
remove(messageBoxId as string, position as string, () => {
doneCallback && doneCallback(2);
});
};
const exit = () => {
//取消关闭
remove(messageBoxId as string, position as string, () => {
doneCallback && doneCallback(3);
});
};
return (
<div
className="notifica-container"
style={{ opacity: opac, ...messageXtransform, ...style }}
ref={messageDom}
>
<div className="title">
<div className="title-left">
{messageIcon}
<span className="title-content">{title}</span>
</div>
{clearable && <CloseOutlined className="close-icon" onClick={closeMessage} />}
</div>
<div className="notification-content">{content}</div>
{showFooter && (
<div className="notification-footer">
<div></div>
<div>
<Button type="text" height={30} handleClick={enter}>
{(footerBtnVal as footerBtnVal).exit}
</Button>
<Button type="primary" height={30} style={{ marginLeft: '15px' }} handleClick={exit}>
{(footerBtnVal as footerBtnVal).enter}
</Button>
</div>
</div>
)}
</div>
);
};
组件中的代码主要做了一些样式设计,可以看到组件中共有三个事件:
在其中,都调用了move方法,而move方法并不在组件中,原因很简单,move其实像所有组件的父亲一样,用于全局控制组件销毁后页面上dom节点的删除,看一下move方法:
//移除窗口
function remove(id: string, position: string, callback: Function) {
const container = document.querySelector('.notification-container');
const children = Array.prototype.slice.call(container?.childNodes);
for (let key in children) {
if (children[key].getAttribute('class') === `${position}-${id}`) {
const removeDom = children[key];
console.log(removeDom.childNodes);
removeDom.childNodes[0].style.opacity = 0;
setTimeout(() => {
container?.removeChild(removeDom);
}, 50);
if (position === 'topLeft') {
topLeftMessageNum--;
} else if (position === 'topRight') {
topRightMessageNum--;
} else if (position === 'bottomLeft') {
bottomLeftMessageNum--;
} else if (position === 'bottomRight') {
bottomRightMessageNum--;
}
changeHeight(children.slice(Number(key)), position);
callback();
}
}
}
根据我们最早挂载的id唯一标识,去查找这样标识的dom节点,删除,并把对应位置的计数减1之后调用changeHeight方法重排手动删除元素下的元素位置(让他们顶上去,不留空隙)
changeHeight方法如下:
//重排节点下窗口高度
function changeHeight(children: Array<HTMLElement>, position: any) {
const transform = position.startsWith('top') ? 'top' : 'bottom';
for (let key in children) {
const child = children[key].childNodes[0] as HTMLElement;
if (children[key].getAttribute('class')?.startsWith(transform)) {
const domHeight = document.querySelector('.notifica-container')?.clientHeight;
child.style[transform] =
Number(child.style[transform].split('p')[0]) - 30 - (domHeight as number) + 'px';
}
}
}
import React, { useState, useEffect, useMemo, useRef, CSSProperties } from 'react';
import ReactDOM from 'react-dom';
import { NotificationProps, footerBtnVal } from './interface';
import Button from '../Button';
import './index.module.less';
import {
ExclamationCircleFilled,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
CloseOutlined,
} from '@ant-design/icons';
let container: HTMLDivElement | null;
let topLeftMessageNum: number = 0;
let topRightMessageNum: number = 0;
let bottomLeftMessageNum: number = 0;
let bottomRightMessageNum: number = 0;
//添加消息窗口
function addInstance(
type: 'info' | 'success' | 'warning' | 'error' | 'normal' | 'loading',
props: string | NotificationProps<string>,
) {
let style: CSSProperties = {},
duration: number = 3000,
title: string = '',
content: string = '',
position: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' = 'topRight',
clearable: boolean = false,
showFooter: boolean = false,
footerBtnVal: footerBtnVal = {
enter: 'OK',
exit: 'Cancel',
},
doneCallback: Function | undefined;
if (typeof props === 'object') {
title = props.title;
style = props.style || {};
duration = props.duration || 3000;
content = props.content as string;
doneCallback = props.doneCallback;
if (!props.position) {
position = 'topRight';
} else {
position = props.position;
}
clearable = props.clearable ? props.clearable : false;
showFooter = props.showFooter ? props.showFooter : false;
if (props.footerBtnVal) {
footerBtnVal = props.footerBtnVal as footerBtnVal;
}
} else if (typeof props === 'string') {
content = props;
}
const div = document.createElement('div');
const messageBoxId = String(Math.floor(Math.random() * 1000));
div.setAttribute('class', `${position}-${messageBoxId}`);
if (container) {
container.appendChild(div);
} else {
container = document.createElement('div');
container.setAttribute('class', 'notification-container');
document.body.appendChild(container);
container.appendChild(div);
}
setTimeout(() => {
if (Array.prototype.slice.call(container?.childNodes).includes(div)) {
changeHeight(Array.prototype.slice.call(container?.childNodes), position);
container?.removeChild(div);
if (position === 'topLeft') {
topLeftMessageNum--;
} else if (position === 'topRight') {
topRightMessageNum--;
} else if (position === 'bottomLeft') {
bottomLeftMessageNum--;
} else if (position === 'bottomRight') {
bottomRightMessageNum--;
}
}
}, duration + 200);
//挂载组件
ReactDOM.render(
<Notification
title={title}
style={style}
content={content}
type={type}
duration={duration}
position={position}
clearable={clearable}
showFooter={showFooter}
footerBtnVal={footerBtnVal}
doneCallback={doneCallback}
messageBoxId={messageBoxId}
/>,
div,
);
}
//移除窗口
function remove(id: string, position: string, callback: Function) {
const container = document.querySelector('.notification-container');
const children = Array.prototype.slice.call(container?.childNodes);
for (let key in children) {
if (children[key].getAttribute('class') === `${position}-${id}`) {
const removeDom = children[key];
console.log(removeDom.childNodes);
removeDom.childNodes[0].style.opacity = 0;
setTimeout(() => {
container?.removeChild(removeDom);
}, 50);
if (position === 'topLeft') {
topLeftMessageNum--;
} else if (position === 'topRight') {
topRightMessageNum--;
} else if (position === 'bottomLeft') {
bottomLeftMessageNum--;
} else if (position === 'bottomRight') {
bottomRightMessageNum--;
}
changeHeight(children.slice(Number(key)), position);
callback();
}
}
}
//重排节点下窗口高度
function changeHeight(children: Array<HTMLElement>, position: any) {
const transform = position.startsWith('top') ? 'top' : 'bottom';
for (let key in children) {
const child = children[key].childNodes[0] as HTMLElement;
if (children[key].getAttribute('class')?.startsWith(transform)) {
const domHeight = document.querySelector('.notifica-container')?.clientHeight;
child.style[transform] =
Number(child.style[transform].split('p')[0]) - 30 - (domHeight as number) + 'px';
}
}
}
const Notification = (props: NotificationProps<string>) => {
const {
style,
title,
content,
type,
duration,
position,
clearable,
showFooter,
footerBtnVal,
doneCallback,
messageBoxId,
} = props;
const [opac, setOpac] = useState(1);
const messageDom = useRef<any>(null);
useEffect(() => {
if (position === 'topLeft') {
topLeftMessageNum++;
} else if (position === 'topRight') {
topRightMessageNum++;
} else if (position === 'bottomLeft') {
bottomLeftMessageNum++;
} else if (position === 'bottomRight') {
bottomRightMessageNum++;
}
setTimeout(() => {
(messageDom.current as HTMLElement).style.transition = '0.2s linear';
(messageDom.current as HTMLElement).style.animation = 'none';
}, 500);
setTimeout(() => {
setOpac(0);
}, duration);
}, []);
useEffect(() => {
let transform;
if (position?.startsWith('top')) {
transform = 'top';
} else {
transform = 'bottom';
}
let defaultHeight = 0;
let avaHeight;
if (position === 'topLeft' && topLeftMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (topLeftMessageNum - 1);
avaHeight = topLeftMessageNum;
} else if (position === 'topRight' && topRightMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (topRightMessageNum - 1);
avaHeight = topRightMessageNum;
} else if (position === 'bottomLeft' && bottomLeftMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (bottomLeftMessageNum - 1);
avaHeight = bottomLeftMessageNum;
} else if (position === 'bottomRight' && bottomRightMessageNum >= 1) {
defaultHeight = messageDom.current.clientHeight * (bottomRightMessageNum - 1);
avaHeight = bottomRightMessageNum;
}
(messageDom?.current as HTMLElement).style[transform as 'top' | 'bottom'] =
(avaHeight as number) * 30 + defaultHeight + 'px';
}, [topLeftMessageNum, topRightMessageNum, bottomLeftMessageNum, bottomRightMessageNum]);
const messageIcon = useMemo(() => {
if (type === 'info') {
return <ExclamationCircleFilled style={{ color: '#1890ff', fontSize: '24px' }} />;
} else if (type === 'error') {
return <CloseCircleFilled style={{ color: '#f53f3f', fontSize: '24px' }} />;
} else if (type === 'normal') {
return <></>;
} else if (type === 'success') {
return <CheckCircleFilled style={{ color: '#19b42a', fontSize: '24px' }} />;
} else if (type === 'warning') {
return <ExclamationCircleFilled style={{ color: '#fa7d00', fontSize: '24px' }} />;
} else if (type === 'loading') {
return <LoadingOutlined style={{ color: '#1890ff', fontSize: '24px' }} />;
}
}, [type]);
const messageXtransform = useMemo(() => {
//提示框水平位置,居左/居右
if (position?.includes('Left')) {
return {
left: '20px',
};
} else {
return {
right: '20px',
};
}
}, [position]);
const closeMessage = () => {
//close按钮关闭
remove(messageBoxId as string, position as string, () => {
doneCallback && doneCallback(1);
});
};
const enter = () => {
//确认关闭
remove(messageBoxId as string, position as string, () => {
doneCallback && doneCallback(2);
});
};
const exit = () => {
//取消关闭
remove(messageBoxId as string, position as string, () => {
doneCallback && doneCallback(3);
});
};
return (
<div
className="notifica-container"
style={{ opacity: opac, ...messageXtransform, ...style }}
ref={messageDom}
>
<div className="title">
<div className="title-left">
{messageIcon}
<span className="title-content">{title}</span>
</div>
{clearable && <CloseOutlined className="close-icon" onClick={closeMessage} />}
</div>
<div className="notification-content">{content}</div>
{showFooter && (
<div className="notification-footer">
<div></div>
<div>
<Button type="text" height={30} handleClick={enter}>
{(footerBtnVal as footerBtnVal).exit}
</Button>
<Button type="primary" height={30} style={{ marginLeft: '15px' }} handleClick={exit}>
{(footerBtnVal as footerBtnVal).enter}
</Button>
</div>
</div>
)}
</div>
);
};
Notification.info = (props: string | NotificationProps<string>) => {
return addInstance('info', props);
};
Notification.success = (props: string | NotificationProps<string>) => {
return addInstance('success', props);
};
Notification.error = (props: string | NotificationProps<string>) => {
return addInstance('error', props);
};
Notification.normal = (props: string | NotificationProps<string>) => {
return addInstance('normal', props);
};
Notification.warning = (props: string | NotificationProps<string>) => {
return addInstance('warning', props);
};
Notification.loading = (props: string | NotificationProps<string>) => {
return addInstance('loading', props);
};
export default Notification;
组件jest测试代码如下:
import React from 'react';
import Notification from '../../Notification/index';
import Enzyme from '../setup';
import mountTest from '../mountTest';
const { mount } = Enzyme;
describe('Notification', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runAllTimers();
});
it('test base Notification show correctly', () => {
//基础测试
Notification.info({
title: 'Notification',
content: 'test',
duration: 3000,
});
expect(document.querySelectorAll('.notification-container')).toHaveLength(1);
expect(document.querySelectorAll('.notifica-container .title-content')[0].innerHTML).toBe(
'Notification',
);
expect(
document.querySelectorAll('.notifica-container .notification-content')[0].innerHTML,
).toBe('test');
});
it('test click five nums Notification show num correctly', () => {
//测试多次渲染
for (let i = 0; i < 5; i++) {
Notification.info({
title: 'Notification',
content: 'test',
duration: 3000,
});
}
expect(document.querySelectorAll('.notification-container')[0].childNodes.length).toBe(5);
});
it('test four transform Notification show correctly', async () => {
//测试不同方向
const transforms = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight'];
for (let i = 0; i < transforms.length; i++) {
const transform = transforms[i] as 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
Notification.info({
title: 'Notification',
content: 'test',
duration: 3000,
position: transform,
});
setTimeout(() => {
const dom = document.querySelectorAll('.notifica-container')[i];
switch (i) {
case 0:
expect(
dom.getAttribute('style')?.includes('top') &&
dom.getAttribute('style')?.includes('left'),
).toBe(true);
break;
case 1:
expect(
dom.getAttribute('style')?.includes('top') &&
dom.getAttribute('style')?.includes('right'),
).toBe(true);
break;
case 2:
expect(
dom.getAttribute('style')?.includes('bottom') &&
dom.getAttribute('style')?.includes('left'),
).toBe(true);
break;
case 3:
expect(
dom.getAttribute('style')?.includes('bottom') &&
dom.getAttribute('style')?.includes('right'),
).toBe(true);
break;
}
}, 500);
}
});
it('test footer Notification show correctly', () => {
//测试自定义按钮
const mockFn = jest.fn();
Notification.info({
title: 'Notification',
content: 'test',
duration: 10000,
clearable: true,
showFooter: true,
footerBtnVal: {
enter: '确认',
exit: '取消',
},
doneCallback: mockFn,
});
expect(document.querySelector('.notifica-container .title')?.childNodes.length).toBe(2);
expect(document.querySelector('.notifica-container .notification-footer')).toBeDefined();
expect(
document.querySelector('.notifica-container .notification-footer .text')?.innerHTML,
).toBe('取消');
expect(
document.querySelector('.notifica-container .notification-footer .primary')?.innerHTML,
).toBe('确认');
});
it('test setting style Notification correctly', () => {
//测试自定义样式
Notification.info({
title: 'Notification',
content: 'test',
duration: 3000,
style: { width: '500px', fontSize: '15px' },
});
expect(
document
.querySelector('.notifica-container')
?.getAttribute('style')
?.includes('width: 500px, font-size: 15px'),
);
});
});
主要测试了文档中所有的功能,文档地址在下面~~
React-View-UI组件库线上链接:http://react-view-ui.com:92/#
github:https://github.com/fengxinhhh/React-View-UI-fs
npm:https://www.npmjs.com/package/react-view-ui
开源不易,欢迎学习和体验,喜欢请多多支持,有问题请留言。