Skip to content

Commit 5f9a49d

Browse files
authored
feat(components): add HoverTrigger (#1312)
* feat(components): add `HoverTrigger` * feat: add tests * chore: changeset
1 parent bf904e8 commit 5f9a49d

File tree

8 files changed

+205
-12
lines changed

8 files changed

+205
-12
lines changed

.changeset/good-rivers-ring.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@launchpad-ui/components": patch
3+
---
4+
5+
Add `HoverTrigger`

packages/components/__tests__/Popover.spec.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

33
import { render, screen, userEvent } from '../../../test/utils';
4-
import { Button, Dialog, DialogTrigger, OverlayArrow, Popover } from '../src';
4+
import { Button, Dialog, DialogTrigger, HoverTrigger, OverlayArrow, Popover } from '../src';
55

66
describe('Popover', () => {
77
it('renders', async () => {
@@ -19,4 +19,65 @@ describe('Popover', () => {
1919
await user.click(screen.getByRole('button'));
2020
expect(await screen.findByRole('dialog')).toBeVisible();
2121
});
22+
23+
it('toggles on hover/unhover with HoverTrigger', async () => {
24+
const user = userEvent.setup();
25+
render(
26+
<HoverTrigger>
27+
<Button>Trigger</Button>
28+
<Popover>
29+
<OverlayArrow />
30+
<Dialog>Message</Dialog>
31+
</Popover>
32+
</HoverTrigger>,
33+
);
34+
35+
await user.hover(screen.getByRole('button'));
36+
await user.pointer([{ keys: '[TouchA>]', target: screen.getByRole('button') }]);
37+
expect(await screen.findByRole('dialog')).toBeVisible();
38+
39+
await user.pointer([{ pointerName: 'TouchA', target: document.body }, { keys: '[/TouchA]' }]);
40+
expect(await screen.queryByRole('dialog')).not.toBeInTheDocument();
41+
});
42+
43+
it('toggles on click when hovered with HoverTrigger', async () => {
44+
const user = userEvent.setup();
45+
render(
46+
<HoverTrigger>
47+
<Button>Trigger</Button>
48+
<Popover>
49+
<OverlayArrow />
50+
<Dialog>Message</Dialog>
51+
</Popover>
52+
</HoverTrigger>,
53+
);
54+
55+
await user.hover(screen.getByRole('button'));
56+
expect(await screen.findByRole('dialog')).toBeVisible();
57+
58+
await user.click(screen.getByText('Trigger'));
59+
expect(await screen.queryByRole('dialog')).not.toBeInTheDocument();
60+
61+
await user.click(screen.getByRole('button'));
62+
expect(await screen.findByRole('dialog')).toBeVisible();
63+
});
64+
65+
it('stays open when popover is hovered with HoverTrigger', async () => {
66+
const user = userEvent.setup();
67+
render(
68+
<HoverTrigger>
69+
<Button>Trigger</Button>
70+
<Popover>
71+
<OverlayArrow />
72+
<Dialog>Message</Dialog>
73+
</Popover>
74+
</HoverTrigger>,
75+
);
76+
77+
await user.hover(screen.getByRole('button'));
78+
expect(await screen.findByRole('dialog')).toBeVisible();
79+
80+
await user.hover(screen.getByRole('dialog'));
81+
expect(await screen.findByRole('dialog')).toBeVisible();
82+
});
2283
});

packages/components/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,22 @@
3636
"dependencies": {
3737
"@launchpad-ui/icons": "workspace:~",
3838
"@launchpad-ui/tokens": "workspace:~",
39-
"@react-aria/utils": "3.24.0",
39+
"@react-aria/interactions": "3.21.2",
4040
"@react-aria/toast": "3.0.0-beta.11",
41+
"@react-aria/utils": "3.24.0",
4142
"@react-stately/toast": "3.0.0-beta.3",
4243
"@react-types/shared": "3.23.0",
4344
"class-variance-authority": "0.7.0",
45+
"react-aria": "3.33.0",
4446
"react-aria-components": "1.2.0",
45-
"react-router-dom": "6.16.0"
47+
"react-router-dom": "6.16.0",
48+
"react-stately": "3.31.0"
4649
},
4750
"peerDependencies": {
4851
"react": "18.3.1",
4952
"react-dom": "18.3.1"
5053
},
5154
"devDependencies": {
52-
"react-stately": "3.31.0",
5355
"react": "18.3.1",
5456
"react-dom": "18.3.1"
5557
}

packages/components/src/Popover.tsx

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,24 @@ import type { ForwardedRef } from 'react';
22
import type {
33
OverlayArrowProps as AriaOverlayArrowProps,
44
PopoverProps as AriaPopoverProps,
5+
DialogTriggerProps,
56
} from 'react-aria-components';
67

8+
import { PressResponder } from '@react-aria/interactions';
9+
import { useId, useLayoutEffect } from '@react-aria/utils';
710
import { cva } from 'class-variance-authority';
8-
import { forwardRef, useContext } from 'react';
11+
import { forwardRef, useCallback, useContext, useRef } from 'react';
12+
import { useHover, useOverlayTrigger } from 'react-aria';
913
import {
1014
OverlayArrow as AriaOverlayArrow,
1115
Popover as AriaPopover,
16+
PopoverContext as AriaPopoverContext,
17+
DialogContext,
18+
OverlayTriggerStateContext,
19+
Provider,
1220
composeRenderProps,
1321
} from 'react-aria-components';
22+
import { useOverlayTriggerState } from 'react-stately';
1423

1524
import { PopoverContext } from './ComboBox';
1625
import styles from './styles/Popover.module.css';
@@ -62,7 +71,83 @@ const _OverlayArrow = (props: OverlayArrowProps, ref: ForwardedRef<HTMLDivElemen
6271
);
6372
};
6473

74+
/**
75+
* An OverlayArrow renders a custom arrow element relative to an overlay element such as a popover or tooltip such that it aligns with a trigger element.
76+
*
77+
* https://react-spectrum.adobe.com/react-aria/Popover.html
78+
*/
6579
const OverlayArrow = forwardRef(_OverlayArrow);
6680

67-
export { OverlayArrow, Popover };
81+
const HoverTrigger = (props: DialogTriggerProps) => {
82+
const state = useOverlayTriggerState(props);
83+
84+
const buttonRef = useRef<HTMLButtonElement>(null);
85+
const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'dialog' }, state, buttonRef);
86+
87+
triggerProps.id = useId();
88+
// @ts-expect-error
89+
overlayProps['aria-labelledby'] = triggerProps.id;
90+
91+
const ref = useRef<HTMLSpanElement>(null);
92+
const openTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
93+
94+
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
95+
const cancelOpenTimeout = useCallback(() => {
96+
if (openTimeout.current) {
97+
clearTimeout(openTimeout.current);
98+
openTimeout.current = undefined;
99+
}
100+
}, [openTimeout]);
101+
102+
const onHoverChange = (isHovering: boolean) => {
103+
if (!openTimeout.current) {
104+
openTimeout.current = setTimeout(() => {
105+
cancelOpenTimeout();
106+
state.setOpen(isHovering);
107+
}, 250);
108+
} else {
109+
cancelOpenTimeout();
110+
}
111+
};
112+
113+
const { hoverProps } = useHover({
114+
onHoverChange,
115+
});
116+
117+
useLayoutEffect(() => {
118+
return () => {
119+
cancelOpenTimeout();
120+
};
121+
}, [cancelOpenTimeout]);
122+
123+
const shouldCloseOnInteractOutside = (target: Element) => {
124+
return target !== buttonRef.current;
125+
};
126+
127+
return (
128+
<Provider
129+
values={[
130+
[OverlayTriggerStateContext, state],
131+
[DialogContext, overlayProps],
132+
[
133+
AriaPopoverContext,
134+
{
135+
trigger: 'DialogTrigger',
136+
triggerRef: buttonRef,
137+
UNSTABLE_portalContainer: ref.current || undefined,
138+
shouldCloseOnInteractOutside,
139+
},
140+
],
141+
]}
142+
>
143+
<span className={styles.hover} ref={ref} {...hoverProps}>
144+
<PressResponder {...triggerProps} ref={buttonRef} isPressed={state.isOpen}>
145+
{props.children}
146+
</PressResponder>
147+
</span>
148+
</Provider>
149+
);
150+
};
151+
152+
export { HoverTrigger, OverlayArrow, Popover };
68153
export type { OverlayArrowProps, PopoverProps };

packages/components/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export { ExternalLinkIconButton, LinkIconButton } from './LinkIconButton';
6565
export { ListBox, ListBoxItem } from './ListBox';
6666
export { Menu, MenuItem, MenuTrigger, SubmenuTrigger } from './Menu';
6767
export { Modal, ModalOverlay } from './Modal';
68-
export { OverlayArrow, Popover } from './Popover';
68+
export { HoverTrigger, OverlayArrow, Popover } from './Popover';
6969
export { ProgressBar } from './ProgressBar';
7070
export { Radio } from './Radio';
7171
export { RadioButton } from './RadioButton';

packages/components/src/styles/Popover.module.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,9 @@
113113
}
114114
}
115115
}
116+
117+
.hover {
118+
& [data-testid='underlay'] {
119+
pointer-events: none;
120+
}
121+
}

packages/components/stories/Popover.stories.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@ import type { PlayFunction } from '@storybook/types';
33

44
import { expect, userEvent, within } from '@storybook/test';
55

6-
import { Button, Dialog, DialogTrigger, Heading, OverlayArrow, Popover } from '../src';
6+
import {
7+
Button,
8+
Dialog,
9+
DialogTrigger,
10+
Heading,
11+
HoverTrigger,
12+
OverlayArrow,
13+
Popover,
14+
} from '../src';
715

816
const meta: Meta<typeof Popover> = {
917
component: Popover,
1018
// @ts-ignore
11-
subcomponents: { OverlayArrow, DialogTrigger },
19+
subcomponents: { OverlayArrow, DialogTrigger, HoverTrigger },
1220
title: 'Components/Overlays/Popover',
1321
parameters: {
1422
status: {
@@ -89,3 +97,23 @@ export const WithHeading: Story = {
8997
},
9098
play,
9199
};
100+
101+
export const Hover: Story = {
102+
render: (args) => {
103+
return (
104+
<HoverTrigger>
105+
<Button>Trigger</Button>
106+
<Popover {...args}>
107+
<Dialog>Message</Dialog>
108+
</Popover>
109+
</HoverTrigger>
110+
);
111+
},
112+
play: async ({ canvasElement }) => {
113+
const canvas = within(canvasElement);
114+
115+
await userEvent.hover(canvas.getByRole('button'));
116+
const body = canvasElement.ownerDocument.body;
117+
await expect(await within(body).findByRole('dialog'));
118+
},
119+
};

pnpm-lock.yaml

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)