diff --git a/src/components/ui/__tests__/tooltip.test.tsx b/src/components/ui/__tests__/tooltip.test.tsx new file mode 100644 index 0000000..7bcdb66 --- /dev/null +++ b/src/components/ui/__tests__/tooltip.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; + +const setupMatchMedia = (matches: boolean) => { + const listeners = new Set(); + + const mockMatchMedia = (query: string): MediaQueryList => ({ + matches, + media: query, + onchange: null, + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'change') { + listeners.add(listener); + } + }, + removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'change') { + listeners.delete(listener); + } + }, + addListener: () => { + /* deprecated */ + }, + removeListener: () => { + /* deprecated */ + }, + dispatchEvent: (event: Event) => { + listeners.forEach((listener) => { + if (typeof listener === 'function') { + listener(event); + } else { + listener.handleEvent(event); + } + }); + return true; + }, + }); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(mockMatchMedia), + }); +}; + +describe('Tooltip hybrid behaviour', () => { + beforeEach(() => { + class ResizeObserverMock { + observe() { + /* noop */ + } + unobserve() { + /* noop */ + } + disconnect() { + /* noop */ + } + } + + Object.defineProperty(window, 'ResizeObserver', { + writable: true, + value: ResizeObserverMock, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('falls back to popover interaction on touch devices', async () => { + setupMatchMedia(true); + + render( + + Trigger + Tooltip text + , + ); + + const trigger = screen.getByRole('button', { name: 'Trigger' }); + expect(trigger).toHaveAttribute('data-touch', 'true'); + + const user = userEvent.setup(); + await user.click(trigger); + + expect( + await screen.findByText('Tooltip text', { selector: '[data-slot="tooltip-content"]' }), + ).toBeVisible(); + }); + + it('keeps tooltip interaction on non-touch devices', async () => { + setupMatchMedia(false); + + render( + + Trigger + Tooltip text + , + ); + + const trigger = screen.getByRole('button', { name: 'Trigger' }); + expect(trigger).toHaveAttribute('data-touch', 'false'); + + expect( + await screen.findByText('Tooltip text', { selector: '[data-slot="tooltip-content"]' }), + ).toBeVisible(); + }); +}); diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 5766bf5..8a06f6e 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,10 +1,55 @@ 'use client'; import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import { cn } from '@/lib/utils'; +type TooltipProps = Readonly< + React.ComponentProps & React.ComponentProps +>; + +type TooltipTriggerProps = Readonly< + React.ComponentProps & + React.ComponentProps +>; + +type TooltipContentProps = Readonly< + React.ComponentProps & + React.ComponentProps +>; + +const TooltipTouchContext = React.createContext(false); + +function useIsTouchDevice() { + const [isTouch, setIsTouch] = React.useState(() => { + if (typeof window === 'undefined') { + return false; + } + return window.matchMedia('(pointer: coarse)').matches; + }); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const mediaQuery = window.matchMedia('(pointer: coarse)'); + const handleChange = (event: MediaQueryListEvent) => { + setIsTouch(event.matches); + }; + + setIsTouch(mediaQuery.matches); + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + return isTouch; +} + function TooltipProvider({ delayDuration = 0, ...props @@ -14,28 +59,63 @@ function TooltipProvider({ ); } -function Tooltip({ ...props }: Readonly>) { +function Tooltip({ children, ...props }: TooltipProps) { + const isTouch = useIsTouchDevice(); + return ( - + + {isTouch ? ( + + {children} + + ) : ( + + {children} + + )} + ); } -function TooltipTrigger({ ...props }: React.ComponentProps) { - return ; +function TooltipTrigger({ ...props }: TooltipTriggerProps) { + const isTouch = React.useContext(TooltipTouchContext); + + return isTouch ? ( + + ) : ( + + ); } -function TooltipContent({ - className, - sideOffset = 0, - children, - ...props -}: React.ComponentProps) { +function TooltipContent({ className, sideOffset = 0, children, ...props }: TooltipContentProps) { + const isTouch = React.useContext(TooltipTouchContext); + + if (isTouch) { + return ( + + + {children} + + + ); + } + return (