import classnames from 'classnames';
import m from 'mithril';
import { Classes, IAttrs, safeCall, Keys, getScrollbarWidth, hasScrollbar } from '../../_shared';
import { AbstractComponent } from '../abstract-component';
import { Portal, IPortalAttrs } from '../portal';
import { TransitionManager } from '../../utils';
export interface IOverlayableAttrs {
/** Class added to backdrop element */
backdropClass?: string;
/**
* Whether component can be closed on outer click.
* Triggers the onClose attribute when true
*/
closeOnOutsideClick?: boolean;
/**
* Whether component can be closed on Escape key.
* Triggers the onClose attribute when true
* @default true
*/
closeOnEscapeKey?: boolean;
/** Whether to show backdrop element */
hasBackdrop?: boolean;
/** Renders component relative to parent container */
inline?: boolean;
/**
* Callback invoked on initial close
* Passes back event that triggered close
*/
onClose?: (e: Event) => void;
/** Callback invoked after transition is complete and component is unmounted */
onClosed?: () => void;
/**
* Callback invoked when component mounts and transition is complete
* Passes back DOM element container
*/
onOpened?: (contentEl: HTMLElement) => void;
/** Sets focus to first element that has a autofocus or tabindex attribute */
autofocus?: boolean;
/** Wether last active element should be focused on close */
restoreFocus?: boolean;
/**
* Wether overlay should be added to the "open" stack.
* When true, overlays will be stacked on top of one another
* and will close in sequence.
*/
addToStack?: boolean;
/** Attrs passed through to the Portal component */
portalAttrs?: IPortalAttrs;
/**
* Name of transition. The name is used to apply CSS transition classes on open and close.
* On open, ${name}-enter and ${name}-enter-active are added. On close, ${name}-exit
* and ${name}-exit-active are added.
* @default 'fade'
*/
transitionName?: string;
/**
* Duration of the animation. Note: the CSS transition duration must match the
* custom duration passed to this component
* @default 200
*/
transitionDuration?: number;
}
export interface IOverlayAttrs extends IOverlayableAttrs, IAttrs {
/** Inner content */
content?: m.Children;
/** Toggles overlay visibility */
isOpen?: boolean;
}
let instanceCounter = 0;
export class Overlay extends AbstractComponent {
private id: number = instanceCounter++;
private shouldRender: boolean = false;
private contentEl: HTMLElement;
private lastActiveElement: HTMLElement;
private static openStack: number[] = [];
private static getLastOpened = () => Overlay.openStack[Overlay.openStack.length - 1];
public getDefaultAttrs() {
return {
closeOnEscapeKey: true,
closeOnOutsideClick: true,
hasBackdrop: true,
addToStack: true,
transitionName: 'fade',
transitionDuration: TransitionManager.isEnabled ? 200 : 0
};
}
public oninit(vnode: m.Vnode) {
super.oninit(vnode);
this.shouldRender = !!vnode.attrs.isOpen;
}
public onbeforeupdate(vnode: m.Vnode, old: m.VnodeDOM) {
super.onbeforeupdate(vnode, old);
const { isOpen, transitionDuration } = vnode.attrs;
const wasOpen = old.attrs.isOpen;
if (isOpen && !wasOpen) {
this.clearTimeouts();
this.shouldRender = true;
} else if (!isOpen && wasOpen) {
if (transitionDuration! > 0) {
this.handleClose();
this.setTimeout(() => {
this.shouldRender = false;
m.redraw();
this.handleClosed();
}, transitionDuration);
} else {
this.shouldRender = false;
this.handleClose();
this.handleClosed();
}
}
}
public onremove() {
if (this.shouldRender === true) {
this.handleClose();
this.handleClosed();
this.shouldRender = false;
}
}
public view() {
const {
backdropClass,
hasBackdrop,
content,
inline,
class: className,
style,
portalAttrs
} = this.attrs;
if (!this.shouldRender) {
return null;
}
const innerContent = [
hasBackdrop && m('', {
class: classnames(Classes.OVERLAY_BACKDROP, backdropClass),
onmousedown: this.handleBackdropMouseDown,
tabindex: 0
}),
content
];
const classes = classnames(
Classes.OVERLAY,
inline && Classes.OVERLAY_INLINE,
className
);
const container = m('', {
class: classes,
style,
oncreate: this.onContainerCreate,
onupdate: this.onContainerUpdate
}, innerContent);
return inline ? container : m(Portal, { ...portalAttrs }, container);
}
private onContainerCreate = ({ dom }: m.VnodeDOM) => {
if (this.shouldRender) {
this.handleOpen(dom as HTMLElement);
}
};
private onContainerUpdate = ({ dom }: m.VnodeDOM) => {
const isOpen = this.attrs.isOpen;
const wasOpen = this.prevAttrs.isOpen;
if (isOpen && !wasOpen) {
this.handleOpen(dom as HTMLElement);
} else if (!isOpen && wasOpen) {
this.handleClose();
}
};
private handleOpen(contentEl: HTMLElement) {
const {
addToStack,
closeOnOutsideClick,
closeOnEscapeKey,
hasBackdrop,
onOpened,
inline
} = this.attrs;
this.contentEl = contentEl;
if (addToStack) {
Overlay.openStack.push(this.id);
}
if (closeOnOutsideClick && !hasBackdrop) {
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
if (closeOnEscapeKey) {
document.addEventListener('keydown', this.handleKeyDown);
}
this.handleEnterTransition();
if (hasBackdrop && !inline) {
document.body.classList.add(Classes.OVERLAY_OPEN);
const bodyHasScrollbar = hasScrollbar(document.body);
if (bodyHasScrollbar) {
document.body.style.paddingRight = `${getScrollbarWidth()}px`;
}
}
safeCall(onOpened, contentEl);
this.handleFocus();
}
private handleClose() {
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
document.removeEventListener('keydown', this.handleKeyDown);
this.handleExitTransition();
}
private handleClosed() {
const { restoreFocus, onClosed, hasBackdrop, inline } = this.attrs;
if (this.attrs.addToStack) {
Overlay.openStack = Overlay.openStack.filter(id => id !== this.id);
}
if (this.lastActiveElement && restoreFocus) {
window.requestAnimationFrame(() => this.lastActiveElement.focus());
}
if (hasBackdrop && !inline) {
document.body.classList.remove(Classes.OVERLAY_OPEN);
document.body.style.paddingRight = '';
}
safeCall(onClosed);
}
private handleEnterTransition() {
const { transitionName, transitionDuration } = this.attrs;
const el = this.contentEl;
if (el == null || transitionDuration === 0) return;
el.classList.remove(`${transitionName}-exit`);
el.classList.remove(`${transitionName}-exit-active`);
el.classList.add(`${transitionName}-enter`);
el.scrollTop;
el.classList.add(`${transitionName}-enter-active`);
}
private handleExitTransition() {
const { transitionDuration, transitionName } = this.attrs;
const el = this.contentEl;
if (el == null || transitionDuration === 0) return;
el.classList.remove(`${transitionName}-enter`);
el.classList.remove(`${transitionName}-enter-active`);
el.classList.add(`${transitionName}-exit`);
el.scrollTop;
el.classList.add(`${transitionName}-exit-active`);
}
private handleFocus() {
this.lastActiveElement = document.activeElement as HTMLElement;
const contentEl = this.contentEl;
const { isOpen, autofocus } = this.attrs;
if (!contentEl || !document.activeElement || !isOpen || !autofocus) {
return;
}
window.requestAnimationFrame(() => {
const isFocusOutsideOverlay = !contentEl.contains(document.activeElement);
if (isFocusOutsideOverlay) {
const autofocusEl = contentEl.querySelector('[autofocus]') as HTMLElement;
const tabIndexEl = contentEl.querySelector('[tabindex]') as HTMLElement;
if (autofocusEl) {
autofocusEl.focus();
} else if (tabIndexEl) {
tabIndexEl.focus();
}
}
});
}
private handleBackdropMouseDown = (e: Event) => {
const { closeOnOutsideClick, onClose } = this.attrs;
if (closeOnOutsideClick) {
safeCall(onClose, e);
} else (e as any).redraw = false;
};
private handleDocumentMouseDown = (e: MouseEvent) => {
const { isOpen, onClose, closeOnOutsideClick } = this.attrs;
const contentEl = this.contentEl;
const isClickOnOverlay = contentEl && contentEl.contains(e.target as HTMLElement);
if (isOpen && closeOnOutsideClick && !isClickOnOverlay && this.lastOpened) {
safeCall(onClose, e);
m.redraw();
}
};
private handleKeyDown = (e: KeyboardEvent) => {
const { closeOnEscapeKey, onClose } = this.attrs;
if (e.which === Keys.ESCAPE && closeOnEscapeKey && this.lastOpened) {
safeCall(onClose, e);
e.preventDefault();
m.redraw();
}
};
private get lastOpened() {
return this.attrs.addToStack ? Overlay.getLastOpened() === this.id : true;
}
}