From 1ab771e06a186b7133df9035e8a83f0b80b19368 Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Mon, 18 Mar 2024 14:00:55 -0700 Subject: [PATCH 1/8] Initial Tooltip update to support hybrid (un)controlled state --- .../src/components/Tooltip/Tooltip.tsx | 340 ++++++++++-------- 1 file changed, 200 insertions(+), 140 deletions(-) diff --git a/polaris-react/src/components/Tooltip/Tooltip.tsx b/polaris-react/src/components/Tooltip/Tooltip.tsx index 635e3977fed..327291735c7 100644 --- a/polaris-react/src/components/Tooltip/Tooltip.tsx +++ b/polaris-react/src/components/Tooltip/Tooltip.tsx @@ -7,7 +7,6 @@ import type { import {Portal} from '../Portal'; import {useEphemeralPresenceManager} from '../../utilities/ephemeral-presence-manager'; import {findFirstFocusableNode} from '../../utilities/focus'; -import {useToggle} from '../../utilities/use-toggle'; import {classNames} from '../../utilities/css'; import {TooltipOverlay} from './components'; @@ -23,7 +22,14 @@ export interface TooltipProps { children?: React.ReactNode; /** The content to display within the tooltip */ content: React.ReactNode; - /** Toggle whether the tooltip is visible */ + /** Toggle whether the tooltip is visible. */ + open?: boolean; + /** Toggle whether the tooltip is visible initially */ + defaultOpen?: boolean; + /** + * Toggle whether the tooltip is visible initially + * @deprecated Use `defaultOpen` instead + */ active?: boolean; /** Delay in milliseconds while hovering over an element before the tooltip is visible */ hoverDelay?: number; @@ -74,6 +80,8 @@ export function Tooltip({ children, content, dismissOnMouseOut, + open: openProp, + defaultOpen: defaultOpenProp, active: originalActive, hoverDelay, preferredPosition = 'above', @@ -84,148 +92,148 @@ export function Tooltip({ borderRadius: borderRadiusProp, zIndexOverride, hasUnderline, - persistOnClick, + persistOnClick = false, onOpen, onClose, }: TooltipProps) { const borderRadius = borderRadiusProp || '200'; + const isControlled = typeof openProp === 'boolean'; + const defaultOpen = defaultOpenProp ?? originalActive ?? false; + const [open, setOpen] = useState(defaultOpen); + const [isPersisting, setIsPersisting] = useState( + defaultOpen && persistOnClick, + ); + const [shouldAnimate, setShouldAnimate] = useState(!defaultOpen); + + const isMouseEntered = useRef(false); + const hoverDelayTimeout = useRef(null); + const hoverOutTimeout = useRef(null); + const id = useId(); const WrapperComponent: any = activatorWrapper; - const { - value: active, - setTrue: setActiveTrue, - setFalse: handleBlur, - } = useToggle(Boolean(originalActive)); - - const {value: persist, toggle: togglePersisting} = useToggle( - Boolean(originalActive) && Boolean(persistOnClick), + const activatorContainer = useRef(null); + const [activatorNode, setActivatorNode] = useState(null); + const wrapperClassNames = classNames( + WrapperComponent === 'div' && styles.TooltipContainer, + hasUnderline && styles.HasUnderline, ); - const [activatorNode, setActivatorNode] = useState(null); const {presenceList, addPresence, removePresence} = useEphemeralPresenceManager(); - const id = useId(); - const activatorContainer = useRef(null); - const mouseEntered = useRef(false); - const [shouldAnimate, setShouldAnimate] = useState(Boolean(!originalActive)); - const hoverDelayTimeout = useRef(null); - const hoverOutTimeout = useRef(null); - - const handleFocus = useCallback(() => { - if (originalActive !== false) { - setActiveTrue(); + const clearHoverDelayTimeout = useCallback(() => { + if (hoverDelayTimeout.current) { + clearTimeout(hoverDelayTimeout.current); + hoverDelayTimeout.current = null; } - }, [originalActive, setActiveTrue]); - - useEffect(() => { - const firstFocusable = activatorContainer.current - ? findFirstFocusableNode(activatorContainer.current) - : null; - const accessibilityNode = firstFocusable || activatorContainer.current; - - if (!accessibilityNode) return; - - accessibilityNode.tabIndex = 0; - accessibilityNode.setAttribute('aria-describedby', id); - accessibilityNode.setAttribute('data-polaris-tooltip-activator', 'true'); - }, [id, children]); + }, []); - useEffect(() => { - return () => { - if (hoverDelayTimeout.current) { - clearTimeout(hoverDelayTimeout.current); - } - if (hoverOutTimeout.current) { - clearTimeout(hoverOutTimeout.current); - } - }; + const clearHoverOutTimeout = useCallback(() => { + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + hoverOutTimeout.current = null; + } }, []); const handleOpen = useCallback(() => { - setShouldAnimate(!presenceList.tooltip && !active); + if (open) return; + + if (!isControlled && originalActive !== false) { + setShouldAnimate(!open && !presenceList.tooltip); + setOpen(true); + addPresence('tooltip'); + } + onOpen?.(); - addPresence('tooltip'); - }, [addPresence, presenceList.tooltip, onOpen, active]); + }, [ + addPresence, + isControlled, + onOpen, + open, + originalActive, + presenceList.tooltip, + ]); const handleClose = useCallback(() => { + if (!open) return; + + if (!isControlled) { + setOpen(false); + removePresence('tooltip'); + } + onClose?.(); - setShouldAnimate(false); + }, [isControlled, open, onClose, removePresence]); + + const handleMouseEnter = useCallback(() => { + // https://github.com/facebook/react/issues/10109 + // Mouseenter event not triggered when cursor moves from disabled button + if (isMouseEntered.current) return; + isMouseEntered.current = true; + + clearHoverOutTimeout(); + + if (open) return; + + if (hoverDelay && !presenceList.tooltip) { + hoverDelayTimeout.current = setTimeout(() => { + handleOpen(); + }, hoverDelay); + } else { + handleOpen(); + } + }, [ + clearHoverOutTimeout, + handleOpen, + hoverDelay, + open, + presenceList.tooltip, + ]); + + const handleMouseLeave = useCallback(() => { + isMouseEntered.current = false; + + clearHoverDelayTimeout(); + + if (isPersisting) return; + hoverOutTimeout.current = setTimeout(() => { - removePresence('tooltip'); + handleClose(); }, HOVER_OUT_TIMEOUT); - }, [removePresence, onClose]); + }, [clearHoverDelayTimeout, handleClose, isPersisting]); + + const handleFocus = useCallback(() => { + if (open) return; + + clearHoverDelayTimeout(); + + handleOpen(); + }, [clearHoverDelayTimeout, handleOpen, open]); + + const handleBlur = useCallback(() => { + if (isPersisting) setIsPersisting(false); + + handleClose(); + }, [handleClose, isPersisting, setIsPersisting]); const handleKeyUp = useCallback( (event: React.KeyboardEvent) => { if (event.key !== 'Escape') return; - handleClose?.(); - handleBlur(); - persistOnClick && togglePersisting(); - }, - [handleBlur, handleClose, persistOnClick, togglePersisting], - ); - useEffect(() => { - if (originalActive === false && active) { - handleClose(); - handleBlur(); - } - }, [originalActive, active, handleClose, handleBlur]); - - const portal = activatorNode ? ( - - - {content} - - - ) : null; + if (isPersisting) setIsPersisting(false); - const wrapperClassNames = classNames( - activatorWrapper === 'div' && styles.TooltipContainer, - hasUnderline && styles.HasUnderline, + handleClose(); + }, + [handleClose, isPersisting, setIsPersisting], ); - return ( - { - handleOpen(); - handleFocus(); - }} - onBlur={() => { - handleClose(); - handleBlur(); - - if (persistOnClick) { - togglePersisting(); - } - }} - onMouseLeave={handleMouseLeave} - onMouseOver={handleMouseEnterFix} - onMouseDown={persistOnClick ? togglePersisting : undefined} - ref={setActivator} - onKeyUp={handleKeyUp} - className={wrapperClassNames} - > - {children} - {portal} - - ); + const handleMouseDown = useCallback(() => { + if (!open) return; - function setActivator(node: HTMLElement | null) { + setIsPersisting((prevIsPersisting) => !prevIsPersisting); + }, [open, setIsPersisting]); + + const setActivator = useCallback((node: HTMLElement | null) => { const activatorContainerRef: any = activatorContainer; if (node == null) { activatorContainerRef.current = null; @@ -237,40 +245,92 @@ export function Tooltip({ setActivatorNode(node.firstElementChild); activatorContainerRef.current = node; - } + }, []); - function handleMouseEnter() { - mouseEntered.current = true; - if (hoverDelay && !presenceList.tooltip) { - hoverDelayTimeout.current = setTimeout(() => { - handleOpen(); - handleFocus(); - }, hoverDelay); + // Sync controlled state with uncontrolled state + useEffect(() => { + if (!isControlled || openProp === open) return; + + clearHoverDelayTimeout(); + clearHoverOutTimeout(); + + if (openProp) { + setShouldAnimate(!open && !presenceList.tooltip); + setOpen(true); + addPresence('tooltip'); } else { - handleOpen(); - handleFocus(); + setShouldAnimate(false); + setOpen(false); + removePresence('tooltip'); } - } + }, [ + addPresence, + clearHoverDelayTimeout, + clearHoverOutTimeout, + isControlled, + open, + openProp, + presenceList.tooltip, + removePresence, + ]); + + // Clear timeouts on unmount + useEffect( + () => () => { + clearHoverDelayTimeout(); + clearHoverOutTimeout(); + }, + [clearHoverDelayTimeout, clearHoverOutTimeout], + ); - function handleMouseLeave() { - if (hoverDelayTimeout.current) { - clearTimeout(hoverDelayTimeout.current); - hoverDelayTimeout.current = null; - } + // Add `tabIndex` and other a11y attributes to the first focusable node + useEffect(() => { + const firstFocusable = activatorContainer.current + ? findFirstFocusableNode(activatorContainer.current) + : null; + const accessibilityNode = firstFocusable || activatorContainer.current; - mouseEntered.current = false; - handleClose(); + if (!accessibilityNode) return; - if (!persist) { - handleBlur(); - } - } + accessibilityNode.tabIndex = 0; + accessibilityNode.setAttribute('aria-describedby', id); + accessibilityNode.setAttribute('data-polaris-tooltip-activator', 'true'); + }, [id, children]); - // https://github.com/facebook/react/issues/10109 - // Mouseenter event not triggered when cursor moves from disabled button - function handleMouseEnterFix() { - !mouseEntered.current && handleMouseEnter(); - } + return ( + + {children} + {activatorNode && ( + + + {content} + + + )} + + ); } function noop() {} From fccd38dc95e540945c559a4d5ab6ed4873b23887 Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Fri, 22 Mar 2024 14:45:13 -0700 Subject: [PATCH 2/8] Remove ephemeral presence manager in favor of hysteresis pattern --- .../src/components/Tooltip/Tooltip.tsx | 146 ++++++++---------- polaris-react/src/components/Tooltip/utils.ts | 70 +++++++++ 2 files changed, 133 insertions(+), 83 deletions(-) create mode 100644 polaris-react/src/components/Tooltip/utils.ts diff --git a/polaris-react/src/components/Tooltip/Tooltip.tsx b/polaris-react/src/components/Tooltip/Tooltip.tsx index 327291735c7..935d7646256 100644 --- a/polaris-react/src/components/Tooltip/Tooltip.tsx +++ b/polaris-react/src/components/Tooltip/Tooltip.tsx @@ -5,13 +5,13 @@ import type { } from '@shopify/polaris-tokens'; import {Portal} from '../Portal'; -import {useEphemeralPresenceManager} from '../../utilities/ephemeral-presence-manager'; import {findFirstFocusableNode} from '../../utilities/focus'; import {classNames} from '../../utilities/css'; import {TooltipOverlay} from './components'; import type {TooltipOverlayProps} from './components'; import styles from './Tooltip.module.css'; +import {Timeout, useTimeout} from './utils'; export type Width = 'default' | 'wide'; export type Padding = 'default' | Extract; @@ -74,7 +74,18 @@ export interface TooltipProps { onClose?(): void; } -const HOVER_OUT_TIMEOUT = 150; +/** + * The [hysteresis](https://en.wikipedia.org/wiki/Hysteresis) flag is used to influence the `hoverDelay` and `animateOpen` behavior of the Tooltip. + * Adapted from the [MUI Tooltip component](https://github.com/mui/material-ui/blob/822a7e69c062a5e4f99f02b4a3aadc7fb51c2ce9/packages/mui-material/src/Tooltip/Tooltip.js#L217C1-L218C38a). + */ +let hysteresisOpen = false; +const hysteresisTimer = new Timeout(); +const HYSTERESIS_TIMEOUT = 150; + +export function testResetHysteresis() { + hysteresisOpen = false; + hysteresisTimer.clear(); +} export function Tooltip({ children, @@ -99,15 +110,14 @@ export function Tooltip({ const borderRadius = borderRadiusProp || '200'; const isControlled = typeof openProp === 'boolean'; const defaultOpen = defaultOpenProp ?? originalActive ?? false; + const animateOpen = useRef(!defaultOpen && !hysteresisOpen); const [open, setOpen] = useState(defaultOpen); const [isPersisting, setIsPersisting] = useState( defaultOpen && persistOnClick, ); - const [shouldAnimate, setShouldAnimate] = useState(!defaultOpen); const isMouseEntered = useRef(false); - const hoverDelayTimeout = useRef(null); - const hoverOutTimeout = useRef(null); + const hoverDelayTimer = useTimeout(); const id = useId(); const WrapperComponent: any = activatorWrapper; @@ -118,52 +128,36 @@ export function Tooltip({ hasUnderline && styles.HasUnderline, ); - const {presenceList, addPresence, removePresence} = - useEphemeralPresenceManager(); - - const clearHoverDelayTimeout = useCallback(() => { - if (hoverDelayTimeout.current) { - clearTimeout(hoverDelayTimeout.current); - hoverDelayTimeout.current = null; - } - }, []); - - const clearHoverOutTimeout = useCallback(() => { - if (hoverOutTimeout.current) { - clearTimeout(hoverOutTimeout.current); - hoverOutTimeout.current = null; - } - }, []); - const handleOpen = useCallback(() => { if (open) return; if (!isControlled && originalActive !== false) { - setShouldAnimate(!open && !presenceList.tooltip); + hysteresisTimer.clear(); + + animateOpen.current = !hysteresisOpen; + hysteresisOpen = true; + setOpen(true); - addPresence('tooltip'); } onOpen?.(); - }, [ - addPresence, - isControlled, - onOpen, - open, - originalActive, - presenceList.tooltip, - ]); + }, [isControlled, onOpen, open, originalActive]); const handleClose = useCallback(() => { if (!open) return; if (!isControlled) { + hysteresisTimer.start(HYSTERESIS_TIMEOUT, () => { + hysteresisOpen = false; + }); + + animateOpen.current = false; + setOpen(false); - removePresence('tooltip'); } onClose?.(); - }, [isControlled, open, onClose, removePresence]); + }, [open, isControlled, onClose]); const handleMouseEnter = useCallback(() => { // https://github.com/facebook/react/issues/10109 @@ -171,44 +165,35 @@ export function Tooltip({ if (isMouseEntered.current) return; isMouseEntered.current = true; - clearHoverOutTimeout(); - if (open) return; - if (hoverDelay && !presenceList.tooltip) { - hoverDelayTimeout.current = setTimeout(() => { + if (hoverDelay && !hysteresisOpen) { + hoverDelayTimer.start(hoverDelay, () => { handleOpen(); - }, hoverDelay); + }); } else { + hoverDelayTimer.clear(); handleOpen(); } - }, [ - clearHoverOutTimeout, - handleOpen, - hoverDelay, - open, - presenceList.tooltip, - ]); + }, [open, hoverDelayTimer, hoverDelay, handleOpen]); const handleMouseLeave = useCallback(() => { isMouseEntered.current = false; - clearHoverDelayTimeout(); + hoverDelayTimer.clear(); - if (isPersisting) return; + if (isPersisting || !open) return; - hoverOutTimeout.current = setTimeout(() => { - handleClose(); - }, HOVER_OUT_TIMEOUT); - }, [clearHoverDelayTimeout, handleClose, isPersisting]); + handleClose(); + }, [hoverDelayTimer, isPersisting, open, handleClose]); const handleFocus = useCallback(() => { if (open) return; - clearHoverDelayTimeout(); + hoverDelayTimer.clear(); handleOpen(); - }, [clearHoverDelayTimeout, handleOpen, open]); + }, [handleOpen, hoverDelayTimer, open]); const handleBlur = useCallback(() => { if (isPersisting) setIsPersisting(false); @@ -228,10 +213,10 @@ export function Tooltip({ ); const handleMouseDown = useCallback(() => { - if (!open) return; + if (!persistOnClick) return; setIsPersisting((prevIsPersisting) => !prevIsPersisting); - }, [open, setIsPersisting]); + }, [persistOnClick]); const setActivator = useCallback((node: HTMLElement | null) => { const activatorContainerRef: any = activatorContainer; @@ -251,37 +236,32 @@ export function Tooltip({ useEffect(() => { if (!isControlled || openProp === open) return; - clearHoverDelayTimeout(); - clearHoverOutTimeout(); + hoverDelayTimer.clear(); + + if (openProp && !originalActive) { + hysteresisTimer.clear(); + + animateOpen.current = !hysteresisOpen; + hysteresisOpen = true; - if (openProp) { - setShouldAnimate(!open && !presenceList.tooltip); setOpen(true); - addPresence('tooltip'); } else { - setShouldAnimate(false); + hysteresisTimer.start(HYSTERESIS_TIMEOUT, () => { + hysteresisOpen = false; + }); + + animateOpen.current = false; + setOpen(false); - removePresence('tooltip'); } - }, [ - addPresence, - clearHoverDelayTimeout, - clearHoverOutTimeout, - isControlled, - open, - openProp, - presenceList.tooltip, - removePresence, - ]); - - // Clear timeouts on unmount - useEffect( - () => () => { - clearHoverDelayTimeout(); - clearHoverOutTimeout(); - }, - [clearHoverDelayTimeout, clearHoverOutTimeout], - ); + }, [hoverDelayTimer, isControlled, open, openProp, originalActive]); + + // Note: Remove this effect along with the `active` prop in Polaris v14 + useEffect(() => { + if (originalActive === false && open) { + handleClose(); + } + }, [originalActive, handleClose, handleBlur, open]); // Add `tabIndex` and other a11y attributes to the first focusable node useEffect(() => { @@ -323,7 +303,7 @@ export function Tooltip({ padding={padding} borderRadius={borderRadius} zIndexOverride={zIndexOverride} - instant={!shouldAnimate} + instant={!animateOpen.current} > {content} diff --git a/polaris-react/src/components/Tooltip/utils.ts b/polaris-react/src/components/Tooltip/utils.ts new file mode 100644 index 00000000000..1847c865fa7 --- /dev/null +++ b/polaris-react/src/components/Tooltip/utils.ts @@ -0,0 +1,70 @@ +import {useEffect, useRef} from 'react'; + +/** + * Adapted from https://github.com/mui/material-ui/blob/0102a9579628d48d784511a562b7b72f0f51847e/packages/mui-utils/src/useTimeout/useTimeout.ts#L35 + */ +export function useTimeout() { + const timeoutRef = useLazyRef(Timeout.create); + + /* eslint-disable react-hooks/exhaustive-deps */ + useEffect(timeoutRef.current.clearEffect, []); + /* eslint-enable react-hooks/exhaustive-deps */ + + return timeoutRef.current; +} + +/** + * Adapted from https://github.com/mui/material-ui/blob/0102a9579628d48d784511a562b7b72f0f51847e/packages/mui-utils/src/useTimeout/useTimeout.ts#L5 + */ +export class Timeout { + static create() { + return new Timeout(); + } + + id: ReturnType | null = null; + + /** + * Executes `fn` after `delay`, clearing any previously scheduled call. + */ + start = (delay: number, fn: () => void) => { + this.clear(); + + this.id = setTimeout(() => { + this.id = null; + fn(); + }, delay); + }; + + clear = () => { + if (this.id === null) return; + + clearTimeout(this.id); + this.id = null; + }; + + clearEffect = () => this.clear; +} + +const uninitializedRef = {}; + +/** + * A React.useRef() that is initialized lazily with a function. Note that it accepts an optional + * initialization argument, so the initialization function doesn't need to be an inline closure. + * + * Adapted from https://github.com/mui/material-ui/blob/next/packages/mui-utils/src/useLazyRef/useLazyRef.ts + * + * @usage + * const lazyRef = useLazyRef(sortColumns, columns) + */ +export function useLazyRef( + init: (arg?: InitArg) => LazyRef, + initArg?: InitArg, +) { + const lazyRef = useRef(uninitializedRef as unknown as LazyRef); + + if (lazyRef.current === uninitializedRef) { + lazyRef.current = init(initArg); + } + + return lazyRef; +} From 0a71758b6b575b3424435d1a2574dc6d98c74242 Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Fri, 22 Mar 2024 14:45:43 -0700 Subject: [PATCH 3/8] Initial test updates --- .../components/Tooltip/tests/Tooltip.test.tsx | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx b/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx index b17a4202214..42a4435d49a 100644 --- a/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx +++ b/polaris-react/src/components/Tooltip/tests/Tooltip.test.tsx @@ -2,10 +2,14 @@ import React from 'react'; import {mountWithApp} from 'tests/utilities'; import {Link} from '../../Link'; -import {Tooltip} from '../Tooltip'; +import {testResetHysteresis, Tooltip} from '../Tooltip'; import {TooltipOverlay} from '../components'; describe('', () => { + beforeEach(() => { + testResetHysteresis(); + }); + it('renders its children', () => { const tooltip = mountWithApp( @@ -35,16 +39,94 @@ describe('', () => { expect(tooltipActive.find(TooltipOverlay)).toContainReactComponent('div'); }); + it('renders initially when defaultOpen is true', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div'); + }); + it('does not render when active is false', () => { - const tooltipActive = mountWithApp( + const tooltip = mountWithApp( link content , ); - expect(tooltipActive.find(TooltipOverlay)).not.toContainReactComponent( - 'div', + expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div'); + }); + + it('does not render initially when defaultOpen is false', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div'); + }); + + it('renders when open is true', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div'); + }); + + it('does not render when open is false', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div'); + }); + + it('renders when open is true and active is false', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div'); + }); + + it('renders when open is true and defaultOpen is false', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div'); + }); + + it('does not render when open is false and active is true', () => { + const tooltip = mountWithApp( + + link content + , + ); + + expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div'); + }); + + it('does not render when open is false and defaultOpen is true', () => { + const tooltip = mountWithApp( + + link content + , ); + + expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div'); }); it('does not render when active prop is updated to false', () => { @@ -61,9 +143,23 @@ describe('', () => { expect(tooltip.find(TooltipOverlay)).not.toContainReactComponent('div'); }); + it('renders when defaultOpen prop is updated to false', () => { + const tooltip = mountWithApp( + + link content + , + ); + + findWrapperComponent(tooltip)!.trigger('onMouseOver'); + expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div'); + + tooltip.setProps({defaultOpen: false}); + expect(tooltip.find(TooltipOverlay)).toContainReactComponent('div'); + }); + it('passes preventInteraction to TooltipOverlay when dismissOnMouseOut is true', () => { const tooltip = mountWithApp( - + link content , ); @@ -118,7 +214,7 @@ describe('', () => { it('closes itself when escape is pressed on keyup', () => { const tooltip = mountWithApp( - +
Order #1001
, ); @@ -132,11 +228,11 @@ describe('', () => { }); }); - it('does not call onOpen when initially activated', () => { + it('does not call onOpen initially when defaultOpen is true', () => { const openSpy = jest.fn(); const tooltip = mountWithApp( @@ -151,11 +247,11 @@ describe('', () => { expect(openSpy).not.toHaveBeenCalled(); }); - it('calls onClose when initially activated and then closed', () => { + it('calls onClose initially when defaultOpen is true and then closed', () => { const closeSpy = jest.fn(); const tooltip = mountWithApp( @@ -216,7 +312,7 @@ describe('', () => { const closeSpy = jest.fn(); const tooltip = mountWithApp( - + link content , ); @@ -234,7 +330,7 @@ describe('', () => { const closeSpy = jest.fn(); const tooltip = mountWithApp( - + link content , ); @@ -255,7 +351,7 @@ describe('', () => { link content , @@ -267,7 +363,7 @@ describe('', () => { it("passes 'zIndexOverride' to TooltipOverlay", () => { const tooltip = mountWithApp( - + link content , ); From 8898d66ef56a0b3e41ce5880e12a29d07425eadd Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Fri, 22 Mar 2024 14:57:27 -0700 Subject: [PATCH 4/8] Update references to use permalinks --- polaris-react/src/components/Tooltip/Tooltip.tsx | 2 +- polaris-react/src/components/Tooltip/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/polaris-react/src/components/Tooltip/Tooltip.tsx b/polaris-react/src/components/Tooltip/Tooltip.tsx index 935d7646256..ea2a0ddbcea 100644 --- a/polaris-react/src/components/Tooltip/Tooltip.tsx +++ b/polaris-react/src/components/Tooltip/Tooltip.tsx @@ -76,7 +76,7 @@ export interface TooltipProps { /** * The [hysteresis](https://en.wikipedia.org/wiki/Hysteresis) flag is used to influence the `hoverDelay` and `animateOpen` behavior of the Tooltip. - * Adapted from the [MUI Tooltip component](https://github.com/mui/material-ui/blob/822a7e69c062a5e4f99f02b4a3aadc7fb51c2ce9/packages/mui-material/src/Tooltip/Tooltip.js#L217C1-L218C38a). + * Adapted from the [MUI Tooltip component](https://github.com/mui/material-ui/blob/822a7e69c062a5e4f99f02b4a3aadc7fb51c2ce9/packages/mui-material/src/Tooltip/Tooltip.js#L217-L218) */ let hysteresisOpen = false; const hysteresisTimer = new Timeout(); diff --git a/polaris-react/src/components/Tooltip/utils.ts b/polaris-react/src/components/Tooltip/utils.ts index 1847c865fa7..5dcc971d675 100644 --- a/polaris-react/src/components/Tooltip/utils.ts +++ b/polaris-react/src/components/Tooltip/utils.ts @@ -51,7 +51,7 @@ const uninitializedRef = {}; * A React.useRef() that is initialized lazily with a function. Note that it accepts an optional * initialization argument, so the initialization function doesn't need to be an inline closure. * - * Adapted from https://github.com/mui/material-ui/blob/next/packages/mui-utils/src/useLazyRef/useLazyRef.ts + * Adapted from https://github.com/mui/material-ui/blob/0102a9579628d48d784511a562b7b72f0f51847e/packages/mui-utils/src/useLazyRef/useLazyRef.ts#L13 * * @usage * const lazyRef = useLazyRef(sortColumns, columns) From 979b21f34329e39248ed615bdd7887ea51fadc3f Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Fri, 22 Mar 2024 15:10:15 -0700 Subject: [PATCH 5/8] Update stories to use defaultOpen and add (un)controlled examples --- .../components/Tooltip/Tooltip.stories.tsx | 86 ++++++++++++++++--- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/polaris-react/src/components/Tooltip/Tooltip.stories.tsx b/polaris-react/src/components/Tooltip/Tooltip.stories.tsx index 173bf0a2fc3..4869f0b286d 100644 --- a/polaris-react/src/components/Tooltip/Tooltip.stories.tsx +++ b/polaris-react/src/components/Tooltip/Tooltip.stories.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {QuestionCircleIcon} from '@shopify/polaris-icons'; import type {ComponentMeta} from '@storybook/react'; import { @@ -43,7 +43,7 @@ export function All() { export function Default() { return ( - + Order #1001 @@ -57,7 +57,7 @@ export function PreferredPosition() { @@ -75,7 +75,7 @@ export function PreferredPosition() { @@ -102,7 +102,7 @@ export function Width() { @@ -119,7 +119,7 @@ export function Width() { @@ -145,7 +145,7 @@ export function Padding() { return ( - + Tooltip with @@ -160,7 +160,7 @@ export function Padding() { @@ -187,7 +187,7 @@ export function BorderRadius() { @@ -204,7 +204,7 @@ export function BorderRadius() { @@ -307,7 +307,7 @@ export function ActivatorAsDiv() { return ( @@ -462,7 +462,7 @@ export function Alignment() { export function HasUnderline() { return ( - + Order #1001 @@ -486,6 +486,66 @@ export function PersistOnClick() { ); } +export function WithControlledState() { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + + + + The tooltip is {String(open)} + + + + ); +} + +export function WithUncontrolledState() { + return ( + + + + + Default open true + + + + + Default open false + + + + + Default open undefined + + + + + ); +} + export function ActiveStates() { const [popoverActive, setPopoverActive] = useState(false); const [tooltipActive, setTooltipActive] = @@ -558,7 +618,7 @@ export function ActiveStates() { export function OneCharacter() { return ( - + Order #1001 From eefcfb6be58c495f3a89e60b7aae9ea2670400a7 Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Fri, 22 Mar 2024 15:18:35 -0700 Subject: [PATCH 6/8] Add changeset entry --- .changeset/loud-shrimps-promise.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/loud-shrimps-promise.md diff --git a/.changeset/loud-shrimps-promise.md b/.changeset/loud-shrimps-promise.md new file mode 100644 index 00000000000..1430880bda7 --- /dev/null +++ b/.changeset/loud-shrimps-promise.md @@ -0,0 +1,11 @@ +--- +'@shopify/polaris': minor +--- + +Updated `Tooltip` props and state management: + +- Added a new `open` prop +- Added a new `defaultOpen` prop +- Deprecated the `active` prop +- Special cased the existing `active` prop behavior +- Replaced `EphemeralPresenceManger` with alternative hysteresis pattern From 6269a0216f96220a5680bc3baf6a705b59913046 Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Sat, 23 Mar 2024 16:26:27 -0700 Subject: [PATCH 7/8] Temporarily add copy to clipboard recipe --- .../src/components/Button/Button.stories.tsx | 34 +++++++++++++++ polaris-react/src/index.ts | 1 + .../src/utilities/use-copy-to-clipboard.ts | 43 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 polaris-react/src/utilities/use-copy-to-clipboard.ts diff --git a/polaris-react/src/components/Button/Button.stories.tsx b/polaris-react/src/components/Button/Button.stories.tsx index 7936981bc86..798a4aa2001 100644 --- a/polaris-react/src/components/Button/Button.stories.tsx +++ b/polaris-react/src/components/Button/Button.stories.tsx @@ -11,6 +11,9 @@ import { Box, Popover, ActionList, + Link, + Tooltip, + useCopyToClipboard, } from '@shopify/polaris'; import { PlusIcon, @@ -19,6 +22,8 @@ import { EditIcon, MagicIcon, DeleteIcon, + CheckIcon, + ClipboardIcon, } from '@shopify/polaris-icons'; export default { @@ -832,3 +837,32 @@ export function LoadingState() { ); } + +export function CopyToClipboard() { + const [copy, status] = useCopyToClipboard({ + defaultValue: 'hello@example.com', + }); + + return ( +
+ + + hello@example.com + +
+ ); +} diff --git a/polaris-react/src/index.ts b/polaris-react/src/index.ts index c9b223f2d72..060d9665206 100644 --- a/polaris-react/src/index.ts +++ b/polaris-react/src/index.ts @@ -420,6 +420,7 @@ export { export {useFrame, FrameContext} from './utilities/frame'; export {ScrollLockManagerContext as _SECRET_INTERNAL_SCROLL_LOCK_MANAGER_CONTEXT} from './utilities/scroll-lock-manager'; export {WithinContentContext as _SECRET_INTERNAL_WITHIN_CONTENT_CONTEXT} from './utilities/within-content-context'; +export {useCopyToClipboard} from './utilities/use-copy-to-clipboard'; export {useEventListener} from './utilities/use-event-listener'; export {useTheme} from './utilities/use-theme'; export {useIndexResourceState} from './utilities/use-index-resource-state'; diff --git a/polaris-react/src/utilities/use-copy-to-clipboard.ts b/polaris-react/src/utilities/use-copy-to-clipboard.ts new file mode 100644 index 00000000000..f66adceaa7d --- /dev/null +++ b/polaris-react/src/utilities/use-copy-to-clipboard.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +type Status = 'inactive' | 'copied' | 'failed'; + +interface UseCopyToClipboardOptions { + defaultValue?: string; + timeout?: number; +} + +/** + * Copy text to the native clipboard using the `navigator.clipboard` API + * Adapted from https://www.benmvp.com/blog/copy-to-clipboard-react-custom-hook + */ +export function useCopyToClipboard(options: UseCopyToClipboardOptions = {}) { + const {defaultValue = '', timeout = 1500} = options; + + const [status, setStatus] = React.useState('inactive'); + + const copy = React.useCallback( + (value?: string) => { + navigator.clipboard + .writeText(typeof value === 'string' ? value : defaultValue) + .then( + () => setStatus('copied'), + () => setStatus('failed'), + ) + .catch((error) => { + throw error; + }); + }, + [defaultValue], + ); + + React.useEffect(() => { + if (status === 'inactive') return; + + const timeoutId = setTimeout(() => setStatus('inactive'), timeout); + + return () => clearTimeout(timeoutId); + }, [status, timeout]); + + return [copy, status] as const; +} From 36e22c278e9daea972030f8ec9b6f2b1d11e6452 Mon Sep 17 00:00:00 2001 From: Aaron Casanova Date: Sat, 23 Mar 2024 19:44:20 -0700 Subject: [PATCH 8/8] Add accessibility label to recipe --- polaris-react/src/components/Button/Button.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/polaris-react/src/components/Button/Button.stories.tsx b/polaris-react/src/components/Button/Button.stories.tsx index 798a4aa2001..594f9e167b1 100644 --- a/polaris-react/src/components/Button/Button.stories.tsx +++ b/polaris-react/src/components/Button/Button.stories.tsx @@ -859,6 +859,7 @@ export function CopyToClipboard() { variant="tertiary" onClick={copy} icon={status === 'copied' ? CheckIcon : ClipboardIcon} + accessibilityLabel="Copy email address" />