fix tooltips

This commit is contained in:
2025-12-06 20:45:54 +01:00
parent 9ec1a4ab79
commit 0a5d691d04
2 changed files with 200 additions and 10 deletions

View File

@@ -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<EventListenerOrEventListenerObject>();
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(
<Tooltip>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>,
);
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(
<Tooltip defaultOpen>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>,
);
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();
});
});

View File

@@ -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<typeof TooltipPrimitive.Root> & React.ComponentProps<typeof PopoverPrimitive.Root>
>;
type TooltipTriggerProps = Readonly<
React.ComponentProps<typeof TooltipPrimitive.Trigger> &
React.ComponentProps<typeof PopoverPrimitive.Trigger>
>;
type TooltipContentProps = Readonly<
React.ComponentProps<typeof TooltipPrimitive.Content> &
React.ComponentProps<typeof PopoverPrimitive.Content>
>;
const TooltipTouchContext = React.createContext<boolean>(false);
function useIsTouchDevice() {
const [isTouch, setIsTouch] = React.useState<boolean>(() => {
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<React.ComponentProps<typeof TooltipPrimitive.Root>>) {
function Tooltip({ children, ...props }: TooltipProps) {
const isTouch = useIsTouchDevice();
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
<TooltipTouchContext.Provider value={isTouch}>
{isTouch ? (
<PopoverPrimitive.Root data-slot="tooltip" data-touch="true" {...props}>
{children}
</PopoverPrimitive.Root>
) : (
<TooltipPrimitive.Root data-slot="tooltip" data-touch="false" {...props}>
{children}
</TooltipPrimitive.Root>
)}
</TooltipTouchContext.Provider>
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
function TooltipTrigger({ ...props }: TooltipTriggerProps) {
const isTouch = React.useContext(TooltipTouchContext);
return isTouch ? (
<PopoverPrimitive.Trigger data-slot="tooltip-trigger" data-touch="true" {...props} />
) : (
<TooltipPrimitive.Trigger data-slot="tooltip-trigger" data-touch="false" {...props} />
);
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
function TooltipContent({ className, sideOffset = 0, children, ...props }: TooltipContentProps) {
const isTouch = React.useContext(TooltipTouchContext);
if (isTouch) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="tooltip-content"
data-touch="true"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-popover-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance shadow-md outline-hidden',
className,
)}
{...props}
>
{children}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
);
}
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
data-touch="false"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',