Skip to content

Feature/tooltips #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Dec 27, 2020
1 change: 0 additions & 1 deletion jest.config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$",
"setupFilesAfterEnv": ["./setupTests.ts"],
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1"
Expand Down
59,958 changes: 41,291 additions & 18,667 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
"webpack-dev-server": "^3.11.0"
},
"dependencies": {
"@popperjs/core": "^2.6.0",
"clsx": "^1.1.1",
"framer-motion": "^3.1.1",
"react": "^16.14.0"
}
}
101 changes: 101 additions & 0 deletions src/components/Overlay/OverlayTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { useRef, useState } from 'react';
import Overlay from '@/components/Overlay/index';
import { Placement, PositioningStrategy } from '@popperjs/core';
import { Trigger, triggerPropTypes } from '@/components/Overlay/Trigger';
import { AnimatePresence } from 'framer-motion';

interface OverlayTriggerProps {
arrow?: boolean;
children: React.ReactElement;
overlay: React.ReactNode;
placement?: Placement;
positionStrategy?: PositioningStrategy;
className?: string;
trigger?: Trigger | string;
motion?: string;
}

const OverlayTrigger = ({
arrow,
children: triggerElement,
className,
overlay,
placement,
positionStrategy,
trigger = 'hover',
motion
}: OverlayTriggerProps): React.ReactElement => {
const [shown, setShown] = useState<boolean>(false);
const triggerRef = useRef<HTMLElement>();

const attachEvents = (child: React.ReactElement, trigger: string) => {
switch (trigger) {
case Trigger.CLICK:
return {
onClick: (event: React.MouseEvent) => {
if (child.props.onClick) {
child.props.onClick(event);
}

setShown(!shown);
}
}
case Trigger.HOVER:
default:
return {
onMouseEnter: (event: React.MouseEvent): void => {
if (child.props.onMouseEnter) {
child.props.onMouseEnter(event);
}

setShown(true)
},
onMouseLeave: (event: React.MouseEvent) => {
if (child.props.onMouseLeave) {
child.props.onMouseLeave(event);
}

setShown(false)
}
}
}
}

const createChildren = () => shown && (
<Overlay
motion={motion}
arrow={arrow}
triggerRef={triggerRef}
placement={placement}
positionStrategy={positionStrategy}
className={className}
>
{overlay}
</Overlay>
)

return (
<>
{React.cloneElement(triggerElement, {
ref: triggerRef,
...attachEvents(triggerElement, trigger)
})}
{motion
? React.createElement(AnimatePresence, {}, createChildren())
: createChildren()}
</>
)
}

OverlayTrigger.displayName = 'OverlayTrigger';
OverlayTrigger.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
overlay: PropTypes.element.isRequired,
placement: PropTypes.string,
trigger: triggerPropTypes
}

export default OverlayTrigger;
8 changes: 8 additions & 0 deletions src/components/Overlay/Trigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Proptypes from 'prop-types';

export enum Trigger {
CLICK = 'click',
HOVER = 'hover'
}

export const triggerPropTypes = Proptypes.oneOf(['click', 'hover'])
139 changes: 139 additions & 0 deletions src/components/Overlay/__tests__/Overlay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { mount } from 'enzyme';
import React from 'react';
import OverlayTrigger from '@/components/Overlay/OverlayTrigger';
import { motion } from 'framer-motion';

describe('Overlay test', () => {
it('should render overlay when hovered', async () => {
const tooltip = mount(
<div>
<OverlayTrigger
overlay="test"
>
<button>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('mouseenter');

expect(tooltip.find('.content').text()).toBe('test');

tooltip.find('button').simulate('mouseleave');

expect(tooltip.find('.content').length).toBe(0);
});

it('should call props along hover triggers', async () => {
const mockFnEnter = jest.fn();
const mockFnLeave = jest.fn();

const tooltip = mount(
<div>
<OverlayTrigger
overlay="test"
>
<button onMouseEnter={mockFnEnter} onMouseLeave={mockFnLeave}>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('mouseenter');
tooltip.find('button').simulate('mouseleave');

expect(mockFnEnter).toHaveBeenCalled();
expect(mockFnLeave).toHaveBeenCalled();
});

it('should render overlay when clicked', async () => {
const mockFn = jest.fn();

const tooltip = mount(
<div>
<OverlayTrigger
trigger="click"
overlay="test"
>
<button onClick={mockFn}>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('click');

expect(tooltip.find('.content').text()).toBe('test');
expect(mockFn).toHaveBeenCalled();
});

it('should render overlay with arrow', async () => {
const tooltip = mount(
<div>
<OverlayTrigger
trigger="click"
overlay="test"
arrow
>
<button>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('click');

expect(tooltip.find('.arrow')).toBeDefined();
});

it('should render overlay without arrow', async () => {
const tooltip = mount(
<div>
<OverlayTrigger
trigger="click"
overlay="test"
arrow={false}
>
<button>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('click');

expect(tooltip.find('.arrow').length).toBe(0);
});

it('should should use motion when defined', async () => {
const tooltip = mount(
<div>
<OverlayTrigger
trigger="click"
overlay="test"
motion="fade"
>
<button>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('click');

expect(tooltip.find(motion.div)).toBeDefined();
});

it('should should ignore undefined motion', async () => {
const tooltip = mount(
<div>
<OverlayTrigger
trigger="click"
overlay="test"
motion="bounce"
>
<button>test</button>
</OverlayTrigger>
</div>
);

tooltip.find('button').simulate('click');

expect(tooltip.find(motion.div).length).toBe(0);
});
});
113 changes: 113 additions & 0 deletions src/components/Overlay/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useEffect, useRef } from 'react';
import {
createPopper,
Instance as PopperInstance,
Placement,
Options as PopperOptions, PositioningStrategy
} from '@popperjs/core';
import PropTypes from 'prop-types';
import { Modifier } from '@popperjs/core/lib/types';
import clsx from 'clsx';
import { AnimationFeature, ExitFeature, HTMLMotionProps, m as motion, MotionConfig } from 'framer-motion';
import { motionsMap } from '@/components/animations/motionsMap';

interface OverlayProps {
className?: string;
triggerRef: React.MutableRefObject<HTMLElement | undefined>;
placement?: Placement;
arrow?: boolean;
positionStrategy?: PositioningStrategy;
motion?: string;
}

const Overlay = ({
children,
className,
triggerRef,
placement = 'top',
arrow = true,
positionStrategy = 'absolute',
motion: triggerMotion
}: React.PropsWithChildren<OverlayProps>): React.ReactElement => {
const ref = useRef<HTMLDivElement | null>(null);
const popper = useRef<PopperInstance>();

const createModifiers = (): Array<Partial<Modifier<any, any>>> => ([
...(arrow ? [{
name: 'arrow',
options: {
element: '.arrow'
}
}] : [])
]);

const createPopperOptions = (): PopperOptions => ({
modifiers: createModifiers(),
placement,
strategy: positionStrategy
});

const createMotion = (): Record<string, HTMLMotionProps<'div'>> => {
if (!triggerMotion) {
return {};
}

if (Object.prototype.hasOwnProperty.call(motionsMap, triggerMotion)) {
return motionsMap[triggerMotion];
}

return {};
}

useEffect(() => {
if (ref.current && triggerRef.current) {
popper.current = createPopper(
triggerRef.current,
ref.current,
createPopperOptions()
);
popper.current?.forceUpdate()
}
}, [])

const createChildren = () => (
<>
{arrow && (<div className="overlay-arrow arrow" />)}
<div
className="content"
>
{children}
</div>
</>
);

return createPortal(
<MotionConfig features={[ExitFeature, AnimationFeature]}>
<div
ref={ref}
className={clsx(
'overlay-container',
arrow && 'has-arrow',
className
)}
>
{Object.keys(createMotion()).length
? React.createElement<HTMLMotionProps<'div'>>(motion.div, {
className: 'overlay-animator',
...createMotion(),
}, createChildren())
: createChildren()
}
</div>
</MotionConfig>,
document.body
)
}

Overlay.propTypes = {
triggerRef: PropTypes.shape({current: PropTypes.instanceOf(HTMLElement)})
}

export default Overlay;
Loading