Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement new Popover draft #1969

Merged
merged 4 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]);
ValeraS marked this conversation as resolved.
Show resolved Hide resolved

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,
},
};
Loading