Skip to content

Commit

Permalink
feat(OverlayTrigger): allow renderProp and add global shared delays
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense committed Jul 20, 2020
1 parent dc5d64f commit 7960405
Show file tree
Hide file tree
Showing 13 changed files with 238 additions and 137 deletions.
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -76,8 +76,8 @@
"invariant": "^2.2.4",
"prop-types": "^15.7.2",
"prop-types-extra": "^1.1.0",
"react-overlays": "^4.0.0",
"react-transition-group": "^4.0.0",
"react-overlays": "^4.1.0",
"react-transition-group": "^4.4.1",
"uncontrollable": "^7.0.0",
"warning": "^4.0.3"
},
Expand Down
126 changes: 40 additions & 86 deletions src/OverlayTrigger.tsx
@@ -1,12 +1,11 @@
import contains from 'dom-helpers/contains';
import PropTypes from 'prop-types';
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 { Modifier } from 'react-overlays/esm/usePopper';
import Overlay, { OverlayChildren, OverlayProps } from './Overlay';
import useDelayToggleCallback from './useDelayToggleCallback';

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

Expand All @@ -16,11 +15,18 @@ export type OverlayInjectedProps = {
onFocus?: (...args: any[]) => any;
};

export type OverlayTriggerRenderProps = OverlayInjectedProps & {
ref: React.Ref<any>;
};

export interface OverlayTriggerProps
extends Omit<OverlayProps, 'children' | 'target'> {
children: React.ReactElement;
children:
| React.ReactElement
| ((props: OverlayTriggerRenderProps) => React.ReactNode);
trigger?: OverlayTriggerType | OverlayTriggerType[];
delay?: OverlayDelay;
globalDelay?: boolean;
show?: boolean;
defaultShow?: boolean;
onToggle?: (nextShow: boolean) => void;
Expand All @@ -37,23 +43,17 @@ class RefHolder extends React.Component {
}
}

function normalizeDelay(delay?: OverlayDelay) {
return delay && typeof delay === 'object'
? delay
: {
show: delay,
hide: delay,
};
}
const normalizeDelay = (delay: OverlayDelay): [number, number] =>
typeof delay === 'object' ? [delay.show, delay.hide] : [delay, delay];

// Simple implementation of mouseEnter and mouseLeave.
// 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: (...args: any[]) => any,
handler: (...args: [React.MouseEvent, ...any[]]) => any,
args: [React.MouseEvent, ...any[]],
relatedNative,
relatedNative: 'fromElement' | 'toElement',
) {
const [e] = args;
const target = e.currentTarget;
Expand Down Expand Up @@ -175,68 +175,52 @@ function OverlayTrigger({
defaultShow = false,
onToggle,

delay: propsDelay,
delay,
globalDelay = false,
placement,
flip = placement && placement.indexOf('auto') !== -1,
...props
}: OverlayTriggerProps) {
const triggerNodeRef = useRef(null);
const timeout = useTimeout();
const hoverStateRef = useRef<string>('');

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

const delay = normalizeDelay(propsDelay);

const child = React.Children.only(children);
const debouncedSetShow = useDelayToggleCallback(
delay == null ? null : normalizeDelay(delay),
setShow,
globalDelay,
);

const { onFocus, onBlur, onClick } = child.props;
const { onFocus, onBlur, onClick } =
typeof children !== 'function'
? React.Children.only(children).props
: ({} as any);

const getTarget = useCallback(
() => safeFindDOMNode(triggerNodeRef.current),
[],
);

const handleShow = useCallback(() => {
timeout.clear();
hoverStateRef.current = 'show';

if (!delay.show) {
setShow(true);
return;
}

timeout.set(() => {
if (hoverStateRef.current === 'show') setShow(true);
}, delay.show);
}, [delay.show, setShow, timeout]);
debouncedSetShow(true);
}, [debouncedSetShow]);

const handleHide = useCallback(() => {
timeout.clear();
hoverStateRef.current = 'hide';

if (!delay.hide) {
setShow(false);
return;
}

timeout.set(() => {
if (hoverStateRef.current === 'hide') setShow(false);
}, delay.hide);
}, [delay.hide, setShow, timeout]);
debouncedSetShow(false);
}, [debouncedSetShow]);

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

const handleBlur = useCallback(
(...args: any[]) => {
handleHide();
if (onBlur) onBlur(...args);
onBlur?.(...args);
},
[handleHide, onBlur],
);
Expand All @@ -263,34 +247,6 @@ function OverlayTrigger({
[handleHide],
);

// We add aria-describedby in the case where the overlay is a role="tooltip"
// for other cases describedby isn't appropriate (e.g. a popover with inputs) so we don't add it.
const ariaModifier: Modifier<'ariaDescribedBy', Record<string, unknown>> = {
name: 'ariaDescribedBy',
enabled: true,
phase: 'afterWrite',
effect: ({ state }) => {
return () => {
if ('removeAttribute' in state.elements.reference)
state.elements.reference.removeAttribute('aria-describedby');
};
},
fn: ({ state }) => {
const { popper, reference } = state.elements;

if (!show || !reference) return;

const role = popper.getAttribute('role') || '';
if (
popper.id &&
role.toLowerCase() === 'tooltip' &&
'setAttribute' in reference
) {
reference.setAttribute('aria-describedby', popper.id);
}
},
};

const triggers: string[] = trigger == null ? [] : [].concat(trigger as any);
const triggerProps: any = {};

Expand All @@ -312,25 +268,23 @@ function OverlayTrigger({
triggerProps.onMouseOut = handleMouseOut;
}

// TODO: fix typing
// @ts-ignore
const modifiers = [ariaModifier].concat(popperConfig.modifiers || []);
return (
<>
<RefHolder ref={triggerNodeRef}>
{cloneElement(child as any, triggerProps)}
</RefHolder>
{typeof children === 'function' ? (
children({ ...triggerProps, ref: triggerNodeRef })
) : (
<RefHolder ref={triggerNodeRef}>
{cloneElement(children as any, triggerProps)}
</RefHolder>
)}
<Overlay
{...props}
popperConfig={{
...popperConfig,
modifiers,
}}
show={show}
onHide={handleHide}
target={getTarget as any}
placement={placement}
flip={flip}
placement={placement}
popperConfig={popperConfig}
target={getTarget as any}
>
{overlay}
</Overlay>
Expand Down
90 changes: 90 additions & 0 deletions src/useDelayToggleCallback.tsx
@@ -0,0 +1,90 @@
import useTimeout from '@restart/hooks/useTimeout';
import useUpdatedRef from '@restart/hooks/useUpdatedRef';
import { useCallback, useRef } from 'react';

export type Delay = [number, number];

type ValueRef = {
entered?: boolean;
pending?: boolean;
hideCallbacks: Array<() => void>;
};

let entered: boolean;
let showHandle: NodeJS.Timeout;
let leaveHandle: NodeJS.Timeout;
const hideCallbacks = [] as Array<(nextValue: boolean) => void>;

function drainHideCallbacks() {
hideCallbacks.forEach((h) => h(false));
hideCallbacks.length = 0;
}

function useGlobalTimeout(
delay: Delay,
callback: (nextValue: boolean) => void,
) {
const [showDelay, leaveDelay] = delay;
return useCallback(
(nextValue: boolean) => {
clearTimeout(showHandle);

if (nextValue === true) {
if (entered) {
clearTimeout(leaveHandle);
drainHideCallbacks();
callback(true);
return;
}

showHandle = setTimeout(() => {
entered = true;
clearTimeout(leaveHandle);
drainHideCallbacks();
callback(true);
}, showDelay);
} else {
hideCallbacks.push(callback);
clearTimeout(leaveHandle);
leaveHandle = setTimeout(() => {
entered = false;
drainHideCallbacks();
}, leaveDelay);
}
},
[showDelay, leaveDelay, callback],
);
}

export default function useDelayToggleCallback(
delay: Delay | null,
callback: (nextValue: boolean) => void,
global = false,
) {
const timeout = useTimeout();

const delayRef = useUpdatedRef(delay);
const pendingValue = useRef<boolean | null>(null);

const globalDebounced = useGlobalTimeout(delay || [0, 0], callback);

const debounced = useCallback(
(nextValue: boolean) => {
timeout.clear();
pendingValue.current = nextValue;
timeout.set(() => {
if (pendingValue.current === nextValue) {
pendingValue.current = null;
callback(nextValue);
}
}, delayRef.current![nextValue ? 0 : 1]);
},
[delayRef, timeout, callback],
);

if (delay == null) {
return callback;
}

return global ? globalDebounced : debounced;
}
15 changes: 2 additions & 13 deletions www/src/components/Heading.js
Expand Up @@ -10,18 +10,7 @@ const styles = css`
composes: __heading from global;
position: relative;
pointer-events: none;
&:before {
display: block;
height: 6rem;
margin-top: -6rem;
visibility: hidden;
content: '';
}
}
.inner {
pointer-events: auto;
scroll-margin-top: 5rem;
}
`;

Expand All @@ -35,7 +24,7 @@ const Heading = ({ h, id, title, className, children, registerNode }) => {
const H = `h${h}`;
return (
<H id={id} className={classNames(className, styles.heading)}>
<div className={styles.inner}>{children}</div>
{children}
</H>
);
};
Expand Down
6 changes: 3 additions & 3 deletions www/src/components/NavMain.js
Expand Up @@ -32,8 +32,8 @@ const Banner = styled(Navbar).attrs({
}
@include media-breakpoint-up(md) {
position: sticky;
top: 0rem;
// position: sticky;
// top: 0rem;
z-index: 1040;
}
`;
Expand All @@ -51,7 +51,7 @@ const StyledNavbar = styled(Navbar).attrs({
@include media-breakpoint-up(md) {
position: sticky;
top: 4rem;
top: 0rem;
z-index: 1040;
}
`;
Expand Down
6 changes: 4 additions & 2 deletions www/src/components/SideNav.js
Expand Up @@ -18,15 +18,17 @@ const MenuButton = styled(Button).attrs({ variant: 'link' })`
const SidePanel = styled('div')`
@import '../css/theme';
$top: 4rem;
composes: d-flex flex-column from global;
background-color: #f7f7f7;
@include media-breakpoint-up(md) {
position: sticky;
top: 4rem;
top: $top;
z-index: 1000;
height: calc(100vh - 4rem);
height: calc(100vh - #{$top});
background-color: #f7f7f7;
border-right: 1px solid $divider;
}
Expand Down

0 comments on commit 7960405

Please sign in to comment.