Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/components/Form/FieldGroup/FieldGroupDescription.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import { FieldGroupDescription } from './FieldGroupDescription.tsx';

const DESCRIPTION = 'Explains what this field is for.';

describe('FieldGroupDescription', () => {
it('should render nothing without a description', () => {
render(<FieldGroupDescription description={null} />);
expect(screen.queryByTestId('field-description-trigger')).not.toBeInTheDocument();
});

it('should show the description when the mouse hovers the icon, without needing a click', async () => {
render(<FieldGroupDescription description={DESCRIPTION} />);
await userEvent.hover(screen.getByTestId('field-description-trigger'));
expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
});

it('should hide the description once the mouse leaves the icon', async () => {
render(<FieldGroupDescription description={DESCRIPTION} />);
const trigger = screen.getByTestId('field-description-trigger');
await userEvent.hover(trigger);
await userEvent.unhover(trigger);
await waitFor(() => expect(screen.queryByText(DESCRIPTION)).not.toBeInTheDocument());
});

it('should stay open while the mouse moves from the icon onto the description', async () => {
render(<FieldGroupDescription description={DESCRIPTION} />);
await userEvent.hover(screen.getByTestId('field-description-trigger'));
await userEvent.hover(screen.getByText(DESCRIPTION));
await new Promise((resolve) => setTimeout(resolve, 150));
expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
});

it('should not take focus from the field being typed in when it opens on hover', async () => {
render(
<>
<input aria-label="field" />
<FieldGroupDescription description={DESCRIPTION} />
</>
);
const input = screen.getByLabelText('field');
input.focus();
await userEvent.hover(screen.getByTestId('field-description-trigger'));
expect(input).toHaveFocus();
});

it('should keep the description open when a hovering mouse also clicks the icon', async () => {
render(<FieldGroupDescription description={DESCRIPTION} />);
await userEvent.click(screen.getByTestId('field-description-trigger'));
expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
});

it('should still open on a tap, since touch screens cannot hover', async () => {
const user = userEvent.setup();
render(<FieldGroupDescription description={DESCRIPTION} />);
await user.pointer({
keys: '[TouchA]',
pointerName: 'touch',
target: screen.getByTestId('field-description-trigger')
});
expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
});
});
52 changes: 45 additions & 7 deletions src/components/Form/FieldGroup/FieldGroupDescription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,52 @@ import { CircleHelpIcon } from 'lucide-react';

import { Popover } from '#components';

export const FieldGroupDescription: React.FC<{ description?: null | string }> = ({ description }) =>
description ? (
<Popover>
<Popover.Trigger tabIndex={-1}>
/** Lets the pointer cross the gap between the icon and the description without closing it. */
const HOVER_CLOSE_DELAY_MS = 100;

const isMouse = (event: React.PointerEvent) => event.pointerType === 'mouse';

export const FieldGroupDescription: React.FC<{ description?: null | string }> = ({ description }) => {
const [isOpen, setIsOpen] = React.useState(false);
const closeTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);

React.useEffect(() => () => clearTimeout(closeTimeoutRef.current), []);

if (!description) {
return null;
}

const openOnHover = (event: React.PointerEvent) => {
if (isMouse(event)) {
clearTimeout(closeTimeoutRef.current);
setIsOpen(true);
}
};

const closeOnLeave = (event: React.PointerEvent) => {
if (isMouse(event)) {
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), HOVER_CLOSE_DELAY_MS);
}
};

return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger
data-testid="field-description-trigger"
tabIndex={-1}
// A mouse user has already opened it by hovering, so their click must not toggle it shut.
onClick={(event) => isOpen && event.preventDefault()}
onPointerEnter={openOnHover}
onPointerLeave={closeOnLeave}
>
<CircleHelpIcon className="text-muted-foreground" />
</Popover.Trigger>
<Popover.Content className="text-muted-foreground text-sm">
<p>{description}</p>
{/* Not focused on open, so hovering the icon cannot pull focus out of a field being typed in. */}
<Popover.Content asChild autofocus={false} className="text-muted-foreground text-sm">
<div onPointerEnter={openOnHover} onPointerLeave={closeOnLeave}>
<p>{description}</p>
</div>
</Popover.Content>
</Popover>
) : null;
);
};
Loading