From 550307160743b70719b1391521096b3fecb480bc Mon Sep 17 00:00:00 2001 From: Jason Quense Date: Mon, 20 Jul 2020 15:14:21 -0400 Subject: [PATCH] feat(OverlayTrigger): allow renderProp and add global shared delays --- package.json | 4 +- src/OverlayTrigger.tsx | 126 ++++++------------ src/useDelayToggleCallback.tsx | 90 +++++++++++++ www/src/components/Heading.js | 15 +-- www/src/components/NavMain.js | 6 +- www/src/components/SideNav.js | 6 +- www/src/components/Toc.js | 6 +- www/src/examples/Overlays/OverlayTrigger.js | 19 --- www/src/examples/Overlays/Trigger.js | 15 +++ .../examples/Overlays/TriggerGlobalTimer.js | 13 ++ .../examples/Overlays/TriggerRenderProp.js | 21 +++ www/src/pages/components/overlays.js | 38 +++++- yarn.lock | 16 +-- 13 files changed, 238 insertions(+), 137 deletions(-) create mode 100644 src/useDelayToggleCallback.tsx delete mode 100644 www/src/examples/Overlays/OverlayTrigger.js create mode 100644 www/src/examples/Overlays/Trigger.js create mode 100644 www/src/examples/Overlays/TriggerGlobalTimer.js create mode 100644 www/src/examples/Overlays/TriggerRenderProp.js diff --git a/package.json b/package.json index f72a467584..2356d6bd39 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 bac4358db5..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', {}> = { - 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 = () => ( - - - -); - -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( + + + , +); 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}} + > + + + ))} +; 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 }) => ( + + )} + , +); 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 01013a46ae..60c419fe02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7644,10 +7644,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" @@ -7668,10 +7668,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"