Skip to content

Commit

Permalink
feat: implement new Popover draft (#1969)
Browse files Browse the repository at this point in the history
  • Loading branch information
amje authored Dec 4, 2024
1 parent b77fca5 commit 4823a43
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/components/Popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps {
floatingContext?: FloatingRootContext<ReferenceType>;
/** Additional floating element props to provide interactions */
floatingProps?: Record<string, unknown>;
/** React ref floating element is attached to */
floatingRef?: React.Ref<HTMLDivElement>;
/** Do not use `LayerManager` on stacking popups */
disableLayer?: boolean;
/** @deprecated Add onClick handler to children */
Expand Down Expand Up @@ -112,6 +114,7 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps {
const b = block('popup');

export function Popup({
floatingRef,
keepMounted = false,
hasArrow = false,
open,
Expand Down Expand Up @@ -234,6 +237,7 @@ export function Popup({
}

const handleRef = useForkRef<HTMLDivElement>(
floatingRef,
refs.setFloating,
containerRef,
useParentFocusTrap(),
Expand Down
117 changes: 117 additions & 0 deletions src/components/lab/Popover/Popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react';

import {
safePolygon,
useClick,
useDismiss,
useFloatingRootContext,
useHover,
useInteractions,
useRole,
} from '@floating-ui/react';

import {useControlledState, useForkRef} from '../../../hooks';
import {Popup} from '../../Popup';
import type {PopupProps} from '../../Popup';
import type {DOMProps, QAProps} from '../../types';
import {block} from '../../utils/cn';
import {getElementRef} from '../../utils/getElementRef';

export interface PopoverProps
extends QAProps,
DOMProps,
Pick<
PopupProps,
| 'middlewares'
| 'strategy'
| 'placement'
| 'offset'
| 'keepMounted'
| 'disablePortal'
| 'hasArrow'
| 'contentClassName'
| 'disableEscapeKeyDown'
| 'disableOutsideClick'
| 'disableLayer'
> {
children: React.ReactElement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
content?: React.ReactNode;
trigger?: 'click';
delay?: number | {open?: number; close?: number};
enableSafePolygon?: boolean;
}

const b = block('popover2');
const DEFAULT_DELAY = 500;

export function Popover({
children,
open,
onOpenChange,
disabled,
content,
trigger,
delay = DEFAULT_DELAY,
enableSafePolygon,
className,
contentClassName,
disableEscapeKeyDown,
disableOutsideClick,
...restProps
}: PopoverProps) {
const child = React.Children.only(children);
const childRef = getElementRef(child);

const [anchorElement, setAnchorElement] = React.useState<HTMLButtonElement | null>(null);
const [floatingElement, setFloatingElement] = React.useState<HTMLDivElement | null>(null);
const anchorRef = useForkRef(setAnchorElement, childRef);

const [isOpen, setIsOpen] = useControlledState(open, false, onOpenChange);

const context = useFloatingRootContext({
open: isOpen,
onOpenChange: setIsOpen,
elements: {
reference: anchorElement,
floating: floatingElement,
},
});

const hover = useHover(context, {
enabled: !disabled && trigger !== 'click',
delay:
typeof delay === 'number'
? delay
: {open: delay.open ?? DEFAULT_DELAY, close: delay.close ?? DEFAULT_DELAY},
move: false,
handleClose: enableSafePolygon ? safePolygon() : undefined,
});
const click = useClick(context, {enabled: !disabled});
const dismiss = useDismiss(context, {
escapeKey: !disableEscapeKeyDown,
outsidePress: !disableOutsideClick,
});
const role = useRole(context, {role: 'dialog'});

const {getReferenceProps, getFloatingProps} = useInteractions([hover, click, dismiss, role]);

return (
<React.Fragment>
{React.cloneElement(child, {ref: anchorRef, ...getReferenceProps(child.props)})}
<Popup
{...restProps}
open={isOpen}
floatingContext={context}
floatingRef={setFloatingElement}
floatingProps={getFloatingProps()}
className={b(null, className)}
contentClassName={b('content', contentClassName)}
>
{content}
</Popup>
</React.Fragment>
);
}
54 changes: 54 additions & 0 deletions src/components/lab/Popover/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!--GITHUB_BLOCK-->

# Popover

<!--/GITHUB_BLOCK-->

```tsx
import {Popover} from '@gravity-ui/uikit';
```

The `Popover` component is technically the [`Popup`](./TODO) with some trigger interactivity built-in. The `Popover` uses passed `ReactElement`
from `children` property as a trigger, and opens whenever trigger is hovered or clicked. Content of the `Popover` might contain
interactive elements like links or buttons.

## Usage

Wrap HTML element or any component that accepts native DOM handlers and ARIA attributes in properties (i.e. `Button`) with `Popover` component. Put your content
into `content` property.

```jsx
import React from 'react';
import {Button, Popover} from '@gravity-ui/uikit';

<Popover content="Content">
<Button>Click or hover me</Button>
</Popover>;
```

## Properties

| Name | Description | Type | Default |
| :------------------- | :------------------------------------------------------------------------------------------------------------ | :-----------------------------------------------------------------: | :----------: |
| children | `ReactNode` which accepts DOM handlers | `React.ReactNode` | |
| className | HTML `class` attribute for root node | `string` | |
| content | Any content to render inside the `Popover` | `React.ReactNode` | |
| contentClassName | HTML `class` attribute for content node | `string` | |
| delay | Wait specified time in milliseconds before changing `open` state | `number` `{open?: number; close?: number}` | |
| disableEscapeKeyDown | Do not dismiss on `Esc` keydown | `boolean` | `false` |
| disableLayer | Do not use `LayerManager` on stacking floating elements | `boolean` | `false` |
| disableOutsideClick | Do not dismiss on outside click | `boolean` | `false` |
| disablePortal | Do not use `Portal` for children | `boolean` | `false` |
| disabled | Do not open on any event | `boolean` | `false` |
| enableSafePolygon | Use dynamic polygon area when moving the pointer from trigger to `Popover` content to prevent it from closing | `boolean` | `false` |
| hasArrow | Render an arrow pointing to the trigger | `boolean` | `false` |
| keepMounted | `Popover` will not be removed from the DOM upon hiding | `boolean` | `false` |
| middlewares | `Floating UI` middlewares. If set, they will completely overwrite the default middlewares. | `Array<Middleware>` | |
| offset | `Floating UI` offset value | `PopoverOffset` | `4` |
| onOpenChange | Function that is called when the `open` state changes | `Function` | |
| open | Manually control the `open` state | `boolean` | |
| placement | `Floating UI` placement | `Placement` `Array<Placement>` `"auto"` `"auto-start"` `"auto-end"` | `"top"` |
| qa | Test attribute (`data-qa`) | `string` | |
| strategy | `Floating UI` positioning strategy | `"absolute"` `"fixed"` | `"absolute"` |
| style | HTML `style` attribute for root node | `string` | |
| trigger | Which event should open the `Popover`. By default, `click` and `hover` both do | `"click"` | |
7 changes: 7 additions & 0 deletions src/components/lab/Popover/__stories__/Docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {Meta, Markdown} from '@storybook/addon-docs';
import * as Stories from './Popover.stories';
import Readme from '../README.md?raw';

<Meta of={Stories} />

<Markdown>{Readme}</Markdown>
74 changes: 74 additions & 0 deletions src/components/lab/Popover/__stories__/Popover.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';

import {action} from '@storybook/addon-actions';
import type {Meta, StoryObj} from '@storybook/react';

import {Button} from '../../../Button';
import {Flex} from '../../../layout';
import {Popover} from '../Popover';

const meta: Meta<typeof Popover> = {
title: 'Lab/Popover',
component: Popover,
parameters: {
layout: 'centered',
},
};

export default meta;

type Story = StoryObj<typeof Popover>;

export const Default: Story = {
render: (args) => (
<Popover {...args}>
<Button>Anchor</Button>
</Popover>
),
args: {
content: <div style={{padding: 10}}>Content</div>,
onOpenChange: action('onOpenChange'),
},
};

export const Delay: Story = {
render: (args) => (
<Flex gap={3}>
<Popover {...args} delay={{open: 1000}}>
<Button>Open Delay: 1000ms</Button>
</Popover>
<Popover {...args} delay={{close: 2000}}>
<Button>Close Delay: 2000ms</Button>
</Popover>
</Flex>
),
args: {
...Default.args,
},
};

export const OnlyClick: Story = {
...Default,
args: {
...Default.args,
trigger: 'click',
},
};

export const Disabled: Story = {
...Default,
args: {
...Default.args,
disabled: true,
},
};

export const SafePolygon: Story = {
...Default,
args: {
...Default.args,
delay: 0,
offset: 50,
enableSafePolygon: true,
},
};

0 comments on commit 4823a43

Please sign in to comment.