diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index a7d0ef50c..f26b850c3 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -63,6 +63,8 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { floatingContext?: FloatingRootContext; /** Additional floating element props to provide interactions */ floatingProps?: Record; + /** React ref floating element is attached to */ + floatingRef?: React.Ref; /** Do not use `LayerManager` on stacking popups */ disableLayer?: boolean; /** @deprecated Add onClick handler to children */ @@ -112,6 +114,7 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { const b = block('popup'); export function Popup({ + floatingRef, keepMounted = false, hasArrow = false, open, @@ -234,6 +237,7 @@ export function Popup({ } const handleRef = useForkRef( + floatingRef, refs.setFloating, containerRef, useParentFocusTrap(), diff --git a/src/components/lab/Popover/Popover.tsx b/src/components/lab/Popover/Popover.tsx new file mode 100644 index 000000000..f3eb92029 --- /dev/null +++ b/src/components/lab/Popover/Popover.tsx @@ -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(null); + const [floatingElement, setFloatingElement] = React.useState(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.cloneElement(child, {ref: anchorRef, ...getReferenceProps(child.props)})} + + {content} + + + ); +} diff --git a/src/components/lab/Popover/README.md b/src/components/lab/Popover/README.md new file mode 100644 index 000000000..967d6aa3e --- /dev/null +++ b/src/components/lab/Popover/README.md @@ -0,0 +1,54 @@ + + +# Popover + + + +```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'; + + + +; +``` + +## 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` | | +| 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` `"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"` | | diff --git a/src/components/lab/Popover/__stories__/Docs.mdx b/src/components/lab/Popover/__stories__/Docs.mdx new file mode 100644 index 000000000..f7a8df2af --- /dev/null +++ b/src/components/lab/Popover/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import * as Stories from './Popover.stories'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/lab/Popover/__stories__/Popover.stories.tsx b/src/components/lab/Popover/__stories__/Popover.stories.tsx new file mode 100644 index 000000000..d7870ee55 --- /dev/null +++ b/src/components/lab/Popover/__stories__/Popover.stories.tsx @@ -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 = { + title: 'Lab/Popover', + component: Popover, + parameters: { + layout: 'centered', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + ), + args: { + content:
Content
, + onOpenChange: action('onOpenChange'), + }, +}; + +export const Delay: Story = { + render: (args) => ( + + + + + + + + + ), + 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, + }, +};