Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
/**
* Copyright (c) 2019 Paul Armstrong
*/
import React from 'react';
import { render } from 'react-testing-library';
import Tooltip from '../Tooltip';
import { Dimensions, MeasureOnSuccessCallback, View } from 'react-native';
describe('Tooltip', () => {
let viewRef;
beforeEach(() => {
viewRef = React.createRef();
render(<View ref={viewRef} />);
});
test('renders a tooltip to a portal', () => {
const portal = document.createElement('div');
portal.setAttribute('id', 'tooltipPortal');
document.body.appendChild(portal);
jest.spyOn(View.prototype, 'measure').mockImplementation(
(fn: MeasureOnSuccessCallback): void => {
fn(0, 0, 45, 20, 0, 0);
}
);
jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
(fn: (x: number, y: number, width: number, height: number) => void): void => {
fn(20, 100, 40, 30);
}
);
const { getByRole, queryAllByText } = render(<Tooltip relativeTo={viewRef} text="foobar" />, {
container: portal
});
expect(getByRole('tooltip').style).toMatchObject({
top: '136px',
left: '17.5px'
});
expect(queryAllByText('foobar')).toHaveLength(1);
});
test('renders directly without a portal available', () => {
jest.spyOn(View.prototype, 'measure').mockImplementation(
(fn: MeasureOnSuccessCallback): void => {
fn(0, 0, 45, 20, 0, 0);
}
);
jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
(fn: (x: number, y: number, width: number, height: number) => void): void => {
fn(20, 100, 40, 30);
}
);
const { getByRole, queryAllByText } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
expect(getByRole('tooltip').style).toMatchObject({
top: '136px',
left: '17.5px'
});
expect(queryAllByText('foobar')).toHaveLength(1);
});
describe('window edge avoidance', () => {
test('avoids the left edge', () => {
jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
jest.spyOn(View.prototype, 'measure').mockImplementation(
(fn: MeasureOnSuccessCallback): void => {
fn(0, 0, 45, 30, 0, 0);
}
);
jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
(fn: (x: number, y: number, width: number, height: number) => void): void => {
fn(10, 200, 20, 10);
}
);
const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
expect(getByRole('tooltip').style).toMatchObject({
top: '190px',
left: '36px'
});
});
test('avoids the right edge', () => {
jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
jest.spyOn(View.prototype, 'measure').mockImplementation(
(fn: MeasureOnSuccessCallback): void => {
fn(0, 0, 45, 30, 0, 0);
}
);
jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
(fn: (x: number, y: number, width: number, height: number) => void): void => {
fn(380, 200, 50, 10);
}
);
const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
expect(getByRole('tooltip').style).toMatchObject({
top: '190px',
left: '329px'
});
});
test('avoids the bottom edge', () => {
jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
jest.spyOn(View.prototype, 'measure').mockImplementation(
(fn: MeasureOnSuccessCallback): void => {
fn(0, 0, 45, 30, 0, 0);
}
);
jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
(fn: (x: number, y: number, width: number, height: number) => void): void => {
fn(200, 380, 50, 10);
}
);
const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
expect(getByRole('tooltip').style).toMatchObject({
top: '344px',
left: '202.5px'
});
});
});
});
/**
* Copyright (c) 2019 Paul Armstrong
*/
import * as Theme from '../theme';
import React from 'react';
import ReactDOM from 'react-dom';
import { Dimensions, StyleSheet, Text, View } from 'react-native';
interface Props {
relativeTo: React.RefObject<View>;
text: string;
}
const tipSpace = 6;
const Tooltip = (props: Props): React.ReactElement => {
const { relativeTo, text } = props;
const [position, setPosition] = React.useState({ top: -999, left: 0 });
const portalRoot = document.getElementById('tooltipPortal');
const ref: React.RefObject<View> = React.createRef();
React.useEffect(() => {
if (relativeTo.current) {
const { width: windowWidth, height: windowHeight } = Dimensions.get('window');
ref.current.measure(
(_x: number, _y: number, tipWidth: number, tipHeight: number): void => {
relativeTo.current.measureInWindow(
(x: number, y: number, width: number, height: number): void => {
let top = y + height + tipSpace;
let left = x + width / 2 - tipWidth / 2;
// too far right when underneath
if (left + tipWidth > windowWidth) {
left = x - tipWidth - tipSpace;
top = y + height / 2 - tipHeight / 2;
}
// too far left when underneath
else if (left < 0) {
left = x + width + tipSpace;
top = y + height / 2 - tipHeight / 2;
}
// too close to bottom
else if (top + tipHeight > windowHeight) {
top = y - tipHeight - tipSpace;
}
setPosition({ left, top });
}
);
}
);
}
});
const tooltip = (
<View
// @ts-ignore
accessibilityRole="tooltip"
ref={ref}
style={[styles.root, { top: position.top, left: position.left }, position.top > 0 && styles.show]}
>
<Text style={styles.text}>{text}</Text>
</View>
);
return portalRoot ? ReactDOM.createPortal(tooltip, portalRoot) : tooltip;
};
const styles = StyleSheet.create({
root: {
// @ts-ignore
position: 'absolute',
backgroundColor: Theme.Color.Gray50,
borderRadius: Theme.BorderRadius.Normal,
paddingHorizontal: Theme.Spacing.Small,
paddingVertical: Theme.Spacing.Xsmall,
// @ts-ignore
transitionProperty: 'transform, opacity',
transitionDuration: '0.1s',
transitionTimingFunction: Theme.MotionTiming.Accelerate,
transform: [{ scale: 0.75 }],
opacity: 0
},
show: {
transform: [{ scale: 1 }],
opacity: 1
},
// @ts-ignore
text: {
color: Theme.TextColor.Gray50,
fontSize: Theme.FontSize.Xsmall,
textAlign: 'center'
}
});
export default Tooltip;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment