Skip to content

Commit 96877a9

Browse files
authored
Feature/tooltips (#23)
* Added overlays * Added tooltips
1 parent f558c90 commit 96877a9

File tree

14 files changed

+42426
-18920
lines changed

14 files changed

+42426
-18920
lines changed

jest.config.json

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{
2-
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$",
32
"setupFilesAfterEnv": ["./setupTests.ts"],
43
"moduleNameMapper": {
54
"@/(.*)": "<rootDir>/src/$1"

package-lock.json

+41,291-18,667
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
"webpack-dev-server": "^3.11.0"
4141
},
4242
"dependencies": {
43+
"@popperjs/core": "^2.6.0",
4344
"clsx": "^1.1.1",
45+
"framer-motion": "^3.1.1",
4446
"react": "^16.14.0"
4547
}
4648
}
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { useRef, useState } from 'react';
4+
import Overlay from '@/components/Overlay/index';
5+
import { Placement, PositioningStrategy } from '@popperjs/core';
6+
import { Trigger, triggerPropTypes } from '@/components/Overlay/Trigger';
7+
import { AnimatePresence } from 'framer-motion';
8+
9+
interface OverlayTriggerProps {
10+
arrow?: boolean;
11+
children: React.ReactElement;
12+
overlay: React.ReactNode;
13+
placement?: Placement;
14+
positionStrategy?: PositioningStrategy;
15+
className?: string;
16+
trigger?: Trigger | string;
17+
motion?: string;
18+
}
19+
20+
const OverlayTrigger = ({
21+
arrow,
22+
children: triggerElement,
23+
className,
24+
overlay,
25+
placement,
26+
positionStrategy,
27+
trigger = 'hover',
28+
motion
29+
}: OverlayTriggerProps): React.ReactElement => {
30+
const [shown, setShown] = useState<boolean>(false);
31+
const triggerRef = useRef<HTMLElement>();
32+
33+
const attachEvents = (child: React.ReactElement, trigger: string) => {
34+
switch (trigger) {
35+
case Trigger.CLICK:
36+
return {
37+
onClick: (event: React.MouseEvent) => {
38+
if (child.props.onClick) {
39+
child.props.onClick(event);
40+
}
41+
42+
setShown(!shown);
43+
}
44+
}
45+
case Trigger.HOVER:
46+
default:
47+
return {
48+
onMouseEnter: (event: React.MouseEvent): void => {
49+
if (child.props.onMouseEnter) {
50+
child.props.onMouseEnter(event);
51+
}
52+
53+
setShown(true)
54+
},
55+
onMouseLeave: (event: React.MouseEvent) => {
56+
if (child.props.onMouseLeave) {
57+
child.props.onMouseLeave(event);
58+
}
59+
60+
setShown(false)
61+
}
62+
}
63+
}
64+
}
65+
66+
const createChildren = () => shown && (
67+
<Overlay
68+
motion={motion}
69+
arrow={arrow}
70+
triggerRef={triggerRef}
71+
placement={placement}
72+
positionStrategy={positionStrategy}
73+
className={className}
74+
>
75+
{overlay}
76+
</Overlay>
77+
)
78+
79+
return (
80+
<>
81+
{React.cloneElement(triggerElement, {
82+
ref: triggerRef,
83+
...attachEvents(triggerElement, trigger)
84+
})}
85+
{motion
86+
? React.createElement(AnimatePresence, {}, createChildren())
87+
: createChildren()}
88+
</>
89+
)
90+
}
91+
92+
OverlayTrigger.displayName = 'OverlayTrigger';
93+
OverlayTrigger.propTypes = {
94+
children: PropTypes.node.isRequired,
95+
className: PropTypes.string,
96+
overlay: PropTypes.element.isRequired,
97+
placement: PropTypes.string,
98+
trigger: triggerPropTypes
99+
}
100+
101+
export default OverlayTrigger;

src/components/Overlay/Trigger.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Proptypes from 'prop-types';
2+
3+
export enum Trigger {
4+
CLICK = 'click',
5+
HOVER = 'hover'
6+
}
7+
8+
export const triggerPropTypes = Proptypes.oneOf(['click', 'hover'])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { mount } from 'enzyme';
2+
import React from 'react';
3+
import OverlayTrigger from '@/components/Overlay/OverlayTrigger';
4+
import { motion } from 'framer-motion';
5+
6+
describe('Overlay test', () => {
7+
it('should render overlay when hovered', async () => {
8+
const tooltip = mount(
9+
<div>
10+
<OverlayTrigger
11+
overlay="test"
12+
>
13+
<button>test</button>
14+
</OverlayTrigger>
15+
</div>
16+
);
17+
18+
tooltip.find('button').simulate('mouseenter');
19+
20+
expect(tooltip.find('.content').text()).toBe('test');
21+
22+
tooltip.find('button').simulate('mouseleave');
23+
24+
expect(tooltip.find('.content').length).toBe(0);
25+
});
26+
27+
it('should call props along hover triggers', async () => {
28+
const mockFnEnter = jest.fn();
29+
const mockFnLeave = jest.fn();
30+
31+
const tooltip = mount(
32+
<div>
33+
<OverlayTrigger
34+
overlay="test"
35+
>
36+
<button onMouseEnter={mockFnEnter} onMouseLeave={mockFnLeave}>test</button>
37+
</OverlayTrigger>
38+
</div>
39+
);
40+
41+
tooltip.find('button').simulate('mouseenter');
42+
tooltip.find('button').simulate('mouseleave');
43+
44+
expect(mockFnEnter).toHaveBeenCalled();
45+
expect(mockFnLeave).toHaveBeenCalled();
46+
});
47+
48+
it('should render overlay when clicked', async () => {
49+
const mockFn = jest.fn();
50+
51+
const tooltip = mount(
52+
<div>
53+
<OverlayTrigger
54+
trigger="click"
55+
overlay="test"
56+
>
57+
<button onClick={mockFn}>test</button>
58+
</OverlayTrigger>
59+
</div>
60+
);
61+
62+
tooltip.find('button').simulate('click');
63+
64+
expect(tooltip.find('.content').text()).toBe('test');
65+
expect(mockFn).toHaveBeenCalled();
66+
});
67+
68+
it('should render overlay with arrow', async () => {
69+
const tooltip = mount(
70+
<div>
71+
<OverlayTrigger
72+
trigger="click"
73+
overlay="test"
74+
arrow
75+
>
76+
<button>test</button>
77+
</OverlayTrigger>
78+
</div>
79+
);
80+
81+
tooltip.find('button').simulate('click');
82+
83+
expect(tooltip.find('.arrow')).toBeDefined();
84+
});
85+
86+
it('should render overlay without arrow', async () => {
87+
const tooltip = mount(
88+
<div>
89+
<OverlayTrigger
90+
trigger="click"
91+
overlay="test"
92+
arrow={false}
93+
>
94+
<button>test</button>
95+
</OverlayTrigger>
96+
</div>
97+
);
98+
99+
tooltip.find('button').simulate('click');
100+
101+
expect(tooltip.find('.arrow').length).toBe(0);
102+
});
103+
104+
it('should should use motion when defined', async () => {
105+
const tooltip = mount(
106+
<div>
107+
<OverlayTrigger
108+
trigger="click"
109+
overlay="test"
110+
motion="fade"
111+
>
112+
<button>test</button>
113+
</OverlayTrigger>
114+
</div>
115+
);
116+
117+
tooltip.find('button').simulate('click');
118+
119+
expect(tooltip.find(motion.div)).toBeDefined();
120+
});
121+
122+
it('should should ignore undefined motion', async () => {
123+
const tooltip = mount(
124+
<div>
125+
<OverlayTrigger
126+
trigger="click"
127+
overlay="test"
128+
motion="bounce"
129+
>
130+
<button>test</button>
131+
</OverlayTrigger>
132+
</div>
133+
);
134+
135+
tooltip.find('button').simulate('click');
136+
137+
expect(tooltip.find(motion.div).length).toBe(0);
138+
});
139+
});

src/components/Overlay/index.tsx

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
import { createPortal } from 'react-dom';
3+
import { useEffect, useRef } from 'react';
4+
import {
5+
createPopper,
6+
Instance as PopperInstance,
7+
Placement,
8+
Options as PopperOptions, PositioningStrategy
9+
} from '@popperjs/core';
10+
import PropTypes from 'prop-types';
11+
import { Modifier } from '@popperjs/core/lib/types';
12+
import clsx from 'clsx';
13+
import { AnimationFeature, ExitFeature, HTMLMotionProps, m as motion, MotionConfig } from 'framer-motion';
14+
import { motionsMap } from '@/components/animations/motionsMap';
15+
16+
interface OverlayProps {
17+
className?: string;
18+
triggerRef: React.MutableRefObject<HTMLElement | undefined>;
19+
placement?: Placement;
20+
arrow?: boolean;
21+
positionStrategy?: PositioningStrategy;
22+
motion?: string;
23+
}
24+
25+
const Overlay = ({
26+
children,
27+
className,
28+
triggerRef,
29+
placement = 'top',
30+
arrow = true,
31+
positionStrategy = 'absolute',
32+
motion: triggerMotion
33+
}: React.PropsWithChildren<OverlayProps>): React.ReactElement => {
34+
const ref = useRef<HTMLDivElement | null>(null);
35+
const popper = useRef<PopperInstance>();
36+
37+
const createModifiers = (): Array<Partial<Modifier<any, any>>> => ([
38+
...(arrow ? [{
39+
name: 'arrow',
40+
options: {
41+
element: '.arrow'
42+
}
43+
}] : [])
44+
]);
45+
46+
const createPopperOptions = (): PopperOptions => ({
47+
modifiers: createModifiers(),
48+
placement,
49+
strategy: positionStrategy
50+
});
51+
52+
const createMotion = (): Record<string, HTMLMotionProps<'div'>> => {
53+
if (!triggerMotion) {
54+
return {};
55+
}
56+
57+
if (Object.prototype.hasOwnProperty.call(motionsMap, triggerMotion)) {
58+
return motionsMap[triggerMotion];
59+
}
60+
61+
return {};
62+
}
63+
64+
useEffect(() => {
65+
if (ref.current && triggerRef.current) {
66+
popper.current = createPopper(
67+
triggerRef.current,
68+
ref.current,
69+
createPopperOptions()
70+
);
71+
popper.current?.forceUpdate()
72+
}
73+
}, [])
74+
75+
const createChildren = () => (
76+
<>
77+
{arrow && (<div className="overlay-arrow arrow" />)}
78+
<div
79+
className="content"
80+
>
81+
{children}
82+
</div>
83+
</>
84+
);
85+
86+
return createPortal(
87+
<MotionConfig features={[ExitFeature, AnimationFeature]}>
88+
<div
89+
ref={ref}
90+
className={clsx(
91+
'overlay-container',
92+
arrow && 'has-arrow',
93+
className
94+
)}
95+
>
96+
{Object.keys(createMotion()).length
97+
? React.createElement<HTMLMotionProps<'div'>>(motion.div, {
98+
className: 'overlay-animator',
99+
...createMotion(),
100+
}, createChildren())
101+
: createChildren()
102+
}
103+
</div>
104+
</MotionConfig>,
105+
document.body
106+
)
107+
}
108+
109+
Overlay.propTypes = {
110+
triggerRef: PropTypes.shape({current: PropTypes.instanceOf(HTMLElement)})
111+
}
112+
113+
export default Overlay;

0 commit comments

Comments
 (0)