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 6f16c99f8..daa99d13b 100644 --- a/apps/docs/docs/components/overlay/Tooltip/_webExamples.mdx +++ b/apps/docs/docs/components/overlay/Tooltip/_webExamples.mdx @@ -90,3 +90,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 c1b80ea5b..7524a495c 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.29.0 ((12/11/2025, 02:43 PM PST)) + +This is an artificial version bump with no new change. + ## 8.28.1 ((12/10/2025, 04:33 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index fa1407d5c..5a43f60ad 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.28.1", + "version": "8.29.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 2b831f51a..da7af88ac 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.29.0 ((12/11/2025, 02:43 PM PST)) + +This is an artificial version bump with no new change. + ## 8.28.1 ((12/10/2025, 04:33 PM 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 314301523..09a9f2655 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.28.1", + "version": "8.29.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 013047f31..ff60dba84 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.29.0 (12/11/2025 PST) + +#### 🚀 Updates + +- Add open/close visibility delays to Tooltip. [[#234](https://github.com/coinbase/cds/pull/234)] [DX-5010] + ## 8.28.1 (12/10/2025 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 60d6e9be3..453c2be00 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.28.1", + "version": "8.29.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..f2ae14e8d 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,55 @@ export const Tooltip = memo( visible, invertColorScheme = true, elevation, + openDelay = 0, + closeDelay = 0, }: 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 > 0) { + closeTimeoutRef.current = setTimeout(closeTooltip, closeDelay); + return; + } + + closeTooltip(); + }, [animateOut, clearCloseTimeout, clearOpenTimeout, closeDelay, onCloseTooltip]); const handlePressSubject = useCallback(() => { + clearCloseTimeout(); subjectRef.current?.measure((x, y, width, height, pageOffsetX, pageOffsetY) => { setSubjectLayout({ width, @@ -50,9 +81,19 @@ export const Tooltip = memo( pageOffsetY, }); }); - setIsOpen(true); - onOpenTooltip?.(); - }, [onOpenTooltip]); + const openTooltip = () => { + setIsOpen(true); + onOpenTooltip?.(); + }; + + clearOpenTimeout(); + if (openDelay > 0) { + openTimeoutRef.current = setTimeout(openTooltip, openDelay); + return; + } + + 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 +127,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 fb77b96c0..6de78cbeb 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.29.0 (12/11/2025 PST) + +#### 🚀 Updates + +- Add open/close visibility delays to Tooltip. [[#234](https://github.com/coinbase/cds/pull/234)] [DX-5010] + ## 8.28.1 (12/10/2025 PST) #### 🐞 Fixes diff --git a/packages/web/package.json b/packages/web/package.json index a1a7d199c..8e4225d1b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.28.1", + "version": "8.29.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..c2ea46703 100644 --- a/packages/web/src/overlays/__stories__/Tooltip.stories.tsx +++ b/packages/web/src/overlays/__stories__/Tooltip.stories.tsx @@ -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,10 @@ const longContent = TooltipLongContent.args = { content: longContent, }; + +export const DelayedVisibility = Template.bind({}); +DelayedVisibility.args = { + content: 'Opens after 400ms, closes after 150ms', + openDelay: 400, + closeDelay: 150, +}; diff --git a/packages/web/src/overlays/tooltip/Tooltip.tsx b/packages/web/src/overlays/tooltip/Tooltip.tsx index c9c252686..d9e80deba 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 = 0, + closeDelay = 0, }: 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..3e9e12f55 100644 --- a/packages/web/src/overlays/tooltip/TooltipProps.ts +++ b/packages/web/src/overlays/tooltip/TooltipProps.ts @@ -29,6 +29,18 @@ 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. + * @default 0 + */ + openDelay?: number; + /** + * Delay (in ms) before hiding the tooltip after pointer leave. + * Keyboard blur still closes immediately. + * @default 0 + */ + 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..ee30e54a1 100644 --- a/packages/web/src/overlays/tooltip/useTooltipState.ts +++ b/packages/web/src/overlays/tooltip/useTooltipState.ts @@ -1,15 +1,64 @@ -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 = 0, closeDelay = 0) => { 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 > 0) { + openTimeoutRef.current = setTimeout(() => { + setIsHovered(true); + }, openDelay); + return; + } + + setIsHovered(true); + }, [clearCloseTimeout, openDelay]); + + const toggleOffIsHovered = useCallback(() => { + clearOpenTimeout(); + + if (closeDelay > 0) { + closeTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + }, closeDelay); + return; + } + + 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 +68,13 @@ export const useTooltipState = (id?: string) => { toggleOffIsHovered(); }, [toggleOffIsHovered]); + useEffect(() => { + return () => { + clearOpenTimeout(); + clearCloseTimeout(); + }; + }, [clearCloseTimeout, clearOpenTimeout]); + return useMemo(() => { return { isOpen: isHovered || isFocused,