fix tooltips
This commit is contained in:
110
src/components/ui/__tests__/tooltip.test.tsx
Normal file
110
src/components/ui/__tests__/tooltip.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user