/** * 设计: * * Popover组件只是一个壳子,负责组装Trigger和Content。 * * 弹层实际的打开/关闭都是Content完成的,而什么情况打开弹层是Trigger控制的。 * * Popover 组件是一个递归的组件,支持嵌套。 * * * context context * ------> ------> * Popover Popover child Popover grand-child ...... * <------ <------ * isOutsideStacked isOutsideStacked * */ import * as React from 'react'; import { Component, Children } from 'react'; import * as ReactDOM from 'react-dom'; import cx from 'classnames'; import uniqueId from 'zent/es/utils/uniqueId'; import noop from 'zent/es/utils/noop'; import isPromise from 'zent/es/utils/isPromise'; import kindOf from 'zent/es/utils/kindOf'; import getWidth from 'zent/es/utils/getWidth'; import memoize from 'zent/es/utils/memorize-one'; import PopoverContent from './Content'; import Trigger from './trigger'; import PopoverTrigger from './trigger/Trigger'; import PopoverContext, { IPopoverContext } from './PopoverContext'; import { PositionFunction } from './position-function'; import withPopover from './withPopover'; import Position from './placement'; const SKIPPED = () => {}; function handleBeforeHook(beforeFn, arity, continuation, escape) { // 有参数,传入continuation,由外部去控制何时调用 // escapse 用来终止 onChange 操作 if (arity === 1) { return beforeFn(continuation); } if (arity >= 2) { return beforeFn(continuation, escape); } // 无参数,如果返回Promise那么resolve后调用continuation, reject 的话调用 escape; // 如果返回不是Promise,直接调用Promise const mayBePromise = beforeFn(); if (!isPromise(mayBePromise) && mayBePromise !== SKIPPED) { return continuation(); } mayBePromise.then(continuation, escape); } export interface IPopoverProps { position: PositionFunction; cushion?: number; display?: string; onShow?: () => void; onClose?: () => void; onBeforeShow?: (callback?: () => void, escape?: () => void) => void; onBeforeClose?: (callback?: () => void, escape?: () => void) => void; containerSelector?: string; visible?: boolean; onVisibleChange?: (visible: boolean) => void; onPositionUpdated?: () => void; onPositionReady?: () => void; className?: string; wrapperClassName?: string; width?: number | string; prefix?: string; } export interface IPopoverState { visible?: boolean; } export class Popover extends Component { static defaultProps = { prefix: 'zent', className: '', wrapperClassName: '', display: 'block', onBeforeClose: noop, onBeforeShow: noop, onClose: noop, onShow: noop, cushion: 0, containerSelector: 'body', onPositionUpdated: noop, onPositionReady: noop, }; static contextType = PopoverContext; static Content = PopoverContent; static Trigger = Trigger; static Position = Position; static withPopover = withPopover; registerDescendant = (popover: Popover) => { this.descendants.push(popover); }; unregisterDescendant = (popover: Popover) => { const idx = this.descendants.indexOf(popover); this.descendants.splice(idx, 1); }; getPopoverContext = memoize( (): IPopoverContext => { return { _zentPopover: { close: this.close, open: this.open, getContentNode: this.getPopoverNode, getTriggerNode: this.getTriggerNode, registerDescendant: this.registerDescendant, unregisterDescendant: this.unregisterDescendant, }, }; } ); context!: IPopoverContext; id: string; isUnmounted: boolean; descendants: Popover[]; pendingOnBeforeHook: boolean; triggerNode: HTMLElement | null; triggerInstance: PopoverTrigger; contentInstance: PopoverContent; isOutsideSelf: (el: HTMLElement) => boolean | null; constructor(props) { super(props); // id用来唯一标识popover实例 this.id = uniqueId(`${props.prefix}-popover-internal-id-`); // 记录 Popover 子孙 this.descendants = []; if (!this.isVisibilityControlled(props)) { this.state = { visible: false, }; } this.isUnmounted = false; } isVisibilityControlled(props?: IPopoverProps) { const { visible, onVisibleChange } = props || this.props; const hasOnChange = typeof onVisibleChange === 'function'; const hasVisible = typeof visible === 'boolean'; if ((hasVisible && !hasOnChange) || (hasOnChange && !hasVisible)) { throw new Error('visible and onVisibleChange must be used together'); } return hasVisible && hasOnChange; } getVisible = (props?: IPopoverProps, state?: IPopoverState) => { if (this.isVisibilityControlled(props)) { props = props || this.props; return props.visible; } state = state || this.state; return state.visible; }; setVisible = ( visible: boolean, props?: IPopoverProps, state?: IPopoverState ) => { props = props || this.props; state = state || this.state; const beforeHook = visible ? props.onBeforeShow : props.onBeforeClose; const onBefore = (...args) => { // 确保pending的时候不会触发多次beforeHook if (this.pendingOnBeforeHook) { return SKIPPED; } this.pendingOnBeforeHook = true; return beforeHook(...args); }; const escapse = () => { this.pendingOnBeforeHook = false; }; if (this.isVisibilityControlled(props)) { if (this.pendingOnBeforeHook || props.visible === visible) { return; } handleBeforeHook( onBefore, beforeHook.length, () => { props.onVisibleChange(visible); this.pendingOnBeforeHook = false; }, escapse ); } else { if (this.pendingOnBeforeHook || state.visible === visible) { return; } handleBeforeHook( onBefore, beforeHook.length, () => { this.safeSetState({ visible }); this.pendingOnBeforeHook = false; }, escapse ); } }; getPopoverNode = () => { return document.querySelector(`.${this.id}`); }; onTriggerRefChange = (triggerInstance, nodeFilter) => { const node = triggerInstance ? ReactDOM.findDOMNode(triggerInstance) : undefined; this.triggerNode = typeof nodeFilter === 'function' ? nodeFilter(node) : node; this.triggerInstance = triggerInstance; }; onContentRefChange = contentInstance => { this.contentInstance = contentInstance; }; getTriggerNode = () => { return this.triggerNode; }; adjustPosition() { if (this.contentInstance && this.contentInstance.adjustPosition) { this.contentInstance.adjustPosition(); } } onPositionUpdated = () => { // 嵌套的时候需要通知下层更新位置 this.descendants.forEach(child => { child.adjustPosition(); }); const { onPositionUpdated } = this.props; if (onPositionUpdated) { onPositionUpdated(); } }; open = () => { this.setVisible(true); }; close = () => { this.setVisible(false); }; injectIsOutsideSelf = impl => { this.isOutsideSelf = impl; }; // Popover up in the tree will call this method to see if the node lies outside isOutsideStacked = node => { if (this.isOutsideSelf) { // 在自身内部,肯定不在外面 if (!this.isOutsideSelf(node)) { return false; } } // 问下面的 Popover 是否在外面 if (this.descendants.some(popover => !popover.isOutsideStacked(node))) { return false; } return true; }; validateChildren() { const { children } = this.props; const childArray = Children.toArray(children); if (childArray.length !== 2) { throw new Error( 'There must be one and only one trigger and content in Popover' ); } const { trigger, content } = childArray.reduce<{ trigger: any; content: any; }>( (state, c: React.ReactElement) => { const type = c.type; if (kindOf(type, PopoverTrigger)) { state.trigger = c; } else if (kindOf(type, PopoverContent)) { state.content = c; } return state; }, { trigger: null, content: null } ); if (!trigger) { throw new Error('Missing trigger in Popover'); } if (!content) { throw new Error('Missing content in Popover'); } return { trigger, content }; } safeSetState(updater, callback?: () => void) { if (!this.isUnmounted) { return this.setState(updater, callback); } } componentDidMount() { const { _zentPopover: popover } = this.context || ({} as IPopoverContext); if (popover && popover.registerDescendant) { popover.registerDescendant(this); } if (this.isVisibilityControlled() && this.props.visible) { this.props.onShow(); } } componentDidUpdate(prevProps, prevState) { const visible = this.getVisible(); if (visible !== this.getVisible(prevProps, prevState)) { const afterHook = visible ? this.props.onShow : this.props.onClose; afterHook(); } } componentWillUnmount() { const { _zentPopover: popover } = this.context || ({} as IPopoverContext); if (popover && popover.unregisterDescendant) { popover.unregisterDescendant(this); } this.isUnmounted = true; } render() { const { trigger, content } = this.validateChildren(); const { display, prefix, className, wrapperClassName, containerSelector, position, cushion, width, onPositionReady, } = this.props; const visible = this.getVisible(); return (
{React.cloneElement(trigger, { prefix, contentVisible: visible, onTriggerRefChange: this.onTriggerRefChange, getTriggerNode: this.getTriggerNode, getContentNode: this.getPopoverNode, open: this.open, close: this.close, isOutsideStacked: this.isOutsideStacked, injectIsOutsideSelf: this.injectIsOutsideSelf, })} {React.cloneElement(content, { prefix, className, id: this.id, getContentNode: this.getPopoverNode, getAnchor: this.getTriggerNode, ref: this.onContentRefChange, visible, cushion, containerSelector, placement: position, onPositionUpdated: this.onPositionUpdated, onPositionReady, })}
); } } export default Popover;