import {act, cleanup, fireEvent, render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {cloneElement, useState} from 'react';
import {vi} from 'vitest';
import {
FloatingFocusManager,
useClick,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
} from '../../src';
import type {UseFocusProps} from '../../src/hooks/useFocus';
vi.useFakeTimers();
beforeAll(() => {
customElements.define('render-root', class RenderRoot extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' })
.appendChild(document.createElement('div'));
}
});
});
function App(props: UseFocusProps & {dismiss?: boolean; hover?: boolean}) {
const [open, setOpen] = useState(false);
const {refs, context} = useFloating({
open,
onOpenChange: setOpen,
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useFocus(context, props),
useDismiss(context, {enabled: !!props.dismiss, referencePress: true}),
useHover(context, {enabled: !!props.hover}),
]);
return (
<>
{open && (
)}
>
);
}
test('opens on focus', () => {
render();
const button = screen.getByRole('button');
fireEvent.focus(button);
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('closes on blur', () => {
render();
const button = screen.getByRole('button');
act(() => button.focus());
act(() => button.blur());
act(() => {
vi.runAllTimers();
});
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('stays open when focus moves to tooltip rendered inside a shadow root', async () => {
const container = document.body.appendChild(document.createElement('render-root'));
const renderRoot = container.shadowRoot?.firstElementChild as HTMLElement;
render(, { container: renderRoot });
const root = within(renderRoot)
// Open the tooltip by focusing the reference
const button = root.getByRole('button');
await fireEvent.focusIn(button);
// Move focus to the tooltip
const tooltip = root.getByRole('tooltip');
tooltip.focus();
// trigger the blur event caused by the focus move, note relatedTarget points to the shadow root here
fireEvent.focusOut(button, { relatedTarget: container })
act(() => {
vi.runAllTimers();
});
expect(root.getByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('stays open when focus moves to element inside reference that is rendered inside a shadow root', async () => {
const container = document.body.appendChild(document.createElement('render-root'));
const renderRoot = container.shadowRoot?.firstElementChild as HTMLElement;
render(, { container: renderRoot });
const root = within(renderRoot)
// Open the tooltip by focusing the reference
const button = root.getByRole('button');
await fireEvent.focusIn(button);
// Move focus to an element inside the reference
const insideReference = root.getByTestId('inside-reference');
insideReference.focus();
// trigger the blur event caused by the focus move, note relatedTarget points to the shadow root here
fireEvent.focusOut(button, { relatedTarget: container })
act(() => {
vi.runAllTimers();
});
expect(root.getByRole('tooltip')).toBeInTheDocument();
cleanup();
});
test('does not open with a reference pointerDown dismissal', async () => {
render();
const button = screen.getByRole('button');
fireEvent.pointerDown(button);
fireEvent.focus(button);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('does not open when window blurs then receives focus', async () => {
// TODO — not sure how to test this in JSDOM
});
test('blurs when hitting an "inside" focus guard', async () => {
vi.useRealTimers();
function Tooltip({children}: {children: JSX.Element}) {
const [open, setOpen] = useState(false);
const {refs, context} = useFloating({
open,
onOpenChange: setOpen,
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useFocus(context),
]);
return (
<>
{cloneElement(children, getReferenceProps({ref: refs.setReference}))}
{open && (
Label
)}
>
);
}
function App() {
const [open, setOpen] = useState(false);
const {refs, context} = useFloating({
open,
onOpenChange: setOpen,
});
const {getReferenceProps, getFloatingProps} = useInteractions([
useClick(context),
]);
return (
<>
{open && (
)}
>
);
}
render();
await userEvent.click(screen.getByRole('button'));
await userEvent.tab();
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
await userEvent.tab();
// Wait for the timeout in `onBlur()`.
await act(() => new Promise((resolve) => setTimeout(resolve)));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
cleanup();
});
test('reason string', async () => {
function App() {
const [isOpen, setIsOpen] = useState(false);
const {refs, context} = useFloating({
open: isOpen,
onOpenChange(isOpen, _, reason) {
setIsOpen(isOpen);
expect(reason).toBe('focus');
},
});
const focus = useFocus(context);
const {getReferenceProps, getFloatingProps} = useInteractions([focus]);
return (
<>
{isOpen && (
)}
>
);
}
render();
const button = screen.getByRole('button');
fireEvent.focusIn(button);
await act(async () => {});
fireEvent.focusOut(button);
});
describe('visibleOnly prop', () => {
function App({visibleOnly}: {visibleOnly: boolean}) {
const [isOpen, setIsOpen] = useState(false);
const {refs, context} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
});
const focus = useFocus(context, {visibleOnly});
const {getReferenceProps, getFloatingProps} = useInteractions([focus]);
return (
<>
{isOpen && (
)}
>
);
}
test('true', async () => {
render();
const button = screen.getByRole('button');
await userEvent.click(button);
await act(async () => {});
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
test('false', async () => {
render();
const button = screen.getByRole('button');
await userEvent.click(button);
await act(async () => {});
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
});
});