Skip to content

Commit

Permalink
feat(tooltip): refactor tooltip to take in new attributes
Browse files Browse the repository at this point in the history
introduce new attributes and auto placement, mark some attributes as deprecated

fix #89
  • Loading branch information
Chang authored and Chang committed Dec 13, 2019
1 parent 1937816 commit 5445516
Show file tree
Hide file tree
Showing 6 changed files with 576 additions and 338 deletions.
6 changes: 3 additions & 3 deletions develop/components/pages/TooltipPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ const docMD: string = require("../../../src/Tooltip/readme.md");
const moneySVG: JSX.Element = <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170 170"><title>regular_black</title><path d="M137.5,102.1V40.4a3,3,0,0,0-3-3H8a3,3,0,0,0-3,3v61.7a3,3,0,0,0,3,3H134.5A3,3,0,0,0,137.5,102.1ZM112,91.3v7.7H30.5V91.3a3,3,0,0,0-3-3,6.1,6.1,0,0,1-6.1-6.1,3,3,0,0,0-3-3H11V63h7.5a3,3,0,0,0,3-3,6.1,6.1,0,0,1,6.1-6.1,3,3,0,0,0,3-3V43.4H112v7.5a3,3,0,0,0,3,3A6.1,6.1,0,0,1,121,60a3,3,0,0,0,3,3h7.5V79.3H124a3,3,0,0,0-3,3,6.1,6.1,0,0,1-6.1,6.1A3,3,0,0,0,112,91.3ZM131.5,57h-4.9a12.1,12.1,0,0,0-8.7-8.7V43.4h13.6ZM24.5,43.4v4.9A12.1,12.1,0,0,0,15.9,57H11V43.4ZM11,85.3h4.9A12.1,12.1,0,0,0,24.5,94v5.1H11ZM118,99.1V94a12.1,12.1,0,0,0,8.7-8.7h4.9V99.1Z" /><path d="M151.3,115.8V54.2h-6v58.7H21.7v6H148.3A3,3,0,0,0,151.3,115.8Z" /><path d="M159,67.9v58.7H35.5v6H162a3,3,0,0,0,3-3V67.9Z" /><path d="M71.3,88.8A17.5,17.5,0,1,1,88.8,71.3,17.5,17.5,0,0,1,71.3,88.8Zm0-29A11.5,11.5,0,1,0,82.8,71.3,11.5,11.5,0,0,0,71.3,59.8Z" /></svg>;

const TooltipPage: React.FunctionComponent = () => {
let myTooltip: Tooltip;
let myTooltip: typeof Tooltip;

function dismissTooltip(e?: React.MouseEvent<HTMLDivElement>): void {
const dismissableTooltip: HTMLElement = document.getElementById("dismissable-tooltip");
if (event.target !== dismissableTooltip.firstChild) {
myTooltip.forceDismiss(e);
console.log(myTooltip);
}
}

Expand Down Expand Up @@ -44,7 +44,7 @@ const TooltipPage: React.FunctionComponent = () => {
width={200}
customSvg={moneySVG}
id="dismissable-tooltip"
ref={(el: Tooltip) => { myTooltip = el; }}
ref={(el: typeof Tooltip) => { myTooltip = el; }}
/>
</div>

Expand Down
3 changes: 2 additions & 1 deletion develop/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"noImplicitReturns": false,
"noImplicitThis": false,
"noImplicitAny": false,
"strictNullChecks": false
"strictNullChecks": false,
"allowSyntheticDefaultImports": true
},
"exclude": [
"**/*.test.tsx"
Expand Down
273 changes: 202 additions & 71 deletions src/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as React from "react";
import "./tooltip-style.scss";
import { TooltipPositionChecker } from "./placement";
import { randomId } from "../__utils/randomId";
import ReactDOM from "react-dom";
import debounce from "lodash/debounce";

const InfoCircleIcon: JSX.Element = <svg name="info-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 40c118.621 0 216 96.075 216 216 0 119.291-96.61 216-216 216-119.244 0-216-96.562-216-216 0-119.203 96.602-216 216-216m0-32C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm-36 344h12V232h-12c-6.627 0-12-5.373-12-12v-8c0-6.627 5.373-12 12-12h48c6.627 0 12 5.373 12 12v140h12c6.627 0 12 5.373 12 12v8c0 6.627-5.373 12-12 12h-72c-6.627 0-12-5.373-12-12v-8c0-6.627 5.373-12 12-12zm36-240c-17.673 0-32 14.327-32 32s14.327 32 32 32 32-14.327 32-32-14.327-32-32-32z" /></svg>;

Expand All @@ -8,113 +12,240 @@ export interface TooltipMessageGroupItem {
message: string;
}

interface TooltipState {
toggle: boolean;
}

export type TooltipTrigger = "hover" | "click" | "focus";
export type TooltipTheme = "default" | "light" | "primary" | "warning" | "success" | "danger" | "purple";
export type TooltipPosition = "top" | "bottom" | "left" | "right" | "top-right" | "top-left" | "bottom-right" | "bottom-left" | "left-top" | "left-bottom" | "right-top" | "right-bottom";

export interface TooltipProps {
className?: string;
customSvg?: React.ReactNode;
id?: string;
ref?: React.LegacyRef<typeof Tooltip>;
/** @deprecated use content instead */
message?: string;
/** @deprecated use content instead */
messageGroup?: Array<TooltipMessageGroupItem>;
onClick?: (event?: React.MouseEvent<HTMLDivElement>) => void;
/** @deprecated use onVisibleChange instead */
onClick?: (event?: React.MouseEvent<HTMLDivElement> | React.FocusEvent<HTMLElement>) => void;
position?: TooltipPosition;
theme?: TooltipTheme;
/** @deprecated use content instead */
title?: string;
/** @deprecated use trigger instead */
triggerOnHover?: boolean;
/** @deprecated */
width?: number;
content?: string | React.ReactNode;
trigger?: TooltipTrigger;
disableAutoPosition?: boolean;
onVisibleChange?: (event: React.MouseEvent<HTMLDivElement> | React.FocusEvent<HTMLElement>, visible: boolean) => void;
children?: React.ReactNode;
}

export class Tooltip extends React.Component<TooltipProps, TooltipState> {
constructor(props: TooltipProps) {
super(props);

this.state = {
toggle: false
export const Tooltip: React.FunctionComponent<TooltipProps> = (props: TooltipProps): React.ReactElement<void> => {
const containerRef: React.MutableRefObject<HTMLDivElement> = React.useRef(null);
const [id, setId] = React.useState<string>("");
const [visible, setVisible] = React.useState<boolean>(false);
const [tooltipContainer, setTooltipContainer] = React.useState<HTMLDivElement>(null);
const tooltipRootClassName: string = "tooltip-root-container";
const [tooltipPositionChecker, setTooltipPositionChecker] = React.useState<TooltipPositionChecker>(null);

React.useEffect(() => {
const randID: string = constructId();
constructTooltipContentContainer(randID);
}, []);

React.useEffect(() => {
if (!!tooltipPositionChecker) {
tooltipPositionChecker.toggleAutoPlacement(props.disableAutoPosition);
}
const eventListener: string = props.disableAutoPosition ? "remove" : "add";
window[`${eventListener}EventListener`]("resize", debounce(getWithinViewportPosition, 500));
return function cleanup() {
window.removeEventListener("resize", debounce(getWithinViewportPosition, 500));
};
}, [props.disableAutoPosition]);

this.forceDismiss = this.forceDismiss.bind(this);
this.toggleTooltip = this.toggleTooltip.bind(this);
}
React.useEffect(() => {
if (!!tooltipContainer) {
tooltipPositionChecker.addTooltipContainer(tooltipContainer);
}
}, [tooltipContainer]);

React.useEffect(() => {
if (!!tooltipContainer) {
setTooltipContent(tooltipContainer);
}
}, [props.content, props.message, props.messageGroup]);

React.useEffect(() => {
if (!!tooltipContainer) {
const classNames: Array<string> = tooltipContainer.className.split(" ");
classNames[1] = props.theme;
tooltipContainer.className = classNames.join(" ");
}
}, [props.theme]);

/**
* Forces the tooltip to dismiss
* @deprecated
* @param {React.MouseEvent<HTMLDivElement>} e Mouse event
*/
forceDismiss(e?: React.MouseEvent<HTMLDivElement>) {
const forceDismiss = (e?: React.MouseEvent<HTMLDivElement>): void => {
if (e) {
switch ((e.target as HTMLElement).className) {
case "icon":
case "message":
case "message-container":
case "triangle":
return;
default: this.setState({ toggle: false });
default: onTooltipToggle(e, false);
}
} else {
this.setState({ toggle: false });
onTooltipToggle(null, false);
}
}
};

/** Forces the tooltip to show */
forceShow() {
!this.state.toggle && this.setState({ toggle: true });
}
/**
* Forces the tooltip to show
* @deprecated
*/
const forceShow = (): void => {
!visible && onTooltipToggle(null, true);
};

isPositioned(search: string): boolean {
const position: string = this.props.position ? this.props.position : "bottom";
return position.search(search) === 0;
}
/**
* get tooltip position
*/
const getWithinViewportPosition = (): void => {
if (tooltipPositionChecker) {
tooltipPositionChecker.getPosition((props.position || "top"));
}
};

toggleTooltip(state?: boolean, e?: React.MouseEvent<HTMLDivElement>) {
if (state !== undefined) {
this.setState({ toggle: state });
} else {
this.setState({ toggle: !this.state.toggle });
/**
* construct tooltip content container and append to root tooltip container
* @param tooltipId unique tooltip id
*/
const constructTooltipContentContainer = (tooltipId: string): void => {
const tooltipRootArr: HTMLCollectionOf<Element> = document.body.getElementsByClassName(tooltipRootClassName);
let tooltipRootRef: Element = tooltipRootArr && tooltipRootArr[0];
if (tooltipRootArr.length === 0) {
tooltipRootRef = document.createElement("div");
tooltipRootRef.className = tooltipRootClassName;
document.body.appendChild(tooltipRootRef);
}
const newTooltip: HTMLDivElement = document.createElement("div");
newTooltip.className = `tooltip-content ${props.theme || "default"} ${props.position}`;
newTooltip.id = tooltipId;
newTooltip.setAttribute("role", "tooltip");
setTooltipContent(newTooltip);
setTooltipContainer(newTooltip);
tooltipRootRef.appendChild(newTooltip);
setTooltipPositionChecker(new TooltipPositionChecker(containerRef.current, props.disableAutoPosition));
};

this.props.onClick && this.props.onClick(e);
}

render() {
return (
<div className={"tooltip-container" + (this.props.className ? ` ${this.props.className}` : "")} id={this.props.id}>
<div
className="icon tooltip-icon"
onClick={(e: React.MouseEvent<HTMLDivElement>) => !this.props.triggerOnHover && this.toggleTooltip(undefined, e)}
onMouseEnter={() => this.props.triggerOnHover && this.toggleTooltip(true)}
onMouseLeave={() => this.props.triggerOnHover && this.toggleTooltip(false)}
>
{this.props.customSvg ? this.props.customSvg : InfoCircleIcon}
</div>
<div className={`content ${this.props.position || "bottom"} ${this.props.theme || "default"}${this.state.toggle ? " open" : ""}`}>
{this.isPositioned("bottom") && <div className="triangle" />}

{!this.props.messageGroup &&
<div className="message-container" style={{ width: `${this.props.width || 120}px` }}>
{this.props.title && <div className="title">{this.props.title}</div>}
<div className="message">{this.props.message || "Tooltip is empty. Please pass a message."}</div>
</div>
}

{this.props.messageGroup &&
<div className="message-container" style={{ width: `${this.props.width || 120}px` }}>
{this.props.messageGroup.map((item, index) =>
<div key={index} className="message-list-item">
{item.title && <div className="title">{item.title}</div>}
{item.message && <div className="message">{item.message}</div>}
</div>
)}
</div>
}

{(this.isPositioned("top") || this.isPositioned("right") || this.isPositioned("left")) && <div className="triangle" />}
</div>
/**
* construct unique tooltip id
*/
const constructId = (): string => {
const randId: string = randomId("tooltip-");
setId(randId);
return randId;
};

/**
* set content of tooltip
* @param tooltip tooltip element
*/
const setTooltipContent = (tooltip: HTMLDivElement): void => {
let content: string | React.ReactNode = props.content;
if (!props.content) {
if (props.message) {
content = <TooltipMessage {...props} />;
} else if (props.messageGroup) {
content = <TooltipMessageGroup {...props} />;
}
}
ReactDOM.render(<TooltipContent content={content} />, tooltip);
};

/**
* set tooltip visibility
* @param isVisible boolean
*/
const setTooltipVisibility = (isVisible: boolean): void => {
tooltipContainer.style.display = isVisible ? "block" : "none";
if (isVisible) {
getWithinViewportPosition();
}
};

/**
* toggle tooltip
* @param toggle boolean
* @param e event triggering the changes
*/
const onTooltipToggle = (e?: React.MouseEvent<HTMLDivElement> | React.FocusEvent<HTMLDivElement>, toggle?: boolean) => {
const isVisible: boolean = toggle !== undefined ? toggle : !visible;
setVisible(isVisible);
setTooltipVisibility(isVisible);
props.onVisibleChange && props.onVisibleChange(e, isVisible);
props.onClick && props.onClick(e);
};

return (
<div
className={"tooltip-container" + (props.className ? ` ${props.className}` : "")}
id={props.id}
ref={containerRef}
>
<div
className="tooltip-reference"
aria-describedby={id}
tabIndex={-1}
onClick={(e: React.MouseEvent<HTMLDivElement>) => ((!props.trigger && !props.triggerOnHover) || props.trigger === "click") && onTooltipToggle(e)}
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => (props.triggerOnHover || props.trigger === "hover") && onTooltipToggle(e, true)}
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => (props.triggerOnHover || props.trigger === "hover") && onTooltipToggle(e, false)}
onFocus={(e: React.FocusEvent<HTMLDivElement>) => props.trigger === "focus" && onTooltipToggle(e)}
onBlur={(e: React.FocusEvent<HTMLDivElement>) => onTooltipToggle(e, false)}
>
{props.children || <div className="icon">{props.customSvg ? props.customSvg : InfoCircleIcon}</div>}
</div>
);
}
}
</div>
);
};

type TooltipContent = Pick<TooltipProps, "content">;
const TooltipContent: React.FunctionComponent<TooltipContent> = (props: TooltipContent) => {
return (
<>
<div className="tooltip-arrow" />
<div className="tooltip-inner">
{props.content}
</div>
</>
);
};
type TooltipMessage = Pick<TooltipProps, "title" | "message" | "width">;
const TooltipMessage: React.FunctionComponent<TooltipMessage> = (props: TooltipMessage) => {
return (
<div className="message-container" style={{ width: `${props.width || 120}px` }}>
{props.title && <div className="title">{props.title}</div>}
<div className="message">{props.message || "Tooltip is empty. Please pass a message."}</div>
</div>
);
};
type TooltipMessageGroup = Pick<TooltipProps, "messageGroup" | "width">;
const TooltipMessageGroup: React.FunctionComponent<TooltipMessage> = (props: TooltipMessageGroup) => {
return (
<div className="message-container" style={{ width: `${props.width || 120}px` }}>
{props.messageGroup.map((item, index) =>
<div key={index} className="message-list-item">
{item.title && <div className="title">{item.title}</div>}
{item.message && <div className="message">{item.message}</div>}
</div>
)}
</div>
);
};
Loading

0 comments on commit 5445516

Please sign in to comment.