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. */}
+
+
- ) : null;
+ );
+};