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';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
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({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
...props
|
...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 (
|
return (
|
||||||
<TooltipProvider>
|
<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>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
function TooltipTrigger({ ...props }: TooltipTriggerProps) {
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
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({
|
function TooltipContent({ className, sideOffset = 0, children, ...props }: TooltipContentProps) {
|
||||||
className,
|
const isTouch = React.useContext(TooltipTouchContext);
|
||||||
sideOffset = 0,
|
|
||||||
children,
|
if (isTouch) {
|
||||||
...props
|
return (
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
<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 (
|
return (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
|
data-touch="false"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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',
|
'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