@paperbits/core
Version:
Paperbits core components.
623 lines (504 loc) • 27.8 kB
text/typescript
import * as ko from "knockout";
import * as Html from "@paperbits/common/html";
import * as Utils from "@paperbits/common/utils";
import { Keys } from "@paperbits/common/keyboard";
import { View } from "@paperbits/common/ui";
import { BalloonOptions, BalloonActivationMethod, BalloonHandle, BalloonState } from "@paperbits/common/ui/balloons";
import { ViewStack } from "@paperbits/common/ui/viewStack";
import { Events } from "@paperbits/common/events";
const balloonHandlesProp = "balloonHandles";
const activeBalloonProp = "activeBalloon";
const balloonTopClass = "balloon-top";
const balloonBottomClass = "balloon-bottom";
const balloonLeftClass = "balloon-left";
const balloonRightClass = "balloon-right";
const balloonClass = "balloon";
const activeBalloonClass = "balloon-is-active";
const balloonTipClass = "balloon-tip";
enum Position {
left = "left",
right = "right",
top = "top",
bottom = "bottom"
}
enum Direction {
vertical = "vertical",
horizontal = "horizontal",
}
export class BalloonBindingHandler {
constructor(viewStack: ViewStack) {
ko.bindingHandlers["balloon"] = {
init: (toggleElement: HTMLElement, valueAccessor: () => BalloonOptions) => {
const options = ko.unwrap(valueAccessor());
const activateOn = options.activateOn || BalloonActivationMethod.clickOrKeyDown;
const balloonHandles: BalloonHandle[] = toggleElement[balloonHandlesProp] || [];
if (!toggleElement[balloonHandlesProp]) {
toggleElement[balloonHandlesProp] = balloonHandles;
}
let inBalloon = false;
let isHoverOver = false;
let balloonView: View;
let balloonElement: HTMLElement;
let balloonTipElement: HTMLElement;
let closeTimeout;
let createBalloonElement: () => void;
const balloonElementId = Utils.randomClassName();
if (options.component) {
createBalloonElement = () => {
balloonElement = document.createElement("div");
balloonElement.id = balloonElementId;
balloonElement.classList.add(balloonClass);
ko.applyBindingsToNode(balloonElement, { component: options.component, dialog: {} }, null);
document.body.appendChild(balloonElement);
};
}
if (options.template) {
createBalloonElement = () => {
balloonElement = document.createElement("div");
balloonElement.classList.add(balloonClass);
ko.applyBindingsToNode(balloonElement, { template: options.template, dialog: {} }, null);
document.body.appendChild(balloonElement);
};
}
if (activateOn === BalloonActivationMethod.clickOrKeyDown) {
toggleElement.setAttribute(Html.AriaAttributes.expanded, "false");
toggleElement.setAttribute(Html.AriaAttributes.controls, balloonElementId);
}
const createBalloonTip = (): void => {
balloonTipElement = document.createElement("div");
balloonTipElement.classList.add(balloonTipClass);
document.body.appendChild(balloonTipElement);
};
const removeBalloon = (): void => {
balloonHandle.balloonState = BalloonState.closed;
if (!balloonElement) {
return;
}
delete toggleElement[activeBalloonProp];
ko.cleanNode(balloonElement);
balloonElement.remove();
balloonElement = null;
balloonTipElement.remove();
balloonTipElement = null;
viewStack.removeView(balloonView);
};
const resetCloseTimeout = () => {
if (options.closeTimeout) {
clearTimeout(closeTimeout);
balloonHandle.balloonState = BalloonState.closing;
closeTimeout = setTimeout(closeBalloon, options.closeTimeout);
}
};
const updatePosition = async (): Promise<void> => {
if (!balloonElement || !BalloonState.open) {
return;
}
const preferredPosition = options.position;
const preferredDirection = preferredPosition === Position.left || preferredPosition === Position.right
? Direction.horizontal
: Direction.vertical;
const triggerRect = toggleElement.getBoundingClientRect();
const balloonRect = balloonElement.getBoundingClientRect();
const spaceTop = triggerRect.top;
const spaceBottom = window.innerHeight - triggerRect.bottom;
const spaceLeft = triggerRect.left;
const spaceRight = window.innerWidth - triggerRect.height;
const balloonTipSize = 10;
const egdeGap = 10;
const padding = 10;
let balloonLeft: number;
let balloonRight: number;
let balloonTop: number;
let balloonBottom: number;
let selectedPosition: string;
let positionX: string;
let positionY: string;
let availableSpaceX: number;
let availableSpaceY: number;
let balloonHeight: number = balloonRect.height;
let balloonWidth: number = balloonRect.width;
let balloonTipX: number;
let balloonTipY: number;
if (preferredDirection === Direction.vertical) {
if (spaceTop > spaceBottom) {
positionY = Position.top;
availableSpaceY = spaceTop - egdeGap - padding;
}
else {
positionY = Position.bottom;
availableSpaceY = spaceBottom - egdeGap - padding;
}
}
else {
if (spaceLeft > spaceRight) {
positionX = Position.left;
availableSpaceX = spaceLeft - egdeGap;
}
else {
positionX = Position.right;
availableSpaceX = spaceRight - egdeGap;
}
}
if (balloonRect.height > availableSpaceY) {
balloonHeight = availableSpaceY;
}
if (balloonRect.width > availableSpaceX) {
balloonWidth = availableSpaceX;
}
switch (positionY) {
case Position.top:
balloonTop = triggerRect.top;
if ((balloonTop - balloonHeight) < 0) {
positionY = Position.bottom;
}
break;
case Position.bottom:
balloonTop = triggerRect.top + triggerRect.height;
if (balloonTop + balloonHeight > window.innerHeight) {
positionY = Position.top;
}
break;
}
switch (positionX) {
case Position.left:
balloonLeft = triggerRect.left;
if ((balloonLeft - balloonWidth) < 0) {
positionX = Position.right;
}
break;
case Position.right:
balloonLeft = triggerRect.left + triggerRect.width;
if (balloonLeft + balloonWidth > window.innerWidth) {
positionX = Position.left;
}
break;
}
balloonTipElement.classList.remove(balloonTopClass);
balloonTipElement.classList.remove(balloonBottomClass);
balloonTipElement.classList.remove(balloonLeftClass);
balloonTipElement.classList.remove(balloonRightClass);
if (preferredDirection === Direction.vertical) {
switch (positionY) {
case Position.top:
balloonTop = triggerRect.top - balloonHeight - padding;
balloonLeft = triggerRect.left + Math.floor(triggerRect.width / 2) - Math.floor(balloonWidth / 2);
balloonTipX = triggerRect.left + Math.floor(triggerRect.width / 2) - Math.floor(balloonTipSize / 2);
balloonTipY = triggerRect.top - Math.floor(balloonTipSize / 2) - padding;
balloonTipElement.classList.add(balloonTopClass);
selectedPosition = Position.top;
break;
case Position.bottom:
balloonTop = triggerRect.top + triggerRect.height + padding;
balloonLeft = triggerRect.left + Math.floor(triggerRect.width / 2) - Math.floor(balloonWidth / 2);
balloonTipX = triggerRect.left + Math.floor(triggerRect.width / 2) - Math.floor(balloonTipSize / 2);
balloonTipY = triggerRect.bottom - Math.floor(balloonTipSize / 2) + padding;
balloonTipElement.classList.add(balloonBottomClass);
selectedPosition = Position.bottom;
break;
}
}
else {
switch (positionX) {
case Position.left:
balloonTop = triggerRect.top + Math.floor(triggerRect.height / 2) - Math.floor(balloonHeight / 2);
balloonLeft = triggerRect.left - balloonWidth - padding;
balloonTipX = triggerRect.left - Math.floor(balloonTipSize / 2) - padding;
balloonTipY = triggerRect.top + Math.floor(triggerRect.height / 2) - Math.floor(balloonTipSize / 2);
balloonTipElement.classList.add(balloonLeftClass);
selectedPosition = Position.left;
break;
case Position.right:
balloonTop = triggerRect.top + Math.floor(triggerRect.height / 2) - Math.floor(balloonHeight / 2);
balloonLeft = triggerRect.right + padding;
balloonTipX = triggerRect.right - Math.floor(balloonTipSize / 2) + padding;
balloonTipY = triggerRect.top + Math.floor(triggerRect.height / 2) - Math.floor(balloonTipSize / 2);
balloonTipElement.classList.add(balloonRightClass);
selectedPosition = Position.right;
break;
}
}
if (balloonTop < egdeGap) {
balloonTop = egdeGap;
}
if (balloonTop + balloonHeight > innerHeight - egdeGap) {
balloonBottom = egdeGap;
}
else {
balloonBottom = innerHeight - (balloonTop + balloonHeight);
}
if (balloonLeft < egdeGap) {
balloonLeft = egdeGap;
}
delete balloonElement.style.top;
delete balloonElement.style.bottom;
delete balloonElement.style.left;
delete balloonElement.style.right;
if (balloonTop + balloonHeight > window.innerHeight) {
const overflowCorrection = (balloonTop + balloonHeight) - window.innerHeight;
balloonTop = balloonTop - overflowCorrection;
}
switch (selectedPosition) {
case Position.top:
balloonElement.style.bottom = `${balloonBottom}px`;
balloonElement.style.left = `${balloonLeft}px`;
break;
case Position.bottom:
balloonElement.style.top = `${balloonTop}px`;
balloonElement.style.left = `${balloonLeft}px`;
break;
case Position.left:
balloonElement.style.top = `${balloonTop}px`;
balloonElement.style.height = `${balloonHeight}px`;
// balloonElement.style.right = `${balloonRight}px`; // TODO: Make it work
balloonElement.style.left = `${balloonLeft}px`;
break;
case Position.right:
balloonElement.style.top = `${balloonTop}px`;
balloonElement.style.height = `${balloonHeight}px`;
balloonElement.style.left = `${balloonLeft}px`;
break;
}
balloonElement.style.maxHeight = availableSpaceY + "px";
balloonElement.style.maxWidth = availableSpaceX + "px";
balloonTipElement.style.top = `${balloonTipY}px`;
balloonTipElement.style.left = `${balloonTipX}px`;
};
const openBalloon = (returnFocusTo?: HTMLElement): void => {
if (options.isDisabled && options.isDisabled()) {
return;
}
resetCloseTimeout();
const existingBalloonHandle: BalloonHandle = toggleElement[activeBalloonProp];
if (existingBalloonHandle) {
if (existingBalloonHandle.balloonState == BalloonState.opening || existingBalloonHandle.balloonState == BalloonState.open) {
return;
}
if (existingBalloonHandle.activateOn === BalloonActivationMethod.hoverOrFocus) {
existingBalloonHandle.close();
}
else {
return;
}
}
setImmediate(() => { // give chance to view stack to clear unrelated views
if (!document.contains(toggleElement)) {
return;
}
const activeBalloonHandle: BalloonHandle = toggleElement[activeBalloonProp];
if (activeBalloonHandle) {
if (activateOn === BalloonActivationMethod.hoverOrFocus) {
return;
}
else {
activeBalloonHandle.close();
}
}
createBalloonElement();
createBalloonTip();
balloonView = {
close: closeBalloon,
element: balloonElement,
returnFocusTo: returnFocusTo,
hitTest: (targetElement) => {
const element =
Html.closest(targetElement, x => x === balloonElement) ||
Html.closest(targetElement, x => x === toggleElement);
return !!element;
}
};
viewStack.runHitTest(toggleElement);
viewStack.pushView(balloonView);
if (activateOn === BalloonActivationMethod.clickOrKeyDown) {
toggleElement.setAttribute(Html.AriaAttributes.expanded, "true");
}
toggleElement[activeBalloonProp] = balloonHandle;
balloonElement.classList.add(activeBalloonClass);
requestAnimationFrame(updatePosition);
balloonHandle.balloonState = BalloonState.open;
if (options.onOpen) {
options.onOpen();
}
if (activateOn === BalloonActivationMethod.hoverOrFocus) {
balloonElement.addEventListener(Events.MouseEnter, () => {
inBalloon = true;
});
balloonElement.addEventListener(Events.MouseLeave, () => {
inBalloon = false;
checkCloseHoverBalloon();
});
}
});
};
const closeBalloon = (): void => {
if (!balloonElement) {
return;
}
if (options.onClose) {
options.onClose();
}
removeBalloon();
if (activateOn === BalloonActivationMethod.clickOrKeyDown) {
toggleElement.setAttribute(Html.AriaAttributes.expanded, "false");
}
};
const toggle = (): void => {
resetCloseTimeout();
const existingBalloonHandle: BalloonHandle = toggleElement[activeBalloonProp];
if (existingBalloonHandle && balloonHandle !== existingBalloonHandle) {
existingBalloonHandle.close();
}
if (balloonHandle.balloonState === BalloonState.open) {
if (!options.closeTimeout) {
balloonHandle.balloonState = BalloonState.closing;
closeBalloon();
}
}
else {
balloonHandle.balloonState = BalloonState.opening;
openBalloon(toggleElement);
}
};
const balloonHandle: BalloonHandle = {
open: openBalloon,
close: closeBalloon,
toggle: toggle,
updatePosition: () => requestAnimationFrame(updatePosition),
activateOn: activateOn,
balloonState: BalloonState.closed
};
if (balloonHandles.some(x => x.activateOn === activateOn)) {
throw new Error(`The element already has balloon with activation method "${activateOn}".`);
}
balloonHandles.push(balloonHandle);
if (options.onCreated) {
options.onCreated(balloonHandle);
}
const getBaloonHandleFor = (...methods: BalloonActivationMethod[]): BalloonHandle => {
const balloonHandles: BalloonHandle[] = toggleElement[balloonHandlesProp];
const handle = methods.map(x => balloonHandles.find(y => y.activateOn === x)).find(x => !!x);
return handle;
};
const onPointerDown = async (event: MouseEvent): Promise<void> => {
if (!toggleElement) {
return;
}
const targetElement = <HTMLElement>event.target;
const element = Html.closest(targetElement, (node) => node === toggleElement);
if (!element) {
return;
}
const handle = getBaloonHandleFor(BalloonActivationMethod.clickOrKeyDown);
if (handle) {
handle.toggle();
}
};
const onFocus = (): void => {
const existingBalloonHandle: BalloonHandle = toggleElement[activeBalloonProp];
if (existingBalloonHandle) {
return;
}
openBalloon();
};
const onBlur = (): void => {
closeBalloon();
};
const onMouseEnter = (event: MouseEvent): void => {
const handle = getBaloonHandleFor(BalloonActivationMethod.hoverOrFocus, BalloonActivationMethod.all);
if (!handle) {
return;
}
isHoverOver = true;
balloonHandle.balloonState = BalloonState.opening;
setTimeout(() => {
if (!isHoverOver) {
return;
}
openBalloon();
}, options.delay || 0);
};
const onMouseLeave = (event: MouseEvent): void => {
isHoverOver = false;
checkCloseHoverBalloon();
};
const checkCloseHoverBalloon = (): void => {
setTimeout(() => {
if (!isHoverOver && !inBalloon) {
balloonHandle.balloonState = BalloonState.closing;
closeBalloon();
}
}, 50);
};
const onKeyDown = (event: KeyboardEvent): void => {
switch (event.key) {
case Keys.Enter:
case Keys.Space:
event.preventDefault();
const handle = getBaloonHandleFor(BalloonActivationMethod.clickOrKeyDown, BalloonActivationMethod.all);
if (handle) {
handle.toggle();
}
break;
}
};
const onClick = (event: MouseEvent): void => {
event.preventDefault();
};
const onScroll = async (event: MouseEvent): Promise<void> => {
if (!balloonElement) {
return;
}
requestAnimationFrame(updatePosition);
};
if (options.closeOn) {
options.closeOn.subscribe(() => closeBalloon());
}
toggleElement.addEventListener(Events.Click, onClick);
window.addEventListener(Events.Scroll, onScroll, true);
switch (activateOn) {
case BalloonActivationMethod.clickOrKeyDown:
toggleElement.addEventListener(Events.KeyDown, onKeyDown);
document.addEventListener(Events.MouseDown, onPointerDown, true);
break;
case BalloonActivationMethod.hoverOrFocus:
toggleElement.addEventListener(Events.MouseEnter, onMouseEnter);
toggleElement.addEventListener(Events.MouseLeave, onMouseLeave);
toggleElement.addEventListener(Events.Focus, onFocus);
toggleElement.addEventListener(Events.Blur, onBlur);
break;
case BalloonActivationMethod.all:
toggleElement.addEventListener(Events.KeyDown, onKeyDown);
toggleElement.addEventListener(Events.MouseEnter, onMouseEnter);
toggleElement.addEventListener(Events.MouseLeave, onMouseLeave);
// toggleElement.addEventListener(Events.Focus, onFocus);
// toggleElement.addEventListener(Events.Blur, onBlur);
break;
default:
throw new Error(`Unknown balloon trigger event: ${activateOn}`);
}
ko.utils.domNodeDisposal.addDisposeCallback(toggleElement, () => {
balloonHandle.balloonState = BalloonState.closing;
window.removeEventListener(Events.MouseDown, onPointerDown, true);
toggleElement.removeEventListener(Events.Click, onClick);
switch (activateOn) {
case BalloonActivationMethod.clickOrKeyDown:
toggleElement.removeEventListener(Events.KeyDown, onKeyDown);
break;
case BalloonActivationMethod.hoverOrFocus:
toggleElement.removeEventListener(Events.MouseEnter, onMouseEnter);
toggleElement.removeEventListener(Events.MouseLeave, onMouseLeave);
break;
case BalloonActivationMethod.all:
toggleElement.removeEventListener(Events.KeyDown, onKeyDown);
toggleElement.removeEventListener(Events.MouseEnter, onMouseEnter);
toggleElement.removeEventListener(Events.MouseLeave, onMouseLeave);
break;
default:
throw new Error(`Unknown balloon trigger event: ${activateOn}`);
}
removeBalloon();
window.removeEventListener(Events.Scroll, onScroll, true);
});
}
};
}
}