diff --git a/package.json b/package.json
index ac86f72144..7b627aba82 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/OverlayTrigger.tsx b/src/OverlayTrigger.tsx
index 849fdf23a0..46abc77182 100644
--- a/src/OverlayTrigger.tsx
+++ b/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';
@@ -16,11 +15,18 @@ export type OverlayInjectedProps = {
onFocus?: (...args: any[]) => any;
};
+export type OverlayTriggerRenderProps = OverlayInjectedProps & {
+ ref: React.Ref;
+};
+
export interface OverlayTriggerProps
extends Omit {
- 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;
@@ -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;
@@ -175,22 +175,26 @@ 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('');
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),
@@ -198,37 +202,17 @@ function OverlayTrigger({
);
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],
);
@@ -236,7 +220,7 @@ function OverlayTrigger({
const handleBlur = useCallback(
(...args: any[]) => {
handleHide();
- if (onBlur) onBlur(...args);
+ onBlur?.(...args);
},
[handleHide, onBlur],
);
@@ -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> = {
- 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 = {};
@@ -312,25 +268,23 @@ function OverlayTrigger({
triggerProps.onMouseOut = handleMouseOut;
}
- // TODO: fix typing
- // @ts-ignore
- const modifiers = [ariaModifier].concat(popperConfig.modifiers || []);
return (
<>
-
- {cloneElement(child as any, triggerProps)}
-
+ {typeof children === 'function' ? (
+ children({ ...triggerProps, ref: triggerNodeRef })
+ ) : (
+
+ {cloneElement(children as any, triggerProps)}
+
+ )}
{overlay}
diff --git a/src/useDelayToggleCallback.tsx b/src/useDelayToggleCallback.tsx
new file mode 100644
index 0000000000..f0e8f5920f
--- /dev/null
+++ b/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(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;
+}
diff --git a/www/src/components/Heading.js b/www/src/components/Heading.js
index e84a131275..590bb47cbd 100644
--- a/www/src/components/Heading.js
+++ b/www/src/components/Heading.js
@@ -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;
}
`;
@@ -35,7 +24,7 @@ const Heading = ({ h, id, title, className, children, registerNode }) => {
const H = `h${h}`;
return (
- {children}
+ {children}
);
};
diff --git a/www/src/components/NavMain.js b/www/src/components/NavMain.js
index 48cf6c08f7..cb0d340ec8 100644
--- a/www/src/components/NavMain.js
+++ b/www/src/components/NavMain.js
@@ -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;
}
`;
@@ -51,7 +51,7 @@ const StyledNavbar = styled(Navbar).attrs({
@include media-breakpoint-up(md) {
position: sticky;
- top: 4rem;
+ top: 0rem;
z-index: 1040;
}
`;
diff --git a/www/src/components/SideNav.js b/www/src/components/SideNav.js
index 8d0b564c2c..f1df14219c 100644
--- a/www/src/components/SideNav.js
+++ b/www/src/components/SideNav.js
@@ -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;
}
diff --git a/www/src/components/Toc.js b/www/src/components/Toc.js
index 3b6df3a84b..42ecb052cf 100644
--- a/www/src/components/Toc.js
+++ b/www/src/components/Toc.js
@@ -6,10 +6,12 @@ export const TocContext = React.createContext();
const SidePanel = styled('div')`
@import '../css/theme';
+ $top: 4rem;
+
order: 2;
position: sticky;
- top: 4rem;
- height: calc(100vh - 4rem);
+ top: $top;
+ height: calc(100vh - #{$top});
padding-top: 1.5rem;
padding-bottom: 1.5rem;
font-size: 0.875rem;
diff --git a/www/src/examples/Overlays/OverlayTrigger.js b/www/src/examples/Overlays/OverlayTrigger.js
deleted file mode 100644
index cee3c9f162..0000000000
--- a/www/src/examples/Overlays/OverlayTrigger.js
+++ /dev/null
@@ -1,19 +0,0 @@
-function renderTooltip(props) {
- return (
-
- Simple tooltip
-
- );
-}
-
-const Example = () => (
-
- Hover me to see
-
-);
-
-render( );
diff --git a/www/src/examples/Overlays/Trigger.js b/www/src/examples/Overlays/Trigger.js
new file mode 100644
index 0000000000..c79f5cf470
--- /dev/null
+++ b/www/src/examples/Overlays/Trigger.js
@@ -0,0 +1,15 @@
+const renderTooltip = (props) => (
+
+ Simple tooltip
+
+);
+
+render(
+
+ Hover me to see
+ ,
+);
diff --git a/www/src/examples/Overlays/TriggerGlobalTimer.js b/www/src/examples/Overlays/TriggerGlobalTimer.js
new file mode 100644
index 0000000000..ac5154c65c
--- /dev/null
+++ b/www/src/examples/Overlays/TriggerGlobalTimer.js
@@ -0,0 +1,13 @@
+
+ {['Tool 1', 'Tool 2', 'Tool 3', 'Tool 4'].map((text, idx) => (
+ {text}}
+ >
+ {idx + 1}
+
+ ))}
+ ;
diff --git a/www/src/examples/Overlays/TriggerRenderProp.js b/www/src/examples/Overlays/TriggerRenderProp.js
new file mode 100644
index 0000000000..e0c6d3a4fb
--- /dev/null
+++ b/www/src/examples/Overlays/TriggerRenderProp.js
@@ -0,0 +1,21 @@
+render(
+ Check out this avatar}
+ >
+ {({ ref, ...triggerHandler }) => (
+
+
+ Hover to see
+
+ )}
+ ,
+);
diff --git a/www/src/pages/components/overlays.js b/www/src/pages/components/overlays.js
index 58cfbd3b53..f4a3090b96 100644
--- a/www/src/pages/components/overlays.js
+++ b/www/src/pages/components/overlays.js
@@ -5,10 +5,13 @@ import { css } from 'astroturf';
import LinkedHeading from '../../components/LinkedHeading';
import ComponentApi from '../../components/ComponentApi';
import ReactPlayground from '../../components/ReactPlayground';
+import Callout from '../../components/Callout';
import Disabled from '../../examples/Overlays/Disabled';
import Overlay from '../../examples/Overlays/Overlay';
-import OverlayTrigger from '../../examples/Overlays/OverlayTrigger';
+import OverlayTrigger from '../../examples/Overlays/Trigger';
+import TriggerRenderProp from '../../examples/Overlays/TriggerRenderProp';
+import TriggerGlobalTimer from '../../examples/Overlays/TriggerGlobalTimer';
import PopoverBasic from '../../examples/Overlays/PopoverBasic';
import PopoverContained from '../../examples/Overlays/PopoverContained';
import PopoverPositioned from '../../examples/Overlays/PopoverPositioned';
@@ -91,7 +94,7 @@ export default withLayout(function TooltipSection({ data }) {
-
+
OverlayTrigger
@@ -116,6 +119,37 @@ export default withLayout(function TooltipSection({ data }) {
+
+ Customizing trigger behavior
+
+
+
+ For more advanced behaviors {''}
accepts a
+ function child that passes in the injected ref
and event
+ handlers that coorespond to the configured trigger
prop.
+
+
+ You can manually apply the props to any element you want or split them
+ up. The example below shows how to position the overlay to a different
+ element than the one that triggers its visibility.
+
+
+ Pro Tip: Using the function form of OverlayTrigger
+ avoids a React.findDOMNode
call, for those trying to be
+ strict mode compliant.
+
+
+
+
+ Shared delays
+
+
+ For groups of controls with tooltips using globalDelay
will
+ unify the show and hide delays of tooltips such that the show delay is
+ skipped when quickly switching between triggers with tooltips.
+
+
+
Tooltips
diff --git a/yarn.lock b/yarn.lock
index 9bd663185c..797efaa47d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7601,10 +7601,10 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
-react-overlays@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-4.0.0.tgz#7fbcb60d12fee3733e9e4dd216b225e94fb3befe"
- integrity sha512-LpznWocwgeB5oWKg6cDdkqKP7MbX4ClKbJqgZGUMXPRBBYcqrgM6TjjZ/8DeurNU//GuqwQMjhmo/JVma4XEWw==
+react-overlays@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-4.1.0.tgz#755a890519b02e3904845172d5223ff2dfb1bb29"
+ integrity sha512-vdRpnKe0ckWOOD9uWdqykLUPHLPndIiUV7XfEKsi5008xiyHCfL8bxsx4LbMrfnxW1LzRthLyfy50XYRFNQqqw==
dependencies:
"@babel/runtime" "^7.4.5"
"@popperjs/core" "^2.0.0"
@@ -7625,10 +7625,10 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.13.1:
react-is "^16.8.6"
scheduler "^0.19.1"
-react-transition-group@^4.0.0:
- version "4.3.0"
- resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.3.0.tgz#fea832e386cf8796c58b61874a3319704f5ce683"
- integrity sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw==
+react-transition-group@^4.4.1:
+ version "4.4.1"
+ resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
+ integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"