ModalContent.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import React, { CSSProperties, ReactNode } from 'react';
  2. import PropTypes from 'prop-types';
  3. import cls from 'classnames';
  4. import { cssClasses } from '@douyinfe/semi-foundation/modal/constants';
  5. import ConfigContext, { ContextValue } from '../configProvider/context';
  6. import Button from '../iconButton';
  7. import Typography from '../typography';
  8. import BaseComponent from '../_base/baseComponent';
  9. import ModalContentFoundation, {
  10. ModalContentAdapter,
  11. ModalContentProps,
  12. ModalContentState
  13. } from '@douyinfe/semi-foundation/modal/modalContentFoundation';
  14. import { get, isFunction, noop } from 'lodash';
  15. import { IconClose } from '@douyinfe/semi-icons';
  16. import FocusTrapHandle from "@douyinfe/semi-foundation/utils/FocusHandle";
  17. let uuid = 0;
  18. export interface ModalContentReactProps extends ModalContentProps {
  19. children?: React.ReactNode;
  20. modalRender?: (node: ReactNode) => ReactNode
  21. }
  22. export default class ModalContent extends BaseComponent<ModalContentReactProps, ModalContentState> {
  23. static contextType = ConfigContext;
  24. static propTypes = {
  25. close: PropTypes.func,
  26. getContainerContext: PropTypes.func,
  27. contentClassName: PropTypes.string,
  28. maskClassName: PropTypes.string,
  29. onAnimationEnd: PropTypes.func,
  30. preventScroll: PropTypes.bool,
  31. };
  32. static defaultProps = {
  33. close: noop,
  34. getContainerContext: noop,
  35. contentClassName: '',
  36. maskClassName: ''
  37. };
  38. dialogId: string;
  39. private timeoutId: NodeJS.Timeout;
  40. modalDialogRef: React.MutableRefObject<HTMLDivElement>;
  41. foundation: ModalContentFoundation;
  42. context: ContextValue;
  43. focusTrapHandle: FocusTrapHandle;
  44. constructor(props: ModalContentProps) {
  45. super(props);
  46. this.state = {
  47. dialogMouseDown: false,
  48. prevFocusElement: FocusTrapHandle.getActiveElement(),
  49. };
  50. this.foundation = new ModalContentFoundation(this.adapter);
  51. this.dialogId = `dialog-${uuid++}`;
  52. this.modalDialogRef = React.createRef();
  53. }
  54. get adapter(): ModalContentAdapter {
  55. return {
  56. ...super.adapter,
  57. notifyClose: (e: React.MouseEvent) => {
  58. this.props.onClose(e);
  59. },
  60. notifyDialogMouseDown: () => {
  61. this.setState({ dialogMouseDown: true });
  62. },
  63. notifyDialogMouseUp: () => {
  64. if (this.state.dialogMouseDown) {
  65. // Not setting setTimeout triggers close when modal external mouseUp
  66. this.timeoutId = setTimeout(() => {
  67. this.setState({ dialogMouseDown: false });
  68. }, 0);
  69. }
  70. },
  71. addKeyDownEventListener: () => {
  72. if (this.props.closeOnEsc) {
  73. document.addEventListener('keydown', this.foundation.handleKeyDown);
  74. }
  75. },
  76. removeKeyDownEventListener: () => {
  77. if (this.props.closeOnEsc) {
  78. document.removeEventListener('keydown', this.foundation.handleKeyDown);
  79. }
  80. },
  81. getMouseState: () => this.state.dialogMouseDown,
  82. modalDialogFocus: () => {
  83. const { preventScroll } = this.props;
  84. let activeElementInDialog;
  85. if (this.modalDialogRef) {
  86. const activeElement = FocusTrapHandle.getActiveElement();
  87. activeElementInDialog = this.modalDialogRef.current.contains(activeElement);
  88. this.focusTrapHandle?.destroy();
  89. this.focusTrapHandle = new FocusTrapHandle(this.modalDialogRef.current, { preventScroll });
  90. }
  91. if (!activeElementInDialog) {
  92. this.modalDialogRef?.current?.focus({ preventScroll });
  93. }
  94. },
  95. modalDialogBlur: () => {
  96. this.modalDialogRef?.current.blur();
  97. this.focusTrapHandle?.destroy();
  98. },
  99. prevFocusElementReFocus: () => {
  100. const { prevFocusElement } = this.state;
  101. const { preventScroll } = this.props;
  102. const focus = get(prevFocusElement, 'focus');
  103. isFunction(focus) && prevFocusElement.focus({ preventScroll });
  104. }
  105. };
  106. }
  107. componentDidMount() {
  108. this.foundation.handleKeyDownEventListenerMount();
  109. this.foundation.modalDialogFocus();
  110. const nodes = FocusTrapHandle.getFocusableElements(this.modalDialogRef.current);
  111. if (!this.modalDialogRef.current.contains(document.activeElement)) {
  112. // focus on first focusable element
  113. nodes[0]?.focus();
  114. }
  115. }
  116. componentWillUnmount() {
  117. clearTimeout(this.timeoutId);
  118. this.foundation.destroy();
  119. }
  120. onKeyDown = (e: React.MouseEvent) => {
  121. this.foundation.handleKeyDown(e);
  122. };
  123. // Record when clicking the modal box
  124. onDialogMouseDown = () => {
  125. this.foundation.handleDialogMouseDown();
  126. };
  127. // Cancel recording when clicking the modal box at the end
  128. onMaskMouseUp = () => {
  129. this.foundation.handleMaskMouseUp();
  130. };
  131. // onMaskClick will judge dialogMouseDown before onMaskMouseUp updates dialogMouseDown
  132. onMaskClick = (e: React.MouseEvent) => {
  133. this.foundation.handleMaskClick(e);
  134. };
  135. close = (e: React.MouseEvent) => {
  136. this.foundation.close(e);
  137. };
  138. getMaskElement = () => {
  139. const { ...props } = this.props;
  140. const { mask, maskClassName } = props;
  141. if (mask) {
  142. const className = cls(`${cssClasses.DIALOG}-mask`, {
  143. // [`${cssClasses.DIALOG}-mask-hidden`]: !props.visible,
  144. });
  145. return <div key="mask" {...this.props.maskExtraProps} className={cls(className, maskClassName)} style={props.maskStyle}/>;
  146. }
  147. return null;
  148. };
  149. renderCloseBtn = () => {
  150. const {
  151. closable,
  152. closeIcon,
  153. } = this.props;
  154. let closer;
  155. if (closable) {
  156. const iconType = closeIcon || <IconClose x-semi-prop="closeIcon"/>;
  157. closer = (
  158. <Button
  159. aria-label="close"
  160. className={`${cssClasses.DIALOG}-close`}
  161. key="close-btn"
  162. onClick={this.close}
  163. type="tertiary"
  164. icon={iconType}
  165. theme="borderless"
  166. size="small"
  167. />
  168. );
  169. }
  170. return closer;
  171. };
  172. renderIcon = () => {
  173. const { icon } = this.props;
  174. return icon ? <span className={`${cssClasses.DIALOG}-icon-wrapper`} x-semi-prop="icon">{icon}</span> : null;
  175. };
  176. renderHeader = () => {
  177. if ('header' in this.props) {
  178. return this.props.header;
  179. }
  180. const { title } = this.props;
  181. const closer = this.renderCloseBtn();
  182. const icon = this.renderIcon();
  183. return (title === null || title === undefined) ?
  184. null :
  185. (
  186. <div className={`${cssClasses.DIALOG}-header`}>
  187. {icon}
  188. <Typography.Title
  189. heading={5}
  190. className={`${cssClasses.DIALOG}-title`}
  191. id={`${cssClasses.DIALOG}-title`}
  192. x-semi-prop="title"
  193. >
  194. {title}
  195. </Typography.Title>
  196. {closer}
  197. </div>
  198. );
  199. };
  200. renderBody = () => {
  201. const {
  202. bodyStyle,
  203. children,
  204. title,
  205. } = this.props;
  206. const bodyCls = cls(`${cssClasses.DIALOG}-body`, {
  207. [`${cssClasses.DIALOG}-withIcon`]: this.props.icon,
  208. });
  209. const closer = this.renderCloseBtn();
  210. const icon = this.renderIcon();
  211. const hasHeader = title !== null && title !== undefined || 'header' in this.props;
  212. return hasHeader ? (
  213. <div className={bodyCls} id={`${cssClasses.DIALOG}-body`} style={bodyStyle} x-semi-prop="children">
  214. {children}
  215. </div>
  216. ) : (
  217. <div className={`${cssClasses.DIALOG}-body-wrapper`}>
  218. {icon}
  219. <div className={bodyCls} style={bodyStyle} x-semi-prop="children">
  220. {children}
  221. </div>
  222. {closer}
  223. </div>
  224. );
  225. };
  226. getDialogElement = () => {
  227. const { ...props } = this.props;
  228. const style: CSSProperties = {};
  229. const digCls = cls(`${cssClasses.DIALOG}`, {
  230. [`${cssClasses.DIALOG}-centered`]: props.centered,
  231. [`${cssClasses.DIALOG}-${props.size}`]: props.size,
  232. });
  233. if (props.width) {
  234. style.width = props.width;
  235. }
  236. if (props.height) {
  237. style.height = props.height;
  238. }
  239. if (props.isFullScreen) {
  240. style.width = '100%';
  241. style.height = '100%';
  242. style.margin = 'unset';
  243. }
  244. const body = this.renderBody();
  245. const header = this.renderHeader();
  246. const footer = props.footer ? (
  247. <div className={`${cssClasses.DIALOG}-footer`} x-semi-prop="footer">
  248. {props.footer}
  249. </div>
  250. ) : null;
  251. const modalContentElement = (<div
  252. role="dialog"
  253. ref={this.modalDialogRef}
  254. aria-modal="true"
  255. aria-labelledby={`${cssClasses.DIALOG}-title`}
  256. aria-describedby={`${cssClasses.DIALOG}-body`}
  257. onAnimationEnd={props.onAnimationEnd}
  258. className={cls([`${cssClasses.DIALOG}-content`,
  259. props.contentClassName,
  260. { [`${cssClasses.DIALOG}-content-fullScreen`]: props.isFullScreen }])}>
  261. {header}
  262. {body}
  263. {footer}
  264. </div>);
  265. const dialogElement = (
  266. <div
  267. key="dialog-element"
  268. className={digCls}
  269. onMouseDown={this.onDialogMouseDown}
  270. style={{ ...props.style, ...style }}
  271. id={this.dialogId}
  272. >
  273. {props?.modalRender ? props?.modalRender(modalContentElement) : modalContentElement}
  274. </div>
  275. );
  276. return dialogElement;
  277. };
  278. render() {
  279. const {
  280. maskClosable,
  281. className,
  282. getPopupContainer,
  283. maskFixed,
  284. getContainerContext,
  285. ...rest
  286. } = this.props;
  287. const { direction } = this.context;
  288. const classList = cls(className, {
  289. [`${cssClasses.DIALOG}-popup`]: getPopupContainer && getPopupContainer() !== globalThis?.document?.body && !maskFixed,
  290. [`${cssClasses.DIALOG}-fixed`]: maskFixed,
  291. [`${cssClasses.DIALOG}-rtl`]: direction === 'rtl',
  292. });
  293. const containerContext = getContainerContext();
  294. const dataAttr = this.getDataAttr(rest);
  295. const elem = (
  296. <div className={classList} {...dataAttr}>
  297. {this.getMaskElement()}
  298. <div
  299. role="none"
  300. className={cls({
  301. [`${cssClasses.DIALOG}-wrap`]: true,
  302. [`${cssClasses.DIALOG}-wrap-center`]: this.props.centered
  303. })}
  304. onClick={maskClosable ? this.onMaskClick : null}
  305. onMouseUp={maskClosable ? this.onMaskMouseUp : null}
  306. {...this.props.contentExtraProps}
  307. >
  308. {this.getDialogElement()}
  309. </div>
  310. </div>
  311. );
  312. return containerContext && containerContext.Provider ?
  313. <containerContext.Provider value={containerContext.value}>{elem}</containerContext.Provider> : elem;
  314. }
  315. }