diff --git a/src/components/Form/FieldGroup/FieldGroupDescription.test.tsx b/src/components/Form/FieldGroup/FieldGroupDescription.test.tsx new file mode 100644 index 0000000..96322dd --- /dev/null +++ b/src/components/Form/FieldGroup/FieldGroupDescription.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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( + <> + + + + ); + 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(); + 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(); + await user.pointer({ + keys: '[TouchA]', + pointerName: 'touch', + target: screen.getByTestId('field-description-trigger') + }); + expect(screen.getByText(DESCRIPTION)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Form/FieldGroup/FieldGroupDescription.tsx b/src/components/Form/FieldGroup/FieldGroupDescription.tsx index aa23489..3bac086 100644 --- a/src/components/Form/FieldGroup/FieldGroupDescription.tsx +++ b/src/components/Form/FieldGroup/FieldGroupDescription.tsx @@ -4,14 +4,52 @@ import { CircleHelpIcon } from 'lucide-react'; import { Popover } from '#components'; -export const FieldGroupDescription: React.FC<{ description?: null | string }> = ({ description }) => - description ? ( - - +/** 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>(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 ( + + isOpen && event.preventDefault()} + onPointerEnter={openOnHover} + onPointerLeave={closeOnLeave} + > - -

{description}

+ {/* Not focused on open, so hovering the icon cannot pull focus out of a field being typed in. */} + +
+

{description}

+
- ) : null; + ); +};