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;
};
}