Skip to content

Commit

Permalink
feat(components): add HoverTrigger (#1312)
Browse files Browse the repository at this point in the history
* feat(components): add `HoverTrigger`

* feat: add tests

* chore: changeset
  • Loading branch information
Niznikr authored May 28, 2024
1 parent bf904e8 commit 5f9a49d
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/good-rivers-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@launchpad-ui/components": patch
---

Add `HoverTrigger`
63 changes: 62 additions & 1 deletion packages/components/__tests__/Popover.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';

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

describe('Popover', () => {
it('renders', async () => {
Expand All @@ -19,4 +19,65 @@ describe('Popover', () => {
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('dialog')).toBeVisible();
});

it('toggles on hover/unhover with HoverTrigger', async () => {
const user = userEvent.setup();
render(
<HoverTrigger>
<Button>Trigger</Button>
<Popover>
<OverlayArrow />
<Dialog>Message</Dialog>
</Popover>
</HoverTrigger>,
);

await user.hover(screen.getByRole('button'));
await user.pointer([{ keys: '[TouchA>]', target: screen.getByRole('button') }]);
expect(await screen.findByRole('dialog')).toBeVisible();

await user.pointer([{ pointerName: 'TouchA', target: document.body }, { keys: '[/TouchA]' }]);
expect(await screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('toggles on click when hovered with HoverTrigger', async () => {
const user = userEvent.setup();
render(
<HoverTrigger>
<Button>Trigger</Button>
<Popover>
<OverlayArrow />
<Dialog>Message</Dialog>
</Popover>
</HoverTrigger>,
);

await user.hover(screen.getByRole('button'));
expect(await screen.findByRole('dialog')).toBeVisible();

await user.click(screen.getByText('Trigger'));
expect(await screen.queryByRole('dialog')).not.toBeInTheDocument();

await user.click(screen.getByRole('button'));
expect(await screen.findByRole('dialog')).toBeVisible();
});

it('stays open when popover is hovered with HoverTrigger', async () => {
const user = userEvent.setup();
render(
<HoverTrigger>
<Button>Trigger</Button>
<Popover>
<OverlayArrow />
<Dialog>Message</Dialog>
</Popover>
</HoverTrigger>,
);

await user.hover(screen.getByRole('button'));
expect(await screen.findByRole('dialog')).toBeVisible();

await user.hover(screen.getByRole('dialog'));
expect(await screen.findByRole('dialog')).toBeVisible();
});
});
8 changes: 5 additions & 3 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,22 @@
"dependencies": {
"@launchpad-ui/icons": "workspace:~",
"@launchpad-ui/tokens": "workspace:~",
"@react-aria/utils": "3.24.0",
"@react-aria/interactions": "3.21.2",
"@react-aria/toast": "3.0.0-beta.11",
"@react-aria/utils": "3.24.0",
"@react-stately/toast": "3.0.0-beta.3",
"@react-types/shared": "3.23.0",
"class-variance-authority": "0.7.0",
"react-aria": "3.33.0",
"react-aria-components": "1.2.0",
"react-router-dom": "6.16.0"
"react-router-dom": "6.16.0",
"react-stately": "3.31.0"
},
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"react-stately": "3.31.0",
"react": "18.3.1",
"react-dom": "18.3.1"
}
Expand Down
89 changes: 87 additions & 2 deletions packages/components/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,24 @@ import type { ForwardedRef } from 'react';
import type {
OverlayArrowProps as AriaOverlayArrowProps,
PopoverProps as AriaPopoverProps,
DialogTriggerProps,
} from 'react-aria-components';

import { PressResponder } from '@react-aria/interactions';
import { useId, useLayoutEffect } from '@react-aria/utils';
import { cva } from 'class-variance-authority';
import { forwardRef, useContext } from 'react';
import { forwardRef, useCallback, useContext, useRef } from 'react';
import { useHover, useOverlayTrigger } from 'react-aria';
import {
OverlayArrow as AriaOverlayArrow,
Popover as AriaPopover,
PopoverContext as AriaPopoverContext,
DialogContext,
OverlayTriggerStateContext,
Provider,
composeRenderProps,
} from 'react-aria-components';
import { useOverlayTriggerState } from 'react-stately';

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

/**
* 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.
*
* https://react-spectrum.adobe.com/react-aria/Popover.html
*/
const OverlayArrow = forwardRef(_OverlayArrow);

export { OverlayArrow, Popover };
const HoverTrigger = (props: DialogTriggerProps) => {
const state = useOverlayTriggerState(props);

const buttonRef = useRef<HTMLButtonElement>(null);
const { triggerProps, overlayProps } = useOverlayTrigger({ type: 'dialog' }, state, buttonRef);

triggerProps.id = useId();
// @ts-expect-error
overlayProps['aria-labelledby'] = triggerProps.id;

const ref = useRef<HTMLSpanElement>(null);
const openTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();

// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const cancelOpenTimeout = useCallback(() => {
if (openTimeout.current) {
clearTimeout(openTimeout.current);
openTimeout.current = undefined;
}
}, [openTimeout]);

const onHoverChange = (isHovering: boolean) => {
if (!openTimeout.current) {
openTimeout.current = setTimeout(() => {
cancelOpenTimeout();
state.setOpen(isHovering);
}, 250);
} else {
cancelOpenTimeout();
}
};

const { hoverProps } = useHover({
onHoverChange,
});

useLayoutEffect(() => {
return () => {
cancelOpenTimeout();
};
}, [cancelOpenTimeout]);

const shouldCloseOnInteractOutside = (target: Element) => {
return target !== buttonRef.current;
};

return (
<Provider
values={[
[OverlayTriggerStateContext, state],
[DialogContext, overlayProps],
[
AriaPopoverContext,
{
trigger: 'DialogTrigger',
triggerRef: buttonRef,
UNSTABLE_portalContainer: ref.current || undefined,
shouldCloseOnInteractOutside,
},
],
]}
>
<span className={styles.hover} ref={ref} {...hoverProps}>
<PressResponder {...triggerProps} ref={buttonRef} isPressed={state.isOpen}>
{props.children}
</PressResponder>
</span>
</Provider>
);
};

export { HoverTrigger, OverlayArrow, Popover };
export type { OverlayArrowProps, PopoverProps };
2 changes: 1 addition & 1 deletion packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export { ExternalLinkIconButton, LinkIconButton } from './LinkIconButton';
export { ListBox, ListBoxItem } from './ListBox';
export { Menu, MenuItem, MenuTrigger, SubmenuTrigger } from './Menu';
export { Modal, ModalOverlay } from './Modal';
export { OverlayArrow, Popover } from './Popover';
export { HoverTrigger, OverlayArrow, Popover } from './Popover';
export { ProgressBar } from './ProgressBar';
export { Radio } from './Radio';
export { RadioButton } from './RadioButton';
Expand Down
6 changes: 6 additions & 0 deletions packages/components/src/styles/Popover.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,9 @@
}
}
}

.hover {
& [data-testid='underlay'] {
pointer-events: none;
}
}
32 changes: 30 additions & 2 deletions packages/components/stories/Popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import type { PlayFunction } from '@storybook/types';

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

import { Button, Dialog, DialogTrigger, Heading, OverlayArrow, Popover } from '../src';
import {
Button,
Dialog,
DialogTrigger,
Heading,
HoverTrigger,
OverlayArrow,
Popover,
} from '../src';

const meta: Meta<typeof Popover> = {
component: Popover,
// @ts-ignore
subcomponents: { OverlayArrow, DialogTrigger },
subcomponents: { OverlayArrow, DialogTrigger, HoverTrigger },
title: 'Components/Overlays/Popover',
parameters: {
status: {
Expand Down Expand Up @@ -89,3 +97,23 @@ export const WithHeading: Story = {
},
play,
};

export const Hover: Story = {
render: (args) => {
return (
<HoverTrigger>
<Button>Trigger</Button>
<Popover {...args}>
<Dialog>Message</Dialog>
</Popover>
</HoverTrigger>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await userEvent.hover(canvas.getByRole('button'));
const body = canvasElement.ownerDocument.body;
await expect(await within(body).findByRole('dialog'));
},
};
12 changes: 9 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5f9a49d

Please sign in to comment.