Skip to content

Commit b3b2f30

Browse files
authored
feat(components)!: Add popover variant for Tooltip to replace HoverTrigger (#1373)
* refactor(components)!: remove `HoverTrigger` * feat(components): add `popover` variant for `Tooltip`
1 parent 99c45db commit b3b2f30

File tree

11 files changed

+71
-211
lines changed

11 files changed

+71
-211
lines changed

.changeset/giant-ears-reflect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@launchpad-ui/components": minor
3+
---
4+
5+
Add `popover` variant for `Tooltip` to replace `HoverTrigger`
Lines changed: 1 addition & 62 deletions
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, HoverTrigger, OverlayArrow, Popover } from '../src';
4+
import { Button, Dialog, DialogTrigger, OverlayArrow, Popover } from '../src';
55

66
describe('Popover', () => {
77
it('renders', async () => {
@@ -19,65 +19,4 @@ 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-
});
8322
});

packages/components/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,14 @@
3636
"dependencies": {
3737
"@launchpad-ui/icons": "workspace:~",
3838
"@launchpad-ui/tokens": "workspace:~",
39-
"@react-aria/interactions": "3.22.1",
4039
"@react-aria/toast": "3.0.0-beta.14",
4140
"@react-aria/utils": "3.25.1",
4241
"@react-stately/toast": "3.0.0-beta.5",
4342
"@react-types/shared": "3.24.1",
4443
"class-variance-authority": "0.7.0",
4544
"react-aria": "3.34.1",
4645
"react-aria-components": "1.3.1",
47-
"react-router-dom": "6.16.0",
48-
"react-stately": "3.32.1"
46+
"react-router-dom": "6.16.0"
4947
},
5048
"peerDependencies": {
5149
"react": "18.3.1",
@@ -54,6 +52,7 @@
5452
"devDependencies": {
5553
"@internationalized/date": "3.5.5",
5654
"react": "18.3.1",
57-
"react-dom": "18.3.1"
55+
"react-dom": "18.3.1",
56+
"react-stately": "3.32.1"
5857
}
5958
}

packages/components/src/Popover.tsx

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

8-
import { PressResponder } from '@react-aria/interactions';
9-
import { useId, useLayoutEffect } from '@react-aria/utils';
107
import { cva } from 'class-variance-authority';
11-
import { forwardRef, useCallback, useContext, useRef } from 'react';
12-
import { useHover, useOverlayTrigger } from 'react-aria';
8+
import { forwardRef, useContext } from 'react';
139
import {
1410
OverlayArrow as AriaOverlayArrow,
1511
Popover as AriaPopover,
16-
PopoverContext as AriaPopoverContext,
17-
DialogContext,
18-
OverlayTriggerStateContext,
19-
Provider,
2012
composeRenderProps,
2113
} from 'react-aria-components';
22-
import { useOverlayTriggerState } from 'react-stately';
2314

2415
import { PopoverContext } from './ComboBox';
2516
import styles from './styles/Popover.module.css';
@@ -78,76 +69,5 @@ const _OverlayArrow = (props: OverlayArrowProps, ref: ForwardedRef<HTMLDivElemen
7869
*/
7970
const OverlayArrow = forwardRef(_OverlayArrow);
8071

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 };
72+
export { OverlayArrow, Popover };
15373
export type { OverlayArrowProps, PopoverProps };

packages/components/src/Tooltip.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { VariantProps } from 'class-variance-authority';
12
import type { ForwardedRef } from 'react';
23
import type {
34
TooltipProps as AriaTooltipProps,
@@ -12,23 +13,40 @@ import {
1213
composeRenderProps,
1314
} from 'react-aria-components';
1415

16+
import popoverStyles from './styles/Popover.module.css';
1517
import styles from './styles/Tooltip.module.css';
1618

17-
interface TooltipProps extends Omit<AriaTooltipProps, 'offset' | 'crossOffset'> {}
19+
interface TooltipProps
20+
extends Omit<AriaTooltipProps, 'offset' | 'crossOffset'>,
21+
VariantProps<typeof tooltip> {}
1822
interface TooltipTriggerProps extends Omit<TooltipTriggerComponentProps, 'delay' | 'closeDelay'> {}
1923

20-
const tooltip = cva(styles.tooltip);
24+
const tooltip = cva(styles.base, {
25+
variants: {
26+
variant: {
27+
default: styles.tooltip,
28+
popover: popoverStyles.popover,
29+
},
30+
},
31+
defaultVariants: {
32+
variant: 'default',
33+
},
34+
});
2135

22-
const _Tooltip = (props: TooltipProps, ref: ForwardedRef<HTMLDivElement>) => {
36+
const _Tooltip = (
37+
{ variant = 'default', ...props }: TooltipProps,
38+
ref: ForwardedRef<HTMLDivElement>,
39+
) => {
2340
return (
2441
<AriaTooltip
2542
{...props}
2643
offset={4}
2744
crossOffset={0}
2845
ref={ref}
2946
className={composeRenderProps(props.className, (className, renderProps) =>
30-
tooltip({ ...renderProps, className }),
47+
tooltip({ ...renderProps, variant, className }),
3148
)}
49+
data-trigger={variant === 'popover' ? 'DialogTrigger' : undefined}
3250
/>
3351
);
3452
};

packages/components/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export { ListBox, ListBoxItem } from './ListBox';
9393
export { Menu, MenuItem, MenuTrigger, SubmenuTrigger } from './Menu';
9494
export { Modal, ModalOverlay } from './Modal';
9595
export { NumberField } from './NumberField';
96-
export { HoverTrigger, OverlayArrow, Popover } from './Popover';
96+
export { OverlayArrow, Popover } from './Popover';
9797
export { Pressable } from './Pressable';
9898
export { ProgressBar } from './ProgressBar';
9999
export { Radio } from './Radio';

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,3 @@
117117
}
118118
}
119119
}
120-
121-
.hover {
122-
& [data-testid='underlay'] {
123-
pointer-events: none;
124-
}
125-
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
}
1111
}
1212

13+
.base {
14+
}
15+
1316
.tooltip {
1417
isolation: isolate;
1518
font: var(--lp-text-body-2-regular);

packages/components/stories/Popover.stories.tsx

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

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

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

178
const meta: Meta<typeof Popover> = {
189
component: Popover,
1910
// @ts-ignore
20-
subcomponents: { OverlayArrow, DialogTrigger, HoverTrigger },
11+
subcomponents: { OverlayArrow, DialogTrigger },
2112
title: 'Components/Overlays/Popover',
2213
parameters: {
2314
status: {
@@ -99,26 +90,6 @@ export const WithHeading: Story = {
9990
play,
10091
};
10192

102-
export const Hover: Story = {
103-
render: (args) => {
104-
return (
105-
<HoverTrigger>
106-
<Button>Trigger</Button>
107-
<Popover {...args}>
108-
<Dialog>Message</Dialog>
109-
</Popover>
110-
</HoverTrigger>
111-
);
112-
},
113-
play: async ({ canvasElement }) => {
114-
const canvas = within(canvasElement);
115-
116-
await userEvent.hover(canvas.getByRole('button'));
117-
const body = canvasElement.ownerDocument.body;
118-
await expect(await within(body).findByRole('dialog'));
119-
},
120-
};
121-
12293
export const CustomTrigger: Story = {
12394
render: (args) => {
12495
return (

0 commit comments

Comments
 (0)