-
Matiss Janis Aboltins authoredMatiss Janis Aboltins authored
tooltips.tsx 11.45 KiB
// @ts-strict-ignore
import {
Component,
createContext,
createRef,
useState,
type RefObject,
type ReactNode,
type MouseEventHandler,
type MouseEvent,
type ContextType,
} from 'react';
import ReactDOM from 'react-dom';
import { css } from 'glamor';
import { type CSSProperties, styles, theme } from '../style';
export const IntersectionBoundary = createContext<RefObject<HTMLElement>>(null);
// @deprecated: please use `Tooltip` component in `common` folder
export function useTooltip() {
const [isOpen, setIsOpen] = useState<boolean>(false);
return {
getOpenEvents: (events: { onClick?: MouseEventHandler } = {}) => ({
onClick: (e: MouseEvent) => {
e.stopPropagation();
events.onClick?.(e);
setIsOpen(true);
},
}),
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
};
}
type TooltipPosition =
| 'top'
| 'top-left'
| 'top-right'
| 'bottom'
| 'bottom-left'
| 'bottom-right'
| 'bottom-stretch'
| 'top-stretch'
| 'bottom-center'
| 'top-center'
| 'left-center'
| 'right';
type TooltipProps = {
position?: TooltipPosition;
onClose?: () => void;
forceLayout?: boolean;
forceTop?: number;
ignoreBoundary?: boolean;
targetRect?: DOMRect;
offset?: number;
style?: CSSProperties;
width?: number;
children: ReactNode;
targetHeight?: number;
};
type MutableDomRect = {
top: number;
left: number;
width: number;
height: number;
};
// @deprecated: please use `Tooltip` component in `common` folder
export class Tooltip extends Component<TooltipProps> {
static contextType = IntersectionBoundary;
position: TooltipPosition;
contentRef: RefObject<HTMLDivElement>;
cleanup: () => void;
target: HTMLDivElement;
context: ContextType<typeof IntersectionBoundary> = this.context; // assign type to context without using declare.
constructor(props) {
super(props);
this.position = props.position || 'bottom-right';
this.contentRef = createRef<HTMLDivElement>();
}
isHTMLElement(element: unknown): element is HTMLElement {
return element instanceof HTMLElement;
}
setup() {
this.layout();
const pointerDownHandler = e => {
let node = e.target;
while (node && node !== document.documentElement) {
// Allow clicking reach popover that mount outside of
// tooltips. Might need to think about this more, like what
// kind of things can be click that shouldn't close a tooltip?
if (node.dataset.istooltip || node.dataset.reachPopover != null) {
break;
}
node = node.parentNode;
}
if (node === document.documentElement) {
this.props.onClose?.();
}
};
const escHandler = e => {
if (e.key === 'Escape') {
this.props.onClose?.();
}
};
window.document.addEventListener('pointerdown', pointerDownHandler, false);
this.contentRef.current?.addEventListener('keydown', escHandler, false);
this.cleanup = () => {
window.document.removeEventListener(
'pointerdown',
pointerDownHandler,
false,
);
this.contentRef.current?.removeEventListener(
'keydown',
escHandler,
false,
);
};
}
componentDidMount() {
if (this.getContainer()) {
this.setup();
} else {
// TODO: write comment :)
this.forceUpdate(() => {
if (this.getContainer()) {
this.setup();
} else {
console.log('Warning: could not mount tooltip, container missing');
}
});
}
}
componentDidUpdate(prevProps) {
// If providing the target rect manually, we can dynamically
// update to it. We can't do this if we are reading directly from
// the DOM since we don't know when it's updated.
if (
prevProps.targetRect !== this.props.targetRect ||
prevProps.forceTop !== this.props.forceTop ||
this.props.forceLayout
) {
this.layout();
}
}
getContainer(): HTMLElement {
const { ignoreBoundary = false } = this.props;
if (!ignoreBoundary && this.context) {
return this.context.current;
}
return document.body;
}
getBoundsContainer() {
// If the container is a scrollable element, we want to do all the
// bounds checking on the parent DOM element instead
const container = this.getContainer();
if (
container.parentNode &&
this.isHTMLElement(container.parentNode) &&
container.parentNode.style.overflow === 'auto'
) {
return container.parentNode;
}
return container;
}
layout() {
const { targetRect, offset = 0 } = this.props;
const contentEl = this.contentRef.current;
if (!contentEl) {
return;
}
const box = contentEl.getBoundingClientRect();
const anchorEl = this.target.parentNode;
let anchorRect: MutableDomRect | undefined =
targetRect ||
(this.isHTMLElement(anchorEl)
? anchorEl?.getBoundingClientRect()
: undefined);
if (!anchorRect) {
return;
}
// Copy it so we can mutate it
anchorRect = {
top: anchorRect.top,
left: anchorRect.left,
width: anchorRect.width,
height: anchorRect.height,
};
const container = this.getBoundsContainer();
if (!container) {
return;
}
const containerRect = container.getBoundingClientRect();
anchorRect.left -= containerRect.left;
anchorRect.top -= containerRect.top;
// This is a hack, but allow consumers to force a top position if
// they already know it. This allows them to provide consistent
// updates. We should generalize this and `targetRect`
if (this.props.forceTop) {
anchorRect.top = this.props.forceTop;
} else {
const boxHeight = box.height + offset;
const testTop = anchorRect.top - boxHeight;
const testBottom = anchorRect.top + anchorRect.height + boxHeight;
if (
// If it doesn't fit above it, switch it to below
(this.position.indexOf('top') !== -1 && testTop < containerRect.top) ||
// If it doesn't fit below it, switch it above only if it does
// fit above it
(this.position.indexOf('bottom') !== -1 &&
testBottom > containerRect.height &&
testTop > 0)
) {
// Invert the position
this.position = this.getOppositePosition(this.position);
}
anchorRect.top += container.scrollTop;
}
const style = this.getStyleForPosition(
this.position,
box,
anchorRect,
this.getContainer().getBoundingClientRect(),
offset,
);
contentEl.style.top = style.top;
contentEl.style.bottom = style.bottom;
contentEl.style.left = style.left;
contentEl.style.right = style.right;
contentEl.style.width = style.width;
}
componentWillUnmount() {
if (this.cleanup) {
this.cleanup();
}
}
getOppositePosition(position) {
switch (position) {
case 'top':
case 'top-left':
return 'bottom';
case 'top-right':
return 'bottom-right';
case 'bottom':
case 'bottom-left':
return 'top';
case 'bottom-right':
return 'top-right';
case 'bottom-stretch':
return 'top-stretch';
case 'top-stretch':
return 'bottom-stretch';
case 'bottom-center':
return 'top-center';
case 'top-center':
return 'bottom-center';
case 'right':
return 'right';
default:
}
}
getStyleForPosition(position, boxRect, anchorRect, containerRect, offset) {
const style = {
top: 'inherit',
bottom: 'inherit',
left: 'inherit',
right: 'inherit',
width: undefined as string | undefined,
};
if (
position === 'top' ||
position === 'top-right' ||
position === 'top-left'
) {
style.top = anchorRect.top - boxRect.height - offset + 'px';
if (position === 'top-right') {
style.left =
anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
} else {
style.left = anchorRect.left + 'px';
// style.right = 0;
}
} else if (
position === 'bottom' ||
position === 'bottom-right' ||
position === 'bottom-left'
) {
style.top = anchorRect.top + anchorRect.height + offset + 'px';
if (position === 'bottom-right') {
style.left =
anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
} else {
style.left = anchorRect.left + 'px';
// style.right = 0;
}
} else if (position === 'bottom-center') {
style.top = anchorRect.top + anchorRect.height + offset + 'px';
style.left =
anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
} else if (position === 'top-center') {
style.top = anchorRect.top - boxRect.height - offset + 'px';
style.left =
anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
} else if (position === 'left-center') {
style.top =
anchorRect.top - (boxRect.height - anchorRect.height) / 2 + 'px';
style.left = anchorRect.left - boxRect.width + 'px';
} else if (position === 'top-stretch') {
style.bottom = containerRect.height - anchorRect.top + offset + 'px';
style.left = anchorRect.left + 'px';
style.width = anchorRect.width + 'px';
} else if (position === 'bottom-stretch') {
style.top = anchorRect.top + anchorRect.height + offset + 'px';
style.left = anchorRect.left + 'px';
style.width = anchorRect.width + 'px';
} else if (position === 'right') {
style.top = anchorRect.top + 'px';
style.left = anchorRect.left + anchorRect.width + offset + 'px';
} else {
throw new Error('Invalid position for Tooltip: ' + position);
}
return style;
}
render() {
const { children, width, style } = this.props;
const contentStyle = {
position: 'absolute',
zIndex: 3000,
padding: 5,
width,
...styles.shadowLarge,
borderRadius: 4,
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
// opacity: 0,
// transition: 'transform .1s, opacity .1s',
// transitionTimingFunction: 'ease-out'
};
// const enteredStyle = { opacity: 1, transform: 'none' };
if (!this.getContainer()) {
return null;
}
return (
<div ref={el => (this.target = el)}>
{ReactDOM.createPortal(
<div
className={`${css(contentStyle, style, styles.darkScrollbar)}`}
ref={this.contentRef}
data-testid={this.props['data-testid'] || 'tooltip'}
data-istooltip
onClick={e => {
// Click events inside a tooltip (e.g. when selecting a menu item) will bubble up
// through the portal to parents in the React tree (as opposed to DOM parents).
// This is undesirable. For example, clicking on a category group on a budget sheet
// toggles that group, and so would clicking on a menu item in the settings menu
// of that category group if the click event wasn't stopped from bubbling up.
// This issue could be handled in different ways, but I think stopping propagation
// here is sane; I can't see a scenario where you would want to take advantage of
// click propagation from a tooltip back to its React parent.
e.stopPropagation();
}}
>
{children}
</div>,
this.getContainer(),
)}
</div>
);
}
}