feat: 模态框与模态框动画

pull/13/head
moonrailgun 4 years ago
parent fc3d9ae06f
commit 85dbc95459

@ -6,7 +6,12 @@ import _isFunction from 'lodash/isFunction';
import _isEmpty from 'lodash/isEmpty';
import type { ObjectSchema } from 'yup';
import { FastFormContext } from './context';
import { FastFormFieldMeta, getField } from './field';
import { getField, regField } from './field';
import type {
FastFormFieldComponent,
FastFormFieldProps,
FastFormFieldMeta,
} from './field';
import { getFormContainer } from './container';
/**
@ -99,3 +104,9 @@ FastForm.displayName = 'FastForm';
FastForm.defaultProps = {
submitLabel: '提交',
};
export { CustomField } from './CustomField';
export type { FastFormFieldComponent, FastFormFieldProps, FastFormFieldMeta };
export { regField };
export { regFormContainer } from './container';
export type { FastFormContainerComponent } from './container';

@ -0,0 +1,40 @@
import React from 'react';
import type { PortalMethods } from './context';
export type PortalConsumerProps = {
hostName: string;
manager: PortalMethods;
children: React.ReactNode;
};
export class PortalConsumer extends React.Component<PortalConsumerProps> {
_key: any;
componentDidMount() {
if (!this.props.manager) {
throw new Error(
'Looks like you forgot to wrap your root component with `PortalHost` component.\n'
);
}
this._key = this.props.manager.mount(
this.props.hostName,
this.props.children
);
}
componentDidUpdate() {
this.props.manager.update(
this.props.hostName,
this._key,
this.props.children
);
}
componentWillUnmount() {
this.props.manager.unmount(this.props.hostName, this._key);
}
render() {
return null;
}
}

@ -0,0 +1,64 @@
import React from 'react';
export type State = {
portals: {
key: number;
children: React.ReactNode;
}[];
};
interface PortalManagerProps {
renderManagerView: (children: React.ReactNode) => React.ReactElement;
}
export interface PortalManagerState {
portals: any[];
}
/**
* Portal host is the component which actually renders all Portals.
*/
export class PortalManager extends React.PureComponent<
PortalManagerProps,
PortalManagerState
> {
state: State = {
portals: [],
};
mount = (key: number, children: React.ReactNode) => {
this.setState((state) => ({
portals: [...state.portals, { key, children }],
}));
};
update = (key: number, children: React.ReactNode) => {
this.setState((state) => ({
portals: state.portals.map((item) => {
if (item.key === key) {
return { ...item, children };
}
return item;
}),
}));
};
unmount = (key: number) => {
this.setState((state) => ({
portals: state.portals.filter((item) => item.key !== key),
}));
};
render() {
const { renderManagerView } = this.props;
return this.state.portals.map(({ key, children }, i) => (
<React.Fragment key={key}>{renderManagerView(children)}</React.Fragment>
// <View
// key={key}
// collapsable={
// false /* Need collapsable=false here to clip the elevations, otherwise they appear above sibling components */
// }
// pointerEvents="box-none"
// style={[StyleSheet.absoluteFill, { zIndex: 1000 + i }]}
// >
// {children}
// </View>
));
}
}

@ -0,0 +1,10 @@
## Portal
**暂时没有使用**
这是一组用于使用命令式方法将一个React DOM映射到相关的位置的方法
可以指定不同的portal位置
用于处理对于每个节点都需要创建一个相关的Modal的情况
> 参考 https://github.com/ant-design/ant-design-mobile-rn/blob/master/components/portal/ 的实现

@ -0,0 +1,200 @@
import React, { useCallback, useRef, Fragment, useContext } from 'react';
import { useEffect } from 'react';
import { PortalManager } from './Manager';
import { createPortalContext } from './context';
import { PortalConsumer } from './Consumer';
import _isNil from 'lodash/isNil';
import { DefaultEventEmitter } from './defaultEventEmitter';
type Operation =
| { type: 'mount'; key: number; children: React.ReactNode }
| { type: 'update'; key: number; children: React.ReactNode }
| { type: 'unmount'; key: number };
// Events const
const addType = 'ADD_PORTAL';
const removeType = 'REMOVE_PORTAL';
// For react-native
// const TopViewEventEmitter = DeviceEventEmitter || new NativeEventEmitter();
const defaultRenderManagerViewFn = (children: React.ReactNode) => (
<>{children}</>
);
interface EventEmitterFunc {
emit: (...args: any[]) => any;
addListener: (...args: any[]) => any;
removeListener: (...args: any[]) => any;
}
export interface BuildPortalOptions {
/**
*
*
*/
hostName?: string;
/**
*
*/
eventEmitter?: EventEmitterFunc;
/**
* Portal Manager
*/
renderManagerView?: (children: React.ReactNode) => React.ReactElement;
}
export function buildPortal(options: BuildPortalOptions) {
const {
hostName = 'default',
eventEmitter = new DefaultEventEmitter(),
renderManagerView = defaultRenderManagerViewFn,
} = options;
let nextKey = 10000;
const add = (el: React.ReactNode): number => {
const key = nextKey++;
eventEmitter.emit(addType, hostName, el, key);
return key;
};
const remove = (key: number): void => {
eventEmitter.emit(removeType, hostName, key);
};
const PortalContext = createPortalContext(hostName);
const PortalHost = React.memo((props) => {
const managerRef = useRef<PortalManager>();
const nextKeyRef = useRef<number>(0);
const queueRef = useRef<Operation[]>([]);
const hostNameRef = useRef(hostName);
useEffect(() => {
hostNameRef.current = hostName;
}, [hostName]);
const mount: any = useCallback(
(name: string, children: React.ReactNode, _key?: number) => {
if (name !== hostNameRef.current) {
return;
}
const key = _key || nextKeyRef.current++;
if (managerRef.current) {
managerRef.current.mount(key, children);
} else {
queueRef.current.push({ type: 'mount', key, children });
}
return key;
},
[]
);
const update = useCallback(
(name: string, key: number, children: React.ReactNode) => {
if (name !== hostNameRef.current) {
return;
}
if (managerRef.current) {
managerRef.current.update(key, children);
} else {
const op: Operation = { type: 'mount', key, children };
const index = queueRef.current.findIndex(
(o) => o.type === 'mount' || (o.type === 'update' && o.key === key)
);
if (index > -1) {
queueRef.current[index] = op;
} else {
queueRef.current.push(op);
}
}
},
[]
);
const unmount = useCallback((name: string, key: number) => {
if (name !== hostNameRef.current) {
return;
}
if (managerRef.current) {
managerRef.current.unmount(key);
} else {
queueRef.current.push({ type: 'unmount', key });
}
}, []);
useEffect(() => {
eventEmitter.addListener(addType, mount);
eventEmitter.addListener(removeType, unmount);
return () => {
eventEmitter.removeListener(addType, mount);
eventEmitter.removeListener(removeType, unmount);
};
}, [mount, unmount]);
useEffect(() => {
// 处理队列
const queue = queueRef.current;
const manager = managerRef.current;
while (queue.length && manager) {
const action = queue.pop();
if (!action) {
continue;
}
switch (action.type) {
case 'mount':
manager.mount(action.key, action.children);
break;
case 'update':
manager.update(action.key, action.children);
break;
case 'unmount':
manager.unmount(action.key);
break;
}
}
}, []);
return (
<PortalContext.Provider
value={{
mount,
update,
unmount,
}}
>
<Fragment>{props.children}</Fragment>
<PortalManager
ref={managerRef as any}
renderManagerView={renderManagerView}
/>
</PortalContext.Provider>
);
});
PortalHost.displayName = 'PortalHost-' + hostName;
const PortalRender = React.memo((props) => {
const manager = useContext(PortalContext);
if (_isNil(manager)) {
console.error('Not find PortalContext');
return null;
}
return (
<PortalConsumer hostName={hostName} manager={manager}>
{props.children}
</PortalConsumer>
);
});
PortalRender.displayName = 'PortalRender-' + hostName;
return { add, remove, PortalHost, PortalRender };
}

@ -0,0 +1,14 @@
import React from 'react';
export type PortalMethods = {
mount: (name: string, children: React.ReactNode) => number;
update: (name: string, key: number, children: React.ReactNode) => void;
unmount: (name: string, key: number) => void;
};
export function createPortalContext(name: string) {
const PortalContext = React.createContext<PortalMethods | null>(null);
PortalContext.displayName = 'PortalContext-' + name;
return PortalContext;
}

@ -0,0 +1,40 @@
export class DefaultEventEmitter {
// 参考: https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget
listeners = {};
emit(type: string, ...args: any[]) {
if (!(type in this.listeners)) {
return;
}
const stack = this.listeners[type];
for (let i = 0, l = stack.length; i < l; i++) {
stack[i].call(this, event);
const func = stack[i];
if (typeof func === 'function') {
func(...args);
}
}
}
addListener(type: string, callback: (...args: any[]) => any) {
if (!(type in this.listeners)) {
this.listeners[type] = [];
}
this.listeners[type].push(callback);
}
removeListener(type: string, callback: (...args: any[]) => any) {
if (!(type in this.listeners)) {
return;
}
const stack = this.listeners[type];
for (let i = 0, l = stack.length; i < l; i++) {
if (stack[i] === callback) {
stack.splice(i, 1);
return this.removeListener(type, callback);
}
}
}
}

@ -0,0 +1,2 @@
export { buildPortal } from './buildPortal';
export { DefaultEventEmitter } from './defaultEventEmitter';

@ -8,16 +8,19 @@ export { getCachedUserInfo } from './cache/cache';
export { useCachedUserInfo } from './cache/useCache';
// components
export { FastForm } from './components/FastForm/index';
export { CustomField } from './components/FastForm/CustomField';
export {
FastForm,
CustomField,
regField,
regFormContainer,
} from './components/FastForm/index';
export type {
FastFormFieldComponent,
FastFormFieldProps,
FastFormFieldMeta,
} from './components/FastForm/field';
export { regField } from './components/FastForm/field';
export { regFormContainer } from './components/FastForm/container';
export type { FastFormContainerComponent } from './components/FastForm/container';
FastFormContainerComponent,
} from './components/FastForm/index';
export { buildPortal, DefaultEventEmitter } from './components/Portal';
export { TcProvider } from './components/Provider';
// i18n

@ -18,14 +18,15 @@
"clsx": "^1.1.1",
"jwt-decode": "^3.1.2",
"p-min-delay": "^4.0.0",
"tailchat-shared": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-transition-group": "^4.4.2",
"react-use-gesture": "^9.1.3",
"socket.io-client": "^4.1.2",
"tailchat-shared": "*",
"tailwindcss": "^2.2.4",
"yup": "^0.32.9"
},
@ -40,6 +41,7 @@
"@types/react-dom": "^17.0.8",
"@types/react-router": "^5.1.15",
"@types/react-router-dom": "^5.1.7",
"@types/react-transition-group": "^4.4.2",
"@types/webpack": "^5.28.0",
"@types/webpack-dev-server": "^3.11.4",
"autoprefixer": "^10.2.6",

@ -3,6 +3,7 @@ import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import { TcProvider, useStorage } from 'tailchat-shared';
import clsx from 'clsx';
import { Loadable } from './components/Loadable';
import { PortalHost } from './components/Portal';
const MainRoute = Loadable(() =>
import('./routes/Main').then((module) => module.MainRoute)
@ -15,7 +16,9 @@ const EntryRoute = Loadable(() =>
const AppProvider: React.FC = React.memo((props) => {
return (
<BrowserRouter>
<TcProvider>{props.children}</TcProvider>
<TcProvider>
<PortalHost>{props.children}</PortalHost>
</TcProvider>
</BrowserRouter>
);
});

@ -0,0 +1,37 @@
.modal-anim {
&-appear {
opacity: 0;
.modal-inner {
margin-top: -40px;
}
}
&-appear-active,
&-appear-done {
opacity: 1;
transition: opacity 200ms;
.modal-inner {
transition: margin-top 200ms;
margin-top: 0px;
}
}
&-exit {
opacity: 1;
.modal-inner {
margin-top: 0px;
}
}
&-exit-active,
&-exit-done {
opacity: 0;
transition: opacity 200ms;
.modal-inner {
transition: margin-top 200ms;
margin-top: -40px;
}
}
}

@ -0,0 +1,190 @@
import React, { useCallback, useContext, useState } from 'react';
import _isFunction from 'lodash/isFunction';
import _isNil from 'lodash/isNil';
import _last from 'lodash/last';
import _pull from 'lodash/pull';
import _isString from 'lodash/isString';
import _noop from 'lodash/noop';
import { PortalAdd, PortalRemove } from './Portal';
import { Typography } from 'antd';
import { Icon } from '@iconify/react';
import { CSSTransition } from 'react-transition-group';
// import { animated, useSpring } from 'react-spring';
// import { easeQuadInOut } from 'd3-ease';
// import { Iconfont } from './Iconfont';
import './Modal.less';
const transitionEndListener = (node: HTMLElement, done: () => void) =>
node.addEventListener('transitionend', done, false);
/**
*
*/
const ModalContext = React.createContext<{
closeModal: () => void;
}>({
closeModal: _noop,
});
interface ModalProps {
visible?: boolean;
onChangeVisible?: (visible: boolean) => void;
/**
*
* @default false
*/
closable?: boolean;
/**
*
*/
maskClosable?: boolean;
}
export const Modal: React.FC<ModalProps> = React.memo((props) => {
const {
visible,
onChangeVisible,
closable = false,
maskClosable = true,
} = props;
const [showing, setShowing] = useState(true);
const handleClose = useCallback(() => {
if (maskClosable === false) {
return;
}
setShowing(false);
}, [maskClosable]);
const stopPropagation = useCallback((e: React.BaseSyntheticEvent) => {
e.stopPropagation();
}, []);
if (visible === false) {
return null;
}
return (
<CSSTransition
in={showing}
classNames="modal-anim"
timeout={200}
addEndListener={transitionEndListener}
onExited={() => {
if (showing === false && _isFunction(onChangeVisible)) {
onChangeVisible(false);
}
}}
appear={true}
>
<div
className="absolute left-0 right-0 top-0 bottom-0 bg-black bg-opacity-50 flex justify-center items-center"
onClick={handleClose}
>
<ModalContext.Provider value={{ closeModal: handleClose }}>
{/* Inner */}
<div
className="modal-inner bg-gray-800 rounded overflow-auto relative"
style={{ maxHeight: '80vh', maxWidth: '80vw' }}
onClick={stopPropagation}
>
{closable === true && (
<Icon
className="absolute right-2.5 top-3.5 text-xl z-10"
icon="mdi-close"
onClick={handleClose}
/>
)}
{props.children}
</div>
</ModalContext.Provider>
</div>
</CSSTransition>
);
});
Modal.displayName = 'Modal';
const modelKeyStack: number[] = [];
/**
* Modal
*/
export function closeModal(key?: number): void {
if (_isNil(key)) {
key = _last(modelKeyStack);
}
if (typeof key === 'number') {
_pull(modelKeyStack, key);
PortalRemove(key);
}
}
/**
* Modal
*/
export function openModal(
content: React.ReactNode,
props?: Pick<ModalProps, 'closable'>
): number {
const key = PortalAdd(
<Modal
{...props}
visible={true}
onChangeVisible={(visible) => {
if (visible === false) {
closeModal(key);
}
}}
>
{content}
</Modal>
);
modelKeyStack.push(key);
return key;
}
/**
* modal
*/
export function useModalContext() {
const { closeModal } = useContext(ModalContext);
return { closeModal };
}
/**
*
*/
// const ModalWrapperContainer = styled.div`
// padding: 16px;
// min-width: 300px;
// ${(props) => props.theme.mixins.desktop('min-width: 420px;')}
// `;
export const ModalWrapper: React.FC<{
title?: string;
}> = React.memo((props) => {
const title = _isString(props.title) ? (
<Typography.Title
level={4}
style={{ textAlign: 'center', marginBottom: 16 }}
>
{props.title}
</Typography.Title>
) : null;
return (
<div className="p-4" style={{ minWidth: 300 }}>
{title}
{props.children}
</div>
);
});
ModalWrapper.displayName = 'ModalWrapper';

@ -0,0 +1,13 @@
import React from 'react';
import { buildPortal, DefaultEventEmitter } from 'tailchat-shared';
const eventEmitter = new DefaultEventEmitter();
const { PortalHost, PortalRender, add, remove } = buildPortal({
hostName: 'default',
eventEmitter,
// eslint-disable-next-line react/display-name
renderManagerView: (children) => <div>{children}</div>,
});
export { PortalHost, PortalRender, add as PortalAdd, remove as PortalRemove };

@ -1,6 +1,7 @@
import { Avatar } from '@/components/Avatar';
import { openModal } from '@/components/Modal';
import { Icon } from '@iconify/react';
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { GroupInfo, useAppSelector } from 'tailchat-shared';
import { NavbarNavItem } from './NavItem';
@ -15,6 +16,10 @@ function useGroups(): GroupInfo[] {
export const GroupNav: React.FC = React.memo(() => {
const groups = useGroups();
const handleCreateGroup = useCallback(() => {
openModal(<div className="w-60 h-48"></div>);
}, []);
return (
<div className="space-y-2">
{Array.isArray(groups) &&
@ -30,7 +35,7 @@ export const GroupNav: React.FC = React.memo(() => {
))}
{/* 创建群组 */}
<NavbarNavItem className="bg-green-500">
<NavbarNavItem className="bg-green-500" onClick={handleCreateGroup}>
<Icon className="text-3xl text-white" icon="mdi-plus" />
</NavbarNavItem>
</div>

@ -4,6 +4,7 @@ import React from 'react';
export const NavbarNavItem: React.FC<{
className?: ClassValue;
onClick?: () => void;
}> = React.memo((props) => {
return (
<div
@ -11,6 +12,7 @@ export const NavbarNavItem: React.FC<{
'w-12 h-12 hover:rounded-lg bg-gray-300 transition-all rounded-1/2 cursor-pointer flex items-center justify-center overflow-hidden',
props.className
)}
onClick={props.onClick}
>
{props.children}
</div>

@ -321,6 +321,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.8.7":
version "7.14.8"
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.8.tgz#7119a56f421018852694290b9f9148097391b446"
integrity sha512-twj3L8Og5SaCRCErB4x4ajbvBIVV77CGeFglHpeg5WC5FF8TZzBWXtTJ4MqaD9QszLYTtr+IsaAL2rEUevb+eg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.14.5", "@babel/template@^7.3.3":
version "7.14.5"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
@ -1180,6 +1187,13 @@
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.2":
version "4.4.2"
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz#38890fd9db68bf1f2252b99a942998dc7877c5b3"
integrity sha512-KibDWL6nshuOJ0fu8ll7QnV/LVTo3PzQ9aCPnRUYPfX7eZohHwLIdNHj7pftanREzHNP4/nJa8oeM73uSiavMQ==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^17.0.11":
version "17.0.11"
resolved "https://registry.npmjs.org/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
@ -3194,6 +3208,14 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
dependencies:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-serializer@^1.0.1:
version "1.3.2"
resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
@ -7654,6 +7676,16 @@ react-router@5.2.0, react-router@^5.2.0:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-transition-group@^4.4.2:
version "4.4.2"
resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-use-gesture@^9.1.3:
version "9.1.3"
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-9.1.3.tgz#92bd143e4f58e69bd424514a5bfccba2a1d62ec0"

Loading…
Cancel
Save