import {
Component,
ContextType,
createContext,
SyntheticEvent,
cloneElement,
Ref,
ReactElement,
Children,
ReactComponentElement,
ReactInstance,
isValidElement,
MouseEvent,
FocusEvent,
Fragment,
} from 'react'
// import * as PropTypes from 'prop-types'
// import {default as c} from 'classnames'
import {
codeWarningOnce,
safeInvoke,
addEventListener,
EventListenerHandler,
ifNodeContains,
refNode,
pickDerivedStateFromProps,
isPromiseLike, isDev,
} from '@befe/brick-utils'
import {isFunction, isUndefined} from 'lodash-es'
import {isPopper, Popper, PopperProps} from './popper'
import {PopperContext} from './popper-context'
// 不同于 PopperTarget,不接受模拟的 PopperJS.ReferenceObject
// 因为一个模拟的 ReferenceObject 无所谓 trigger 触发方式,没有意义
type TriggerTarget = (() => HTMLElement | null | void) | HTMLElement | null
export interface PopperTriggerProps {
/**
* 触发方式
*/
type?: 'click' | 'hover' | 'focus'
/**
* @todo 是否禁用
* @private
*
* 暂不提供
* - trigger 是个无实体的 wrap,按理说应没有所谓的 disabled
* - 考虑 `` 这种情况(目标 disabled, 但可以 hover 出 tips 的情景),
* 多一层次 disabled 实际有点混淆
* - `beforeChange` / `onChange` 可以满足 “(根据需要)不触发” 的情况,且api 使用复杂度没有比 `disabled` 大
*/
disabled?: boolean
/**
* popper 是否显示控制值
*/
visible?: boolean
/**
* 指定 popper 的目标元素
* 注意
* - 不接受 ReactNode
* - 不接受模拟的 PopperJS.ReferenceObject,因为一个模拟的 ReferenceObject 无所谓 trigger 触发方式,没有意义
* @type (() => HTMLElement | null | void) | HTMLElement | null
*/
target?: TriggerTarget
/**
* popper 是否显示默认值
*/
defaultVisible?: boolean
/**
* visible 变化回调
*/
onChange?: (visible: boolean, e?: Event | SyntheticEvent) => void
/**
* visible 变化前的回调,根据返回值进行 visible 的变化
* - promise,当 promise resolve 后进行 visible change, promise reject 则不进行
* - false,visible 不进行 visible change
* - true | void,立即进行 visible change
*/
beforeChange?: (visible: boolean) => Promise | boolean | void
/**
* mouseEnter 延迟触发,单位:毫秒
*/
mouseEnterDelay?: number
/**
* mouseLeave 延迟触发,单位:毫秒
*/
mouseLeaveDelay?: number
/**
* focus 延迟出发,单位:毫秒
*/
focusDelay?: number
/**
* blur 延迟触发,单位:毫秒
*/
blurDelay?: number
/**
* 是否隐藏时隐藏 parent
*/
shouldHideParent?: boolean
/**
* 对于 type click,
* document 会绑定一个 mousedown 事件,实现 "点击 popper 外部时隐藏 popper" 行为
*
* - 设成 `false` 可去掉此行为
* - 也接受一个回调,以应付额外的自定义判断情况,比如,但如果点击 outside 的是某特定元素,则不关闭
*/
shouldHideOnMousedownDocument?: boolean | ((e: Event) => boolean)
}
type TriggerEventType = 'onClick' | 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur'
type PopperEventType = 'onMouseEnter' | 'onMouseLeave'
export const PopperTriggerContext = createContext({})
export const PopperTriggerConsumer = PopperTriggerContext
export const POPPER_TRIGGER_MINIMUM_MOUSE_LEAVE_DELAY = 60
export const POPPER_TRIGGER_INVALID_CHILDREN_WARNING = 'PopperTrigger 只允许 ReactElement 作为 children'
export const POPPER_TRIGGER_CONTROL_VISIBLE_INIT_WARNING = [
'[PopperTrigger] it won\'t show properly if not provide `props.target`',
'while setting visible `true` by `props.visible` / `props.defaultVisible` at initialization',
].join(' ')
interface PopperTriggerState {
visible: boolean
target?: TriggerTarget
}
interface ReactElementWithRef extends ReactElement {
ref?: Ref
}
export class PopperTrigger extends Component {
static displayName = 'PopperTrigger'
// static propTypes = {}
static defaultProps = {
type: 'click',
defaultVisible: false,
disabled: false,
mouseEnterDelay: 150,
mouseLeaveDelay: POPPER_TRIGGER_MINIMUM_MOUSE_LEAVE_DELAY,
focusDelay: 150,
blurDelay: 0,
shouldHideOnMousedownDocument: true,
}
static contextType = PopperContext
static getDerivedStateFromProps(nextProps: PopperTriggerProps) {
return pickDerivedStateFromProps(nextProps, ['visible', 'target'])
}
context!: ContextType
// hasChildShow = false
handleClickOutside: EventListenerHandler | null = null
nodeTarget?: ReactInstance | null = null
elemPopperWrap?: HTMLDivElement | null = null
nodePopper?: Popper | null = null
// elemPopperWrap?: ComponentType | null = null
visibleTimeoutId: number | null = null
scrollElement: HTMLElement | null = null
constructor(props: PopperTriggerProps) {
super(props)
const {target, visible} = props
const initialState: PopperTriggerState = {
visible: isUndefined(visible) ? !!props.defaultVisible : visible,
target,
}
codeWarningOnce(
!initialState.visible || !!initialState.target,
POPPER_TRIGGER_CONTROL_VISIBLE_INIT_WARNING
)
this.state = initialState
}
refTarget = (node: ReactInstance) => {
this.nodeTarget = node
const childTarget = (this.childTarget as ReactElementWithRef)
childTarget && refNode(childTarget.ref, node)
}
refPopper = (node: Popper) => {
this.nodePopper = node
const childPopper = (this.childPopper as ReactElementWithRef)
childPopper && refNode(childPopper.ref, node)
}
refPopperWrap = (node: HTMLDivElement) => {
this.elemPopperWrap = node
const childPopper = this.childPopper!
refNode(childPopper.props.refPopperWrap, node)
}
get targetElement() {
const {target} = this.state
if (isFunction(target)) {
return target()
}
return target
}
get contextValue() {
return {
...this.context,
hideByChild: this.hideByChild,
}
}
get childrenArray() {
const {children} = this.props
if (!children) {
return []
}
return Children.toArray(this.props.children).filter(child => {
codeWarningOnce(
isValidElement(child),
POPPER_TRIGGER_INVALID_CHILDREN_WARNING
)
return isValidElement(child)
}) as ReactElement[]
}
get childTarget() {
return this.childrenArray.find(child => !isPopper(child)) as ReactComponentElement
}
get childPopper() {
return this.childrenArray.find(child => isPopper(child)) as ReactElement | null
}
get targetInjectProps() {
const eventHandlers = this.props.type && {
'click': {
onClick: this.handleClickTarget,
},
'hover': {
onMouseEnter: this.handleMouseEnterTarget,
onMouseLeave: this.handleMouseLeaveTarget,
},
'focus': {
onFocus: this.handleFocusTarget,
onBlur: this.handleBlurTarget,
},
}[this.props.type]
return {
ref: this.refTarget,
...eventHandlers,
}
}
get popperInjectProps() {
const eventHandlers = this.props.type === 'hover' && {
onMouseEnter: this.handleMouseEnterPopper,
onMouseLeave: this.handleMouseLeavePopper,
}
return {
ref: this.refPopper,
refPopperWrap: this.refPopperWrap,
visible: this.state.visible,
target: this.state.target,
...eventHandlers,
}
}
handleClickTarget = (e: MouseEvent) => {
return this.invokeTargetEventHandler('onClick', e)
}
handleMouseEnterTarget = (e: MouseEvent) => {
this.invokeTargetEventHandler('onMouseEnter', e, true)
}
handleMouseLeaveTarget = (e: MouseEvent) => {
this.invokeTargetEventHandler('onMouseLeave', e, false)
}
handleFocusTarget = (e: FocusEvent) => {
this.invokeTargetEventHandler('onFocus', e, true)
}
handleBlurTarget = (e: FocusEvent) => {
this.invokeTargetEventHandler('onBlur', e, false)
}
handleMouseEnterPopper = (e: MouseEvent) => {
this.invokePopperEventHandler('onMouseEnter', e)
this.clearVisibleDelayTimeout()
}
handleMouseLeavePopper = (e: MouseEvent) => {
this.invokePopperEventHandler('onMouseLeave', e)
this.delaySetVisible(false, this.getEventDelay('onMouseLeave'), e)
}
handleClickDoc = (e: Event) => {
// don't use `findDOMNode` which has been deprecated in StrictMode.
// https://reactjs.org/docs/react-dom.html#finddomnode
// https://reactjs.org/docs/strict-mode.html#warning-about-deprecated-finddomnode-usage
const {shouldHideOnMousedownDocument} = this.props
const shouldClose = typeof shouldHideOnMousedownDocument === 'function'
? shouldHideOnMousedownDocument(e)
: shouldHideOnMousedownDocument
if (
shouldClose
&& !ifNodeContains(this.targetElement, e.target as Node)
&& !this.nodePopper?.isDescendant(e.target as Node)
) {
this.setVisible(false, e)
this.hideParent()
}
}
updateClickOutsideHandler() {
if (!this.state.visible) {
this.clearClickOutsideHandler()
} else if (
!this.handleClickOutside && this.props.type === 'click' && this.props.shouldHideOnMousedownDocument
) {
this.handleClickOutside = addEventListener(window.document, 'mousedown', this.handleClickDoc)
}
}
clearClickOutsideHandler() {
if (this.handleClickOutside) {
this.handleClickOutside.remove()
this.handleClickOutside = null
}
}
delaySetVisible(visible: boolean, delay: number, e: SyntheticEvent) {
this.clearVisibleDelayTimeout()
if (delay > 0) {
e.persist()
this.visibleTimeoutId = window.setTimeout(() => {
this.setVisible(visible, e)
this.clearVisibleDelayTimeout()
}, delay)
} else {
this.setVisible(visible, e)
}
}
clearVisibleDelayTimeout() {
if (this.visibleTimeoutId) {
clearTimeout(this.visibleTimeoutId)
this.visibleTimeoutId = null
}
}
hideByChild = () => {
// @todo to-remove untouchable
// 对于子孙节点是应该跳过的判断,在调用 hideByChild 之前已经进行过,且按职责考虑,也不应该在此进行
// if (e && ifNodeContains(this.elemPopperWrap as Element, e.target as Node)) {
// return
// }
this.setVisible(false)
this.hideParent()
}
hideParent() {
if (this.props.shouldHideParent) {
safeInvoke(this.context.hideByChild)
}
}
/**
* @public
*/
setVisible(visible: boolean, e?: Event | SyntheticEvent) {
this.clearVisibleDelayTimeout()
const beforeChangeResult = safeInvoke(this.props.beforeChange, visible)
if (beforeChangeResult === false) {
return
}
const change = () => {
if (isUndefined(this.props.visible)) {
this.setState({
visible,
})
}
safeInvoke(this.props.onChange, visible, e)
}
if (beforeChangeResult === true || beforeChangeResult === undefined) {
change()
// 同步的 beforeChange 情况下保持同步,以保证执行顺序符合预期
// 不应转成异步
// beforeChangeResult = Promise.resolve()
}
if (isPromiseLike(beforeChangeResult)) {
const syntheticEvent = (e as SyntheticEvent)
if (syntheticEvent && typeof syntheticEvent.persist === 'function') {
syntheticEvent.persist()
}
beforeChangeResult.then(change).catch(error => {
isDev() && console.log(`[Target] beforeChange rejected: ${String(error)}`)
})
}
}
setTarget(target: HTMLElement) {
this.setState({
target,
})
}
getEventDelay(eventType: TriggerEventType | PopperEventType) {
const props = this.props
const delay = eventType && {
'onBlur': props.blurDelay,
'onFocus': props.focusDelay,
'onMouseLeave': Math.max(
props.mouseLeaveDelay || 0,
POPPER_TRIGGER_MINIMUM_MOUSE_LEAVE_DELAY
),
'onMouseEnter': props.mouseEnterDelay,
'onClick': 0,
}[eventType] || 0
return Math.max(0, delay)
}
invokeTargetEventHandler(
type: TriggerEventType,
e: MouseEvent | FocusEvent,
nextVisible = !this.state.visible
) {
const childTarget = this.childTarget
const result = childTarget
&& safeInvoke(childTarget.props[type] as unknown as (e: MouseEvent | FocusEvent) => void, e)
this.delayToggleVisible(e, this.getEventDelay(type), nextVisible)
return result
}
invokePopperEventHandler(type: PopperEventType, e: MouseEvent) {
const childPopper = this.childPopper
childPopper && safeInvoke(childPopper.props[type], e)
}
delayToggleVisible(
e: MouseEvent | FocusEvent,
delay: number,
nextVisible: boolean
) {
this.clearVisibleDelayTimeout()
if (isUndefined(this.props.target)) {
this.setTarget(e.currentTarget as HTMLElement)
}
if (this.state.visible !== nextVisible) {
this.delaySetVisible(nextVisible, delay, e)
}
}
componentWillUnmount(): void {
this.clearClickOutsideHandler()
}
componentDidMount(): void {
if (this.nodeTarget instanceof HTMLElement) {
this.setTarget(this.nodeTarget)
}
this.componentDidUpdate()
}
componentDidUpdate(): void {
this.updateClickOutsideHandler()
}
render() {
const children = this.childrenArray.map(child => {
return child && isPopper(child)
? cloneElement(child, this.popperInjectProps)
: cloneElement(child, this.targetInjectProps)
})
return (
{children}
)
}
}