diff --git a/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx b/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx index 117dfae16..41a0a477d 100644 --- a/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx +++ b/apps/docs/docs/components/overlay/Tooltip/_mobileExamples.mdx @@ -40,3 +40,25 @@ function TooltipColorSchemeOptOut() { ); } ``` + +### Visibility delay (press) + +Use `openDelay` and `closeDelay` to slow down activation/dismissal when users tap through dense surfaces. + +```jsx +function TooltipVisibilityDelay() { + return ( + + + + + + + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/overlay/Tooltip/_webExamples.mdx b/apps/docs/docs/components/overlay/Tooltip/_webExamples.mdx index 7fe78f6ff..102b51d87 100644 --- a/apps/docs/docs/components/overlay/Tooltip/_webExamples.mdx +++ b/apps/docs/docs/components/overlay/Tooltip/_webExamples.mdx @@ -89,3 +89,25 @@ You can use tooltips within `TextInput` to provide more context. placeholder="Satoshi Nakamoto" /> ``` + +### Visibility delay (hover) + +Use `openDelay` and `closeDelay` to slow down hover activation and reduce accidental opens on dense UI. Keyboard focus still opens immediately. + +```jsx live +function TooltipVisibilityDelay() { + return ( + + + + + + + + + + + + ); +} +``` diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 714e7b004..ce176cda3 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.46.0 ((2/12/2026, 11:34 AM PST)) + +This is an artificial version bump with no new change. + ## 8.45.0 ((2/12/2026, 07:33 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 7a5977803..8196edaf9 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.45.0", + "version": "8.46.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index e85c04073..14ff771b7 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.46.0 ((2/12/2026, 11:34 AM PST)) + +This is an artificial version bump with no new change. + ## 8.45.0 ((2/12/2026, 07:33 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 184c80328..8a8c7ae69 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.45.0", + "version": "8.46.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index e0caef6c7..311495edc 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 8.46.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add open/close visibility delays to Tooltip. [[#234](https://github.com/coinbase/cds/pull/234)] + ## 8.45.0 (2/12/2026 PST) #### 🚀 Updates diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 4e13eb603..fa6a9b887 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.45.0", + "version": "8.46.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/overlays/__stories__/TooltipV2.stories.tsx b/packages/mobile/src/overlays/__stories__/TooltipV2.stories.tsx index 3fa9c2dc9..b95fe0b6d 100644 --- a/packages/mobile/src/overlays/__stories__/TooltipV2.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/TooltipV2.stories.tsx @@ -24,6 +24,31 @@ const shortText = 'This is the short text.'; const longText = 'This is the really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really really long text.'; +const DelayVariations = () => { + return ( + + + + + Open delay 400ms + + + Close delay 200ms + + + + + Open 300 / Close 150 + + + Open 500 / Close 300 + + + + + ); +}; + const ToolTipWithA11y = ({ tooltipText, yShiftByStatusBarHeight }: Omit) => { const triggerRef = useRef(null); const { setA11yFocus } = useA11y(); @@ -206,7 +231,7 @@ const RNModalTest = () => { ); return ( - <> + @@ -232,7 +257,7 @@ const RNModalTest = () => { yShiftByStatusBarHeight={yShiftByStatusBarHeight} /> - + ); }; @@ -249,11 +274,14 @@ const DisabledTest = () => { const TooltipV2Screen = () => { return ( - - - - - + + + + + + + + ); }; diff --git a/packages/mobile/src/overlays/tooltip/Tooltip.tsx b/packages/mobile/src/overlays/tooltip/Tooltip.tsx index 180a64059..71bc7292a 100644 --- a/packages/mobile/src/overlays/tooltip/Tooltip.tsx +++ b/packages/mobile/src/overlays/tooltip/Tooltip.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; +import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Modal as RNModal, TouchableOpacity, View } from 'react-native'; import { InvertedThemeProvider } from '../../system/ThemeProvider'; @@ -24,24 +24,54 @@ export const Tooltip = memo( visible, invertColorScheme = true, elevation, + openDelay, + closeDelay, }: TooltipProps) => { const subjectRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const isVisible = visible !== false && isOpen; const [subjectLayout, setSubjectLayout] = useState(); + const openTimeoutRef = useRef | null>(null); + const closeTimeoutRef = useRef | null>(null); const WrapperComponent = invertColorScheme ? InvertedThemeProvider : Fragment; const { opacity, translateY, animateIn, animateOut } = useTooltipAnimation(placement); + const clearOpenTimeout = useCallback(() => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + }, []); + + const clearCloseTimeout = useCallback(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }, []); + const handleRequestClose = useCallback(() => { - animateOut.start(() => { - setIsOpen(false); - onCloseTooltip?.(); - }); - }, [animateOut, onCloseTooltip]); + clearOpenTimeout(); + clearCloseTimeout(); + + const closeTooltip = () => { + animateOut.start(() => { + setIsOpen(false); + onCloseTooltip?.(); + }); + }; + + if (closeDelay && closeDelay > 0) { + closeTimeoutRef.current = setTimeout(closeTooltip, closeDelay); + } else { + closeTooltip(); + } + }, [animateOut, clearCloseTimeout, clearOpenTimeout, closeDelay, onCloseTooltip]); const handlePressSubject = useCallback(() => { + clearCloseTimeout(); subjectRef.current?.measure((x, y, width, height, pageOffsetX, pageOffsetY) => { setSubjectLayout({ width, @@ -50,9 +80,18 @@ export const Tooltip = memo( pageOffsetY, }); }); - setIsOpen(true); - onOpenTooltip?.(); - }, [onOpenTooltip]); + const openTooltip = () => { + setIsOpen(true); + onOpenTooltip?.(); + }; + + clearOpenTimeout(); + if (openDelay && openDelay > 0) { + openTimeoutRef.current = setTimeout(openTooltip, openDelay); + } else { + openTooltip(); + } + }, [clearCloseTimeout, clearOpenTimeout, onOpenTooltip, openDelay]); // The accessibility props for the trigger component. Trigger component // equals the component where when you click on it, it will show the tooltip @@ -86,6 +125,13 @@ export const Tooltip = memo( [content, accessibilityLabelForContent, accessibilityHintForContent, handleRequestClose], ); + useEffect(() => { + return () => { + clearOpenTimeout(); + clearCloseTimeout(); + }; + }, [clearCloseTimeout, clearOpenTimeout]); + return ( { expect(await screen.findByText(contentText)).toBeTruthy(); expect(onOpenTooltip).toHaveBeenCalled(); }); + + it('respects openDelay before showing tooltip content', async () => { + jest.useFakeTimers(); + render( + + + , + ); + + fireEvent.press(screen.getByAccessibilityHint('delay-hint')); + + expect(screen.queryByText(contentText)).toBeNull(); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(screen.queryByText(contentText)).toBeNull(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(await screen.findByText(contentText)).toBeTruthy(); + + jest.useRealTimers(); + }); }); diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 980fc6b98..e09ff21bb 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 8.46.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add open/close visibility delays to Tooltip. [[#234](https://github.com/coinbase/cds/pull/234)] + ## 8.45.0 (2/12/2026 PST) #### 🚀 Updates diff --git a/packages/web/package.json b/packages/web/package.json index e9d6c629d..7d7b197df 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.45.0", + "version": "8.46.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/overlays/__stories__/Tooltip.stories.tsx b/packages/web/src/overlays/__stories__/Tooltip.stories.tsx index 25220d164..c13ca48c4 100644 --- a/packages/web/src/overlays/__stories__/Tooltip.stories.tsx +++ b/packages/web/src/overlays/__stories__/Tooltip.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { assets } from '@coinbase/cds-common/internal/data/assets'; import type { ComponentMeta, @@ -25,38 +25,63 @@ export default { type BasicTooltipProps = { content: TooltipProps['content']; + openDelay?: TooltipProps['openDelay']; + closeDelay?: TooltipProps['closeDelay']; }; -const BasicTooltip = ({ content }: BasicTooltipProps) => { +const BasicTooltip = ({ content, openDelay, closeDelay }: BasicTooltipProps) => { return ( - + - + - + - + - + - + - + @@ -64,6 +89,7 @@ const BasicTooltip = ({ content }: BasicTooltipProps) => { } + openDelay={openDelay} > @@ -76,7 +102,7 @@ const BasicTooltip = ({ content }: BasicTooltipProps) => { - + { - + { /> - + { - + Default - + @@ -127,14 +163,24 @@ const BasicTooltip = ({ content }: BasicTooltipProps) => { - + right - + bottom @@ -165,3 +211,106 @@ const longContent = TooltipLongContent.args = { content: longContent, }; + +export const DelayedVisibility = ({ + openDelay, + closeDelay, +}: { + openDelay: number; + closeDelay: number; +}) => { + const RERENDER_OPEN_DELAY = 3000; + const TICK_INTERVAL = 50; + const [elapsed, setElapsed] = useState(0); + const [rerenders, setRerenders] = useState(0); + const intervalRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const startTimer = useCallback(() => { + setElapsed(0); + setRerenders(0); + intervalRef.current = setInterval(() => { + setRerenders((prev) => prev + 1); + setElapsed((prev) => { + const next = prev + TICK_INTERVAL; + if (next >= RERENDER_OPEN_DELAY) { + clearTimer(); + return RERENDER_OPEN_DELAY; + } + return next; + }); + }, TICK_INTERVAL); + }, [clearTimer]); + + const stopTimer = useCallback(() => { + clearTimer(); + setElapsed(0); + setRerenders(0); + }, [clearTimer]); + + useEffect(() => { + return clearTimer; + }, [clearTimer]); + + const elapsedSeconds = (elapsed / 1000).toFixed(1); + const totalSeconds = (RERENDER_OPEN_DELAY / 1000).toFixed(1); + + return ( + + + + + Hover the button below. The tooltip opens after {openDelay}ms and closes after{' '} + {closeDelay}ms. + + + + + + + + Hover the text below to start a {totalSeconds}s tooltip delay. State updates every{' '} + {TICK_INTERVAL}ms while hovering, triggering rerenders. The tooltip should still open on + schedule because the delay timers are stored in refs that persist across renders. + + + Note: Mouse handlers are on a wrapper div outside the Tooltip to avoid spurious + mouseenter events caused by React replacing the child DOM node during rapid rerenders. + + +
+ + + Hover me for tooltip + + +
+ + {elapsedSeconds}s / {totalSeconds}s ({rerenders} rerenders) + +
+
+
+
+ ); +}; + +DelayedVisibility.args = { + openDelay: 400, + closeDelay: 150, +}; diff --git a/packages/web/src/overlays/tooltip/Tooltip.tsx b/packages/web/src/overlays/tooltip/Tooltip.tsx index c9c252686..b97f9d8fe 100644 --- a/packages/web/src/overlays/tooltip/Tooltip.tsx +++ b/packages/web/src/overlays/tooltip/Tooltip.tsx @@ -29,9 +29,11 @@ export const Tooltip = ({ focusTabIndexElements, respectNegativeTabIndex, autoFocusDelay = 20, + openDelay, + closeDelay, }: TooltipProps) => { const { isOpen, handleOnMouseEnter, handleOnMouseLeave, handleOnFocus, handleOnBlur, tooltipId } = - useTooltipState(tooltipIdDefault); + useTooltipState(tooltipIdDefault, openDelay, closeDelay); const tooltipContentRef = useRef(null); const handleMouseEnter = useCallback( diff --git a/packages/web/src/overlays/tooltip/TooltipProps.ts b/packages/web/src/overlays/tooltip/TooltipProps.ts index e1dbc89cd..b2696b89f 100644 --- a/packages/web/src/overlays/tooltip/TooltipProps.ts +++ b/packages/web/src/overlays/tooltip/TooltipProps.ts @@ -29,6 +29,16 @@ export type TooltipBaseProps = SharedProps & * @default true */ visible?: boolean; + /** + * Delay (in ms) before showing the tooltip on pointer hover. + * Keyboard focus still opens immediately for accessibility. + */ + openDelay?: number; + /** + * Delay (in ms) before hiding the tooltip after pointer leave. + * Keyboard blur still closes immediately. + */ + closeDelay?: number; /** Invert the theme's activeColorScheme for this component * @default true */ diff --git a/packages/web/src/overlays/tooltip/__tests__/Tooltip.test.tsx b/packages/web/src/overlays/tooltip/__tests__/Tooltip.test.tsx index 95933327e..046130696 100644 --- a/packages/web/src/overlays/tooltip/__tests__/Tooltip.test.tsx +++ b/packages/web/src/overlays/tooltip/__tests__/Tooltip.test.tsx @@ -1,20 +1,23 @@ import type { BaseTooltipPlacement } from '@coinbase/cds-common/types'; import { renderA11y } from '@coinbase/cds-web-utils/jest'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Button } from '../../../buttons/Button'; import { DefaultThemeProvider } from '../../../utils/test'; import { PortalProvider } from '../../PortalProvider'; import { Tooltip } from '../Tooltip'; +import type { TooltipProps } from '../TooltipProps'; const tooltipTestID = 'tooltip-test'; const StoryExample = ({ placement = 'top', + tooltipProps, }: { disabled?: boolean; placement?: BaseTooltipPlacement; + tooltipProps?: Partial; }) => { return ( @@ -23,6 +26,7 @@ const StoryExample = ({ content="This is the content in the tooltip!" placement={placement} testID={tooltipTestID} + {...tooltipProps} >
@@ -66,6 +70,52 @@ describe('Tooltip', () => { expect(await screen.findByTestId(tooltipTestID)).toBeInTheDocument(); }); + it('delays showing tooltip content based on openDelay', async () => { + jest.useFakeTimers(); + render(); + const button = screen.getByRole('button'); + + fireEvent.mouseEnter(button); + expect(screen.queryByTestId(tooltipTestID)).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(200); + }); + expect(screen.queryByTestId(tooltipTestID)).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(100); + }); + expect(await screen.findByTestId(tooltipTestID)).toBeInTheDocument(); + + jest.useRealTimers(); + }); + + it('delays hiding tooltip content based on closeDelay', async () => { + jest.useFakeTimers(); + render(); + const button = screen.getByRole('button'); + + fireEvent.mouseEnter(button); + expect(await screen.findByTestId(tooltipTestID)).toBeInTheDocument(); + + fireEvent.mouseLeave(button); + expect(screen.getByTestId(tooltipTestID)).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(100); + }); + expect(screen.getByTestId(tooltipTestID)).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => expect(screen.queryByTestId(tooltipTestID)).not.toBeInTheDocument()); + + jest.useRealTimers(); + }); + it('focuses after a delay when using autoFocusDelay', async () => { jest.useFakeTimers(); diff --git a/packages/web/src/overlays/tooltip/useTooltipState.ts b/packages/web/src/overlays/tooltip/useTooltipState.ts index ef60c9588..02997a2e2 100644 --- a/packages/web/src/overlays/tooltip/useTooltipState.ts +++ b/packages/web/src/overlays/tooltip/useTooltipState.ts @@ -1,15 +1,58 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { usePrefixedId } from '@coinbase/cds-common/hooks/usePrefixedId'; -export const useTooltipState = (id?: string) => { +export const useTooltipState = (id?: string, openDelay?: number, closeDelay?: number) => { const [isHovered, setIsHovered] = useState(false); const [isFocused, setIsFocused] = useState(false); const tooltipId = usePrefixedId(id); + const openTimeoutRef = useRef | null>(null); + const closeTimeoutRef = useRef | null>(null); - const handleOnMouseEnter = useCallback(() => setIsHovered(true), []); - const toggleOffIsHovered = useCallback(() => setIsHovered(false), []); - const handleOnFocus = useCallback(() => setIsFocused(true), []); - const toggleOffIsFocused = useCallback(() => setIsFocused(false), []); + const clearOpenTimeout = useCallback(() => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + }, []); + + const clearCloseTimeout = useCallback(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }, []); + + const handleOnMouseEnter = useCallback(() => { + clearCloseTimeout(); + + if (openDelay && openDelay > 0) { + openTimeoutRef.current = setTimeout(() => setIsHovered(true), openDelay); + } else { + setIsHovered(true); + } + }, [clearCloseTimeout, openDelay]); + + const toggleOffIsHovered = useCallback(() => { + clearOpenTimeout(); + + if (closeDelay && closeDelay > 0) { + closeTimeoutRef.current = setTimeout(() => setIsHovered(false), closeDelay); + } else { + setIsHovered(false); + } + }, [clearOpenTimeout, closeDelay]); + + const handleOnFocus = useCallback(() => { + clearCloseTimeout(); + clearOpenTimeout(); + setIsFocused(true); + }, [clearCloseTimeout, clearOpenTimeout]); + + const toggleOffIsFocused = useCallback(() => { + clearOpenTimeout(); + clearCloseTimeout(); + setIsFocused(false); + }, [clearCloseTimeout, clearOpenTimeout]); const handleOnBlur = useCallback(() => { toggleOffIsFocused(); @@ -19,6 +62,13 @@ export const useTooltipState = (id?: string) => { toggleOffIsHovered(); }, [toggleOffIsHovered]); + useEffect(() => { + return () => { + clearOpenTimeout(); + clearCloseTimeout(); + }; + }, [clearCloseTimeout, clearOpenTimeout]); + return useMemo(() => { return { isOpen: isHovered || isFocused,