Skip to content

Commit

Permalink
feat(OverlayTrigger): expose show for manually controlling visibili…
Browse files Browse the repository at this point in the history
…ty (#5282)
  • Loading branch information
jquense committed Jul 9, 2020
1 parent 4fac1cb commit d7dab82
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 32 deletions.
95 changes: 64 additions & 31 deletions src/OverlayTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
import contains from 'dom-helpers/contains';
import PropTypes from 'prop-types';
import React, { cloneElement, useCallback, useRef, useState } from 'react';
import React, { cloneElement, useCallback, useRef } from 'react';
import useTimeout from '@restart/hooks/useTimeout';
import safeFindDOMNode from 'react-overlays/safeFindDOMNode';
import warning from 'warning';
import { useUncontrolledProp } from 'uncontrollable';
import Overlay, { OverlayChildren, OverlayProps } from './Overlay';

export type OverlayTriggerType = 'hover' | 'click' | 'focus';

export type OverlayDelay = number | { show: number; hide: number };

export type OverlayInjectedProps = {
onFocus?: (...args: any[]) => any;
};

export interface OverlayTriggerProps
extends Omit<OverlayProps, 'children' | 'target'> {
children: React.ReactNode;
children: React.ReactElement;
trigger?: OverlayTriggerType | OverlayTriggerType[];
delay?: number | { show: number; hide: number };
delay?: OverlayDelay;
show?: boolean;
defaultShow?: boolean;
onToggle?: (nextShow: boolean) => void;
flip?: boolean;
overlay: OverlayChildren;

target?: never;
onHide?: never;
show?: never;
}

class RefHolder extends React.Component {
Expand All @@ -28,7 +36,7 @@ class RefHolder extends React.Component {
}
}

function normalizeDelay(delay) {
function normalizeDelay(delay?: OverlayDelay) {
return delay && typeof delay === 'object'
? delay
: {
Expand All @@ -41,12 +49,17 @@ function normalizeDelay(delay) {
// React's built version is broken: https://github.com/facebook/react/issues/4251
// for cases when the trigger is disabled and mouseOut/Over can cause flicker
// moving from one child element to another.
function handleMouseOverOut(handler, e, relatedNative) {
function handleMouseOverOut(
handler: (...args: any[]) => any,
args: [React.MouseEvent, ...any[]],
relatedNative,
) {
const [e] = args;
const target = e.currentTarget;
const related = e.relatedTarget || e.nativeEvent[relatedNative];

if ((!related || related !== target) && !contains(target, related)) {
handler(e);
handler(...args);
}
}

Expand Down Expand Up @@ -74,10 +87,30 @@ const propTypes = {
]),

/**
* The initial visibility state of the Overlay. For more nuanced visibility
* control, consider using the Overlay component directly.
* The visibility of the Overlay. `show` is a _controlled_ prop so should be paired
* with `onToggle` to avoid breaking user interactions.
*
* Manually toggling `show` does **not** wait for `delay` to change the visibility.
*
* @controllable onToggle
*/
show: PropTypes.bool,

/**
* The initial visibility state of the Overlay.
*/
defaultShow: PropTypes.bool,

/**
* A callback that fires when the user triggers a change in tooltip visibility.
*
* `onToggle` is called with the desired next `show`, and generally should be passed
* back to the `show` prop. `onToggle` fires _after_ the configured `delay`
*
* @controllable `show`
*/
onToggle: PropTypes.func,

/**
The initial flip state of the Overlay.
*/
Expand All @@ -104,11 +137,6 @@ const propTypes = {
*/
onHide: PropTypes.oneOf([null]),

/**
* @private
*/
show: PropTypes.oneOf([null]),

/**
* The placement of the Overlay in relation to it's `target`.
*/
Expand Down Expand Up @@ -141,7 +169,11 @@ function OverlayTrigger({
overlay,
children,
popperConfig = {},
defaultShow,

show: propsShow,
defaultShow = false,
onToggle,

delay: propsDelay,
placement,
flip = placement && placement.indexOf('auto') !== -1,
Expand All @@ -150,12 +182,13 @@ function OverlayTrigger({
const triggerNodeRef = useRef(null);
const timeout = useTimeout();
const hoverStateRef = useRef<string>('');
const [show, setShow] = useState(!!defaultShow);

const [show, setShow] = useUncontrolledProp(propsShow, defaultShow, onToggle);

const delay = normalizeDelay(propsDelay);

const child = React.Children.only(children);
// @ts-ignore

const { onFocus, onBlur, onClick } = child.props;

const getTarget = useCallback(
Expand All @@ -175,7 +208,7 @@ function OverlayTrigger({
timeout.set(() => {
if (hoverStateRef.current === 'show') setShow(true);
}, delay.show);
}, [delay.show, timeout]);
}, [delay.show, setShow, timeout]);

const handleHide = useCallback(() => {
timeout.clear();
Expand All @@ -189,42 +222,42 @@ function OverlayTrigger({
timeout.set(() => {
if (hoverStateRef.current === 'hide') setShow(false);
}, delay.hide);
}, [delay.hide, timeout]);
}, [delay.hide, setShow, timeout]);

const handleFocus = useCallback(
(e) => {
(...args: any[]) => {
handleShow();
if (onFocus) onFocus(e);
if (onFocus) onFocus(...args);
},
[handleShow, onFocus],
);

const handleBlur = useCallback(
(e) => {
(...args: any[]) => {
handleHide();
if (onBlur) onBlur(e);
if (onBlur) onBlur(...args);
},
[handleHide, onBlur],
);

const handleClick = useCallback(
(e) => {
setShow((prevShow) => !prevShow);
if (onClick) onClick(e);
(...args: any[]) => {
setShow(!show);
if (onClick) onClick(...args);
},
[onClick],
[onClick, setShow, show],
);

const handleMouseOver = useCallback(
(e) => {
handleMouseOverOut(handleShow, e, 'fromElement');
(...args: [React.MouseEvent, ...any[]]) => {
handleMouseOverOut(handleShow, args, 'fromElement');
},
[handleShow],
);

const handleMouseOut = useCallback(
(e) => {
handleMouseOverOut(handleHide, e, 'toElement');
(...args: [React.MouseEvent, ...any[]]) => {
handleMouseOverOut(handleHide, args, 'toElement');
},
[handleHide],
);
Expand Down
2 changes: 1 addition & 1 deletion src/ToggleButtonGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const propTypes = {
* is `'radio'` or `'checkbox'`, `onChange` will be called with the value or
* array of active values
*
* @controllable values
* @controllable value
*/
onChange: PropTypes.func,

Expand Down
21 changes: 21 additions & 0 deletions test/OverlayTriggerSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ describe('<OverlayTrigger>', () => {
callback.should.have.been.called;
});

it('Should be controllable', () => {
const callback = sinon.spy();

const wrapper = mount(
<OverlayTrigger
show
trigger="click"
onToggle={callback}
overlay={<Div className="test" />}
>
<button type="button">button</button>
</OverlayTrigger>,
);

wrapper.assertSingle('div.test');

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

callback.should.have.been.calledOnce.and.calledWith(false);
});

it('Should show after click trigger', () => {
const wrapper = mount(
<OverlayTrigger trigger="click" overlay={<Div className="test" />}>
Expand Down

0 comments on commit d7dab82

Please sign in to comment.