import m from 'mithril'; import classnames from 'classnames'; import PopperJS, { Boundary } from 'popper.js'; import { Classes, IAttrs, Style, safeCall, getClosest, elementIsOrContains } from '../../_shared'; import { AbstractComponent } from '../abstract-component'; import { IOverlayableAttrs, Overlay } from '../overlay'; import { PopoverInteraction, PopoverPosition } from './popoverTypes'; export interface IPopoverAttrs extends IOverlayableAttrs, IAttrs { /** * Set the bounding box. * see Here for more details * @default 'window' */ boundariesEl?: Boundary | Element; /** Close the popover on inner content click */ closeOnContentClick?: boolean; /** Inner content */ content: m.Children; /** Initial open when in uncontrolled mode */ defaultIsOpen?: boolean; /** * Toggles arrow visiblity * @default true */ hasArrow?: boolean; /** * Duration of close delay on hover interaction * @default 100 */ hoverCloseDelay?: number; /** * Duration of open delay on hover interaction * @default 0 */ hoverOpenDelay?: number; /** * Trigger interaction to toggle visiblity * @default 'click' */ interactionType?: PopoverInteraction; /** * Toggles visibility; * Specifying this attr will place the Popover in controlled mode * and will invoke the `onInteraction` callback for each open/close state change */ isOpen?: boolean; /** * Options to pass to the PopperJS instance; * see HERE for more details */ modifiers?: PopperJS.Modifiers; /** * Position relative to trigger element * @default 'bottom' */ position?: PopoverPosition; /** Callback invoked in controlled mode when a popover action will modify the open state */ onInteraction?: (nextOpenState: boolean, e: Event) => void; /** * Toggles visibilty when trigger is keyboard focused; * Only works when interactionType is hover or hover-trigger */ openOnTriggerFocus?: boolean; /** Overlay HTML container class */ overlayClass?: string; /** Overlay HTML container styles */ overlayStyle?: Style; /** Trigger element */ trigger: m.Vnode; /** * Class added to trigger element on interaction * @default 'cui-active' */ triggerActiveClass?: string; } export interface IPopoverTriggerAttrs extends IAttrs { onclick?(e: Event): void; onmouseenter?(e: MouseEvent): void; onmouseleave?(e: MouseEvent): void; onfocus?(e: Event): void; onblur?(e: Event): void; [htmlAttrs: string]: any; } export class Popover extends AbstractComponent { private isOpen: boolean; private popper?: PopperJS & { options?: PopperJS.PopperOptions }; private trigger: m.VnodeDOM; public getDefaultAttrs() { return { boundariesEl: 'window', restoreFocus: false, hasBackdrop: false, hoverCloseDelay: 100, hoverOpenDelay: 0, interactionType: 'click', position: 'bottom', hasArrow: true, triggerActiveClass: Classes.ACTIVE } as IPopoverAttrs; } public oninit(vnode: m.Vnode) { super.oninit(vnode); const { isOpen, defaultIsOpen } = this.attrs; this.isOpen = isOpen != null ? isOpen : defaultIsOpen != null ? defaultIsOpen : false; } public onbeforeupdate(vnode: m.Vnode, old: m.VnodeDOM) { super.onbeforeupdate(vnode, old); const isOpen = vnode.attrs.isOpen; const wasOpen = old.attrs.isOpen; if (isOpen && !wasOpen) { this.isOpen = true; } else if (!isOpen && wasOpen) { this.isOpen = false; } } public onupdate() { if (this.popper) { this.popper.options.placement = this.attrs.position as PopperJS.Placement; this.popper.scheduleUpdate(); } } public onremove() { this.destroyPopper(); } public view() { const { class: className, style, content, hasArrow, trigger, interactionType, inline, backdropClass, overlayClass, overlayStyle } = this.attrs; this.trigger = trigger as m.VnodeDOM; this.setTriggerAttrs(); const innerContent = m('', { class: classnames(Classes.POPOVER, className), onclick: this.handlePopoverClick, onmouseenter: this.handleTriggerMouseEnter, onmouseleave: this.handleTriggerMouseLeave, style }, [ hasArrow && m(`.${Classes.POPOVER_ARROW}`), m(`.${Classes.POPOVER_CONTENT}`, content) ]); return m.fragment({}, [ this.trigger, m(Overlay, { restoreFocus: this.isClickInteraction(), ...this.attrs as IOverlayableAttrs, backdropClass: classnames(Classes.POPOVER_BACKDROP, backdropClass), class: overlayClass, closeOnOutsideClick: interactionType !== 'click-trigger', content: innerContent, inline, isOpen: this.isOpen, onClose: this.handleOverlayClose, onOpened: this.handleOpened, onClosed: this.handleClosed, style: overlayStyle }) ]); } private handleOpened = (contentEl: HTMLElement) => { if (!this.popper && contentEl) { const popoverEl = contentEl.querySelector(`.${Classes.POPOVER}`)!; this.createPopper(popoverEl as HTMLElement); safeCall(this.attrs.onOpened, contentEl); } }; private handleClosed = () => { this.destroyPopper(); safeCall(this.attrs.onClosed); }; private handleOverlayClose = (e: Event) => { const target = e.target as HTMLElement; const isTriggerClick = elementIsOrContains(this.trigger.dom as HTMLElement, target); if (!isTriggerClick || e instanceof KeyboardEvent) { this.isControlled ? this.handleInteraction(e) : this.isOpen = false; } }; private createPopper(el: HTMLElement) { const { position, hasArrow, boundariesEl, modifiers } = this.attrs; const options = { placement: position, modifiers: { arrow: { enabled: hasArrow, element: `.${Classes.POPOVER_ARROW}` }, offset: { enabled: hasArrow, fn: (data) => this.getContentOffset(data, el) }, preventOverflow: { enabled: true, boundariesElement: boundariesEl, padding: 0 }, ...modifiers } } as PopperJS.PopperOptions; this.popper = new PopperJS( this.trigger.dom, el, options ); } private destroyPopper() { if (this.popper) { this.popper.destroy(); this.popper = undefined; } } private setTriggerAttrs() { const isControlled = this.isControlled; if (!this.trigger.attrs) { this.trigger.attrs = {}; } const triggerAttrs = this.trigger.attrs; if (this.isOpen) { triggerAttrs.class = classnames( triggerAttrs.className || triggerAttrs.class, this.attrs.triggerActiveClass, Classes.POPOVER_TRIGGER_ACTIVE ); } else triggerAttrs.class = triggerAttrs.className || triggerAttrs.class || ''; const triggerEvents = { onmouseenter: triggerAttrs.onmouseenter, onmouseleave: triggerAttrs.onmouseleave, onfocus: triggerAttrs.onfocus, onblur: triggerAttrs.onblur, onclick: triggerAttrs.onclick }; if (this.isClickInteraction()) { triggerAttrs.onclick = (e: Event) => { isControlled ? this.handleInteraction(e) : this.handleTriggerClick(); safeCall(triggerEvents.onclick); }; } else { triggerAttrs.onmouseenter = (e: MouseEvent) => { isControlled ? this.handleInteraction(e) : this.handleTriggerMouseEnter(e); safeCall(triggerEvents.onmouseenter); }; triggerAttrs.onmouseleave = (e: MouseEvent) => { isControlled ? this.handleInteraction(e) : this.handleTriggerMouseLeave(e); safeCall(triggerEvents.onmouseleave); }; triggerAttrs.onfocus = (e: FocusEvent) => { isControlled ? this.handleInteraction(e) : this.handleTriggerFocus(e); safeCall(triggerEvents.onfocus); }; triggerAttrs.onblur = (e: FocusEvent) => { isControlled ? this.handleInteraction(e) : this.handleTriggerBlur(e); safeCall(triggerEvents.onblur); }; } } private handleInteraction(e: Event) { safeCall(this.attrs.onInteraction, !this.isOpen, e); } private handlePopoverClick = (e: Event) => { const target = e.target as HTMLElement; const hasDimiss = getClosest(target, `.${Classes.POPOVER_DISSMISS}`) != null; if (this.attrs.closeOnContentClick || hasDimiss) { this.isControlled ? this.handleInteraction(e) : this.isOpen = false; } else (e as any).redraw = false; }; private handleTriggerClick() { this.isOpen = !this.isOpen; } private handleTriggerFocus(e: FocusEvent) { if (this.attrs.openOnTriggerFocus) { this.handleTriggerMouseEnter(e as any); } else (e as any).redraw = false; } private handleTriggerBlur(e: FocusEvent) { if (this.attrs.openOnTriggerFocus) { this.handleTriggerMouseLeave(e as any); } else (e as any).redraw = false; } private handleTriggerMouseEnter = (e: MouseEvent) => { const { hoverOpenDelay, interactionType } = this.attrs; if (interactionType !== 'hover-trigger') { this.clearTimeouts(); } if (!this.isOpen && this.isHoverInteraction()) { if (hoverOpenDelay! > 0) { this.setTimeout(() => { this.isOpen = true; m.redraw(); }, hoverOpenDelay); } else { this.isOpen = true; m.redraw(); } } (e as any).redraw = false; }; private handleTriggerMouseLeave = (e: MouseEvent) => { const { hoverCloseDelay } = this.attrs; this.clearTimeouts(); if (this.isOpen && this.isHoverInteraction()) { if (hoverCloseDelay! > 0) { this.setTimeout(() => { this.isOpen = false; m.redraw(); }, hoverCloseDelay); } else { this.isOpen = false; m.redraw(); } } (e as any).redraw = false; }; private isHoverInteraction() { const interactionType = this.attrs.interactionType; return interactionType === 'hover' || interactionType === 'hover-trigger'; } private isClickInteraction() { const interactionType = this.attrs.interactionType; return interactionType === 'click' || interactionType === 'click-trigger'; } private get isControlled() { return this.attrs.isOpen != null; } private getContentOffset = (data: PopperJS.Data, containerEl: HTMLElement) => { if (!this.attrs.hasArrow) { return data; } const placement = data.placement; const isHorizontal = placement.includes('left') || placement.includes('right'); const position = isHorizontal ? 'left' : 'top'; const arrowSize = (containerEl.children[0] as HTMLElement).clientHeight + 1; const offset = placement.includes('top') || placement.includes('left') ? -arrowSize : arrowSize; data.offsets.popper[position] += offset; return data; }; }