Summary: Per title Reviewed By: passy Differential Revision: D24994086 fbshipit-source-id: 84d83638ec518a39ea2d80f56b8c794c8b86a9d4
282 lines
7.5 KiB
TypeScript
282 lines
7.5 KiB
TypeScript
/**
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @format
|
|
*/
|
|
|
|
import styled from '@emotion/styled';
|
|
import {colors} from './colors';
|
|
import {memo, createContext, useMemo, useState, useRef} from 'react';
|
|
import {Property} from 'csstype';
|
|
import React from 'react';
|
|
|
|
const defaultOptions = {
|
|
backgroundColor: colors.blueGrey,
|
|
position: 'below',
|
|
color: colors.white,
|
|
showTail: true,
|
|
maxWidth: '200px',
|
|
width: 'auto',
|
|
borderRadius: 4,
|
|
padding: '6px',
|
|
lineHeight: '20px',
|
|
delay: 0,
|
|
};
|
|
|
|
export type TooltipOptions = {
|
|
backgroundColor?: string;
|
|
position?: 'below' | 'above' | 'toRight' | 'toLeft';
|
|
color?: string;
|
|
showTail?: boolean;
|
|
maxWidth?: string;
|
|
width?: string;
|
|
borderRadius?: number;
|
|
padding?: string;
|
|
lineHeight?: string;
|
|
delay?: number; // in milliseconds
|
|
};
|
|
|
|
const TooltipBubble = styled.div<{
|
|
top: Property.Top<number>;
|
|
left: Property.Left<number>;
|
|
bottom: Property.Bottom<number>;
|
|
right: Property.Right<number>;
|
|
options: {
|
|
backgroundColor: Property.BackgroundColor;
|
|
lineHeight: Property.LineHeight<number>;
|
|
padding: Property.Padding<number>;
|
|
borderRadius: Property.BorderRadius<number>;
|
|
width: Property.Width<number>;
|
|
maxWidth: Property.MaxWidth<number>;
|
|
color: Property.Color;
|
|
};
|
|
}>((props) => ({
|
|
position: 'absolute',
|
|
zIndex: 99999999999,
|
|
backgroundColor: props.options.backgroundColor,
|
|
lineHeight: props.options.lineHeight,
|
|
padding: props.options.padding,
|
|
borderRadius: props.options.borderRadius,
|
|
width: props.options.width,
|
|
maxWidth: props.options.maxWidth,
|
|
top: props.top,
|
|
left: props.left,
|
|
bottom: props.bottom,
|
|
right: props.right,
|
|
color: props.options.color,
|
|
}));
|
|
TooltipBubble.displayName = 'TooltipProvider:TooltipBubble';
|
|
|
|
// vertical offset on bubble when position is 'below'
|
|
const BUBBLE_BELOW_POSITION_VERTICAL_OFFSET = -10;
|
|
// horizontal offset on bubble when position is 'toLeft' or 'toRight'
|
|
const BUBBLE_LR_POSITION_HORIZONTAL_OFFSET = 5;
|
|
// offset on bubble when tail is showing
|
|
const BUBBLE_SHOWTAIL_OFFSET = 5;
|
|
// horizontal offset on tail when position is 'above' or 'below'
|
|
const TAIL_AB_POSITION_HORIZONTAL_OFFSET = 4;
|
|
// horizontal offset on tail when position is 'toLeft' or 'toRight'
|
|
const TAIL_LR_POSITION_HORIZONTAL_OFFSET = 5;
|
|
// vertical offset on tail when position is 'toLeft' or 'toRight'
|
|
const TAIL_LR_POSITION_VERTICAL_OFFSET = 12;
|
|
|
|
const TooltipTail = styled.div<{
|
|
top: Property.Top<number>;
|
|
left: Property.Left<number>;
|
|
bottom: Property.Bottom<number>;
|
|
right: Property.Right<number>;
|
|
options: {
|
|
backgroundColor: Property.BackgroundColor;
|
|
};
|
|
}>((props) => ({
|
|
position: 'absolute',
|
|
display: 'block',
|
|
whiteSpace: 'pre',
|
|
height: '10px',
|
|
width: '10px',
|
|
lineHeight: '0',
|
|
zIndex: 99999999998,
|
|
transform: 'rotate(45deg)',
|
|
backgroundColor: props.options.backgroundColor,
|
|
top: props.top,
|
|
left: props.left,
|
|
bottom: props.bottom,
|
|
right: props.right,
|
|
}));
|
|
TooltipTail.displayName = 'TooltipProvider:TooltipTail';
|
|
|
|
type TooltipObject = {
|
|
rect: ClientRect;
|
|
title: React.ReactNode;
|
|
options: TooltipOptions;
|
|
};
|
|
|
|
interface TooltipManager {
|
|
open(
|
|
container: HTMLDivElement,
|
|
title: React.ReactNode,
|
|
options: TooltipOptions,
|
|
): void;
|
|
close(): void;
|
|
}
|
|
|
|
export const TooltipContext = createContext<TooltipManager>(undefined as any);
|
|
|
|
const TooltipProvider: React.FC<{}> = memo(function TooltipProvider({
|
|
children,
|
|
}) {
|
|
const timeoutID = useRef<NodeJS.Timeout>();
|
|
const [tooltip, setTooltip] = useState<TooltipObject | undefined>(undefined);
|
|
const tooltipManager = useMemo(
|
|
() => ({
|
|
open(
|
|
container: HTMLDivElement,
|
|
title: React.ReactNode,
|
|
options: TooltipOptions,
|
|
) {
|
|
if (timeoutID.current) {
|
|
clearTimeout(timeoutID.current);
|
|
}
|
|
const node = container.childNodes[0];
|
|
if (node == null || !(node instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
if (options.delay) {
|
|
timeoutID.current = setTimeout(() => {
|
|
setTooltip({
|
|
rect: node.getBoundingClientRect(),
|
|
title,
|
|
options: options,
|
|
});
|
|
}, options.delay);
|
|
return;
|
|
}
|
|
setTooltip({
|
|
rect: node.getBoundingClientRect(),
|
|
title,
|
|
options: options,
|
|
});
|
|
},
|
|
close() {
|
|
if (timeoutID.current) {
|
|
clearTimeout(timeoutID.current);
|
|
}
|
|
setTooltip(undefined);
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{tooltip && tooltip.title ? <Tooltip tooltip={tooltip} /> : null}
|
|
<TooltipContext.Provider value={tooltipManager}>
|
|
{children}
|
|
</TooltipContext.Provider>
|
|
</>
|
|
);
|
|
});
|
|
|
|
function Tooltip({tooltip}: {tooltip: TooltipObject}) {
|
|
return (
|
|
<>
|
|
{getTooltipTail(tooltip)}
|
|
{getTooltipBubble(tooltip)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default TooltipProvider;
|
|
|
|
function getTooltipTail(tooltip: TooltipObject) {
|
|
const opts = Object.assign(defaultOptions, tooltip.options);
|
|
if (!opts.showTail) {
|
|
return null;
|
|
}
|
|
|
|
let left: Property.Left<number> = 'auto';
|
|
let top: Property.Top<number> = 'auto';
|
|
let bottom: Property.Bottom<number> = 'auto';
|
|
let right: Property.Right<number> = 'auto';
|
|
|
|
if (opts.position === 'below') {
|
|
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
|
|
top = tooltip.rect.bottom;
|
|
} else if (opts.position === 'above') {
|
|
left = tooltip.rect.left + TAIL_AB_POSITION_HORIZONTAL_OFFSET;
|
|
bottom = window.innerHeight - tooltip.rect.top;
|
|
} else if (opts.position === 'toRight') {
|
|
left = tooltip.rect.right + TAIL_LR_POSITION_HORIZONTAL_OFFSET;
|
|
top = tooltip.rect.top + TAIL_LR_POSITION_VERTICAL_OFFSET;
|
|
} else if (opts.position === 'toLeft') {
|
|
right =
|
|
window.innerWidth -
|
|
tooltip.rect.left +
|
|
TAIL_LR_POSITION_HORIZONTAL_OFFSET;
|
|
top = tooltip.rect.top + TAIL_LR_POSITION_VERTICAL_OFFSET;
|
|
}
|
|
|
|
return (
|
|
<TooltipTail
|
|
key="tail"
|
|
top={top}
|
|
left={left}
|
|
bottom={bottom}
|
|
right={right}
|
|
options={opts}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function getTooltipBubble(tooltip: TooltipObject) {
|
|
const opts = Object.assign(defaultOptions, tooltip.options);
|
|
let left: Property.Left<number> = 'auto';
|
|
let top: Property.Top<number> = 'auto';
|
|
let bottom: Property.Bottom<number> = 'auto';
|
|
let right: Property.Right<number> = 'auto';
|
|
|
|
if (opts.position === 'below') {
|
|
left = tooltip.rect.left + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
|
|
top = tooltip.rect.bottom;
|
|
if (opts.showTail) {
|
|
top += BUBBLE_SHOWTAIL_OFFSET;
|
|
}
|
|
} else if (opts.position === 'above') {
|
|
bottom = window.innerHeight - tooltip.rect.top;
|
|
if (opts.showTail) {
|
|
bottom += BUBBLE_SHOWTAIL_OFFSET;
|
|
}
|
|
left = tooltip.rect.left + BUBBLE_BELOW_POSITION_VERTICAL_OFFSET;
|
|
} else if (opts.position === 'toRight') {
|
|
left = tooltip.rect.right + BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
|
|
if (opts.showTail) {
|
|
left += BUBBLE_SHOWTAIL_OFFSET;
|
|
}
|
|
top = tooltip.rect.top;
|
|
} else if (opts.position === 'toLeft') {
|
|
right =
|
|
window.innerWidth -
|
|
tooltip.rect.left +
|
|
BUBBLE_LR_POSITION_HORIZONTAL_OFFSET;
|
|
if (opts.showTail) {
|
|
right += BUBBLE_SHOWTAIL_OFFSET;
|
|
}
|
|
top = tooltip.rect.top;
|
|
}
|
|
|
|
return (
|
|
<TooltipBubble
|
|
key="bubble"
|
|
top={top}
|
|
left={left}
|
|
bottom={bottom}
|
|
right={right}
|
|
options={opts}>
|
|
{tooltip.title}
|
|
</TooltipBubble>
|
|
);
|
|
}
|