diff --git a/package-lock.json b/package-lock.json index c10e57b538..3e1e5e0ee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,15 @@ "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", + "@floating-ui/react": "^0.26.24", "@gravity-ui/i18n": "^1.6.0", "@gravity-ui/icons": "^2.8.1", - "@popperjs/core": "^2.11.8", "blueimp-md5": "^2.19.0", "focus-trap": "^7.5.4", "lodash": "^4.17.21", "rc-slider": "^10.6.2", "react-beautiful-dnd": "^13.1.1", "react-copy-to-clipboard": "^5.1.0", - "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "react-virtualized-auto-sizer": "^1.0.21", "react-window": "^1.8.10", @@ -3334,6 +3333,54 @@ "integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==", "dev": true }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.24", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.24.tgz", + "integrity": "sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@gravity-ui/eslint-config": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@gravity-ui/eslint-config/-/eslint-config-3.1.1.tgz", @@ -4459,15 +4506,6 @@ "node": ">=18" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@radix-ui/primitive": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", @@ -23213,30 +23251,11 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "node_modules/react-popper": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", - "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", - "dependencies": { - "react-fast-compare": "^3.0.1", - "warning": "^4.0.2" - }, - "peerDependencies": { - "@popperjs/core": "^2.0.0", - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, "node_modules/react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", @@ -27583,14 +27602,6 @@ "makeerror": "1.0.12" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", diff --git a/package.json b/package.json index 8aeefdf126..262593ba41 100644 --- a/package.json +++ b/package.json @@ -118,16 +118,15 @@ }, "dependencies": { "@bem-react/classname": "^1.6.0", + "@floating-ui/react": "^0.26.24", "@gravity-ui/i18n": "^1.6.0", "@gravity-ui/icons": "^2.8.1", - "@popperjs/core": "^2.11.8", "blueimp-md5": "^2.19.0", "focus-trap": "^7.5.4", "lodash": "^4.17.21", "rc-slider": "^10.6.2", "react-beautiful-dnd": "^13.1.1", "react-copy-to-clipboard": "^5.1.0", - "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "react-virtualized-auto-sizer": "^1.0.21", "react-window": "^1.8.10", diff --git a/src/components/ActionTooltip/ActionTooltip.tsx b/src/components/ActionTooltip/ActionTooltip.tsx index ad2b5e38b9..7d372d6ccc 100644 --- a/src/components/ActionTooltip/ActionTooltip.tsx +++ b/src/components/ActionTooltip/ActionTooltip.tsx @@ -60,7 +60,7 @@ export function ActionTooltip(props: ActionTooltipProps) { style={style} open={tooltipVisible && !disabled} placement={placement} - anchorRef={{current: anchorElement}} + anchorElement={anchorElement} disableEscapeKeyDown disableOutsideClick disableLayer diff --git a/src/components/DropdownMenu/DropdownMenuPopup.tsx b/src/components/DropdownMenu/DropdownMenuPopup.tsx index 6d356ce9dc..df1e3a639a 100644 --- a/src/components/DropdownMenu/DropdownMenuPopup.tsx +++ b/src/components/DropdownMenu/DropdownMenuPopup.tsx @@ -142,6 +142,7 @@ export const DropdownMenuPopup = ({ open={open} anchorRef={anchorRef} onClose={onClose} + placement="bottom-start" {...popupProps} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} diff --git a/src/components/FilePreview/__stories__/FilePreview.stories.tsx b/src/components/FilePreview/__stories__/FilePreview.stories.tsx index bd2caf7c19..aea550e359 100644 --- a/src/components/FilePreview/__stories__/FilePreview.stories.tsx +++ b/src/components/FilePreview/__stories__/FilePreview.stories.tsx @@ -102,6 +102,14 @@ const CollageTemplate: StoryFn = () => { export const Collage = CollageTemplate.bind({}); +const noClickableTemplateActions = [ + { + icon: , + onClick: () => action('Are you sure you want to delete the file?'), + title: 'Close', + }, +]; + const NoClickableTemplate: StoryFn> = (args) => { return ( @@ -109,25 +117,13 @@ const NoClickableTemplate: StoryFn> = (args) = , - onClick: () => action('Are you sure you want to delete the file?'), - title: 'Close', - }, - ]} + actions={noClickableTemplateActions} /> action('onClick')} - actions={[ - { - icon: , - onClick: () => action('Are you sure you want to delete the file?'), - title: 'Close', - }, - ]} + actions={noClickableTemplateActions} /> ); @@ -135,6 +131,17 @@ const NoClickableTemplate: StoryFn> = (args) = export const NoClickable = NoClickableTemplate.bind({}); +const withoutActionTooltipTemplateActions = [ + { + icon: , + onClick: () => action('onClose'), + title: 'Close', + tooltipExtraProps: { + disabled: true, + }, + }, +]; + const WithoutActionTooltipTemplate: StoryFn> = (args) => { return ( @@ -142,16 +149,7 @@ const WithoutActionTooltipTemplate: StoryFn> = {...args} file={{name: 'Clicable without tooltip', type: 'text/docs'} as File} onClick={() => action('onClick')} - actions={[ - { - icon: , - onClick: () => action('onClose'), - title: 'Close', - tooltipExtraProps: { - disabled: true, - }, - }, - ]} + actions={withoutActionTooltipTemplateActions} /> ); diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index d6d7087be9..d3d46950e8 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -56,6 +56,7 @@ export const Popover = React.forwardRef @@ -185,7 +189,7 @@ export const Popover = React.forwardRef ); - if (anchorRef) { + if (hasAnchor) { return tooltip; } diff --git a/src/components/Popover/README.md b/src/components/Popover/README.md index 774303d967..1e97b29373 100644 --- a/src/components/Popover/README.md +++ b/src/components/Popover/README.md @@ -231,45 +231,45 @@ const close = () => { ## Properties -| Name | Description | Type | Default | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------: | :-------------------: | -| anchorRef | `Popper.js` anchor element. Can also be `popper.VirtualElement`. | [`PopupAnchorRef`](../Popup/README.md#anchor) | | -| autoclosable | Whether the tooltip automatically closes when cursor moves outside it | `boolean` | `true` | -| autoFocus | If true, focus will be transferred to the first element when the popover opens | `boolean` | | -| behavior | Tooltip open/close behaviour when `openOnHover`. `"immediate"` - without any delay. `"delayed"` - with 300ms delay for opening and closing. `"delayedClosing"` - with 300ms delay only for closing. Won't be applied if `delayOpening` or `delayClosing` are passed. | `"immediate"` `"delayed"` `"delayedClosing"` | `"delayed"` | -| children | Tooltip's trigger content over which the tooltip is shown. Can be function `(triggerProps: `[`TriggerProps`](#triggerprops))` => React.ReactNode` or `ReactNode` | `React.ReactNode` `Function` | | -| className | css class for the control | `string` | | -| content | Tooltip's content | `React.ReactNode` | | -| contentClassName | css class for `content` | `string` | | -| delayClosing | Custom delay for closing if autoclosable | `number` | | -| delayOpening | Custom delay for opening if openOnHover | `number` | | -| disabled | Disables open state changes | `boolean` | `false` | -| disablePortal | Disable rendering of the popover in a portal | `boolean` | `false` | -| focusTrap | Prevent focus from leaving the popover while open | `boolean` | | -| forceLinksAppearance | Force styles for links | `boolean` | `false` | -| hasArrow | Whether the tooltip has a tail | `boolean` | `true` | -| hasClose | Whether the tooltip has a close button | `boolean` | `false` | -| htmlContent | Tooltip's html content to be rendered via `dangerouslySetInnerHTML` | `string` | | -| initialOpen | Whether the tooltip initially opened | `boolean` | `false` | -| links | Links under the content | `[`[`LinkProps`](#linksprops)`]` | | -| offset | Control's offset | `{top: number, left: number}` | | -| onClick | Anchor click callback `(event: React.MouseEvent) => boolean \| Promise`. If the function returns `true', the tooltip will be open, otherwise it won't be opened. | `Function` | | -| onCloseClick | Close button click handler `(event: React.MouseEvent) => void` | `Function` | | -| onOpenChange | Open state change handler `(open: boolean) => void`. Might be useful for the delayed rendering of the tooltip's content. | `Function` | | -| openOnHover | Whether the tooltip opens when hovered | `boolean` | `true` | -| placement | `Popper.js` placement | [`PopupPlacement`](../Popup/README.md#placement) | `["right", "bottom"]` | -| qa | HTML `data-qa` attribute, used in tests | `string` | | -| restoreFocusRef | Focused element when the popover closes | `React.RefObject` | | -| size | Tooltip's size | `"s"` `"l"` | `"s"` | -| strategy | `Popper.js` positioning [strategy](https://popper.js.org/docs/v2/constructors/#strategy) | `"absolute"` `"fixed"` | `"absolute"` | -| title | Tooltip's title | `string` | | -| theme | Tooltip's theme | `"info"` `"special"` `"announcement"` | `"info"` | -| tooltipActionButton | Action button properties. The button won't be rendered without it. | [`PopoverButtonProps`](#popoverbuttonprops) | | -| tooltipCancelButton | Cancel button properties. The button won't be rendered without it. | [`PopoverButtonProps`](#popoverbuttonprops) | | -| tooltipClassName | Tooltip's css class | `string` | | -| tooltipContentClassName | Tooltip's content css class | `string` | | -| tooltipOffset | Tooltip's offset relative to the control | `[number, number]` | | -| tooltipId | The html id attribute of the popover | `string` | | +| Name | Description | Type | Default | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------------------------: | :-------------------: | +| anchorElement | `Popup` anchor element. Can also be `VirtualElement`. | [`PopupAnchorElement`](../Popup/README.md#anchor) | | +| autoclosable | Whether the tooltip automatically closes when cursor moves outside it | `boolean` | `true` | +| autoFocus | If true, focus will be transferred to the first element when the popover opens | `boolean` | | +| behavior | Tooltip open/close behaviour when `openOnHover`. `"immediate"` - without any delay. `"delayed"` - with 300ms delay for opening and closing. `"delayedClosing"` - with 300ms delay only for closing. Won't be applied if `delayOpening` or `delayClosing` are passed. | `"immediate"` `"delayed"` `"delayedClosing"` | `"delayed"` | +| children | Tooltip's trigger content over which the tooltip is shown. Can be function `(triggerProps: `[`TriggerProps`](#triggerprops))` => React.ReactNode` or `ReactNode` | `React.ReactNode` `Function` | | +| className | css class for the control | `string` | | +| content | Tooltip's content | `React.ReactNode` | | +| contentClassName | css class for `content` | `string` | | +| delayClosing | Custom delay for closing if autoclosable | `number` | | +| delayOpening | Custom delay for opening if openOnHover | `number` | | +| disabled | Disables open state changes | `boolean` | `false` | +| disablePortal | Disable rendering of the popover in a portal | `boolean` | `false` | +| focusTrap | Prevent focus from leaving the popover while open | `boolean` | | +| forceLinksAppearance | Force styles for links | `boolean` | `false` | +| hasArrow | Whether the tooltip has a tail | `boolean` | `true` | +| hasClose | Whether the tooltip has a close button | `boolean` | `false` | +| htmlContent | Tooltip's html content to be rendered via `dangerouslySetInnerHTML` | `string` | | +| initialOpen | Whether the tooltip initially opened | `boolean` | `false` | +| links | Links under the content | `[`[`LinkProps`](#linksprops)`]` | | +| offset | Control's offset | `{top: number, left: number}` | | +| onClick | Anchor click callback `(event: React.MouseEvent) => boolean \| Promise`. If the function returns `true', the tooltip will be open, otherwise it won't be opened. | `Function` | | +| onCloseClick | Close button click handler `(event: React.MouseEvent) => void` | `Function` | | +| onOpenChange | Open state change handler `(open: boolean) => void`. Might be useful for the delayed rendering of the tooltip's content. | `Function` | | +| openOnHover | Whether the tooltip opens when hovered | `boolean` | `true` | +| placement | `Popup` placement | [`PopupPlacement`](../Popup/README.md#placement) | `["right", "bottom"]` | +| qa | HTML `data-qa` attribute, used in tests | `string` | | +| restoreFocusRef | Focused element when the popover closes | `React.RefObject` | | +| size | Tooltip's size | `"s"` `"l"` | `"s"` | +| strategy | `Floating UI` positioning [strategy](https://floating-ui.com/docs/computePosition#strategy) | `"absolute"` `"fixed"` | `"absolute"` | +| title | Tooltip's title | `string` | | +| theme | Tooltip's theme | `"info"` `"special"` `"announcement"` | `"info"` | +| tooltipActionButton | Action button properties. The button won't be rendered without it. | [`PopoverButtonProps`](#popoverbuttonprops) | | +| tooltipCancelButton | Cancel button properties. The button won't be rendered without it. | [`PopoverButtonProps`](#popoverbuttonprops) | | +| tooltipClassName | Tooltip's css class | `string` | | +| tooltipContentClassName | Tooltip's content css class | `string` | | +| tooltipOffset | Tooltip's offset relative to the control | `[number, number]` | | +| tooltipId | The html id attribute of the popover | `string` | | ### TriggerProps diff --git a/src/components/Popover/index.ts b/src/components/Popover/index.ts index 11e52e1806..18c9d69577 100644 --- a/src/components/Popover/index.ts +++ b/src/components/Popover/index.ts @@ -4,5 +4,6 @@ export type { PopoverProps, PopoverInstanceProps, PopoverAnchorRef, + PopoverAnchorElement, } from './types'; export {PopoverBehavior} from './config'; diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index 1331e8d7af..cc936a6b05 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -1,4 +1,4 @@ -import type {PopupAnchorRef, PopupProps} from '../Popup'; +import type {PopupAnchorElement, PopupAnchorRef, PopupOffset, PopupProps} from '../Popup'; import type {ButtonsProps} from './components/Buttons/Buttons'; import type {ContentProps} from './components/Content/Content'; @@ -41,7 +41,7 @@ export interface PopoverExternalProps { */ tooltipCancelButton?: ButtonsProps['tooltipCancelButton']; /** Tooltip's offset relative to the control */ - tooltipOffset?: [number, number]; + tooltipOffset?: PopupOffset; /** Tooltip's css class */ tooltipClassName?: string; /** Tooltip's content css class */ @@ -92,6 +92,7 @@ export type PopoverBehaviorProps = { export type PopoverTheme = 'info' | 'special' | 'announcement'; export type PopoverAnchorRef = PopupAnchorRef; +export type PopoverAnchorElement = PopupAnchorElement; export type PopoverDefaultProps = { /** Whether the tooltip initially opened */ @@ -121,7 +122,10 @@ export type PopoverDefaultProps = { size: 's' | 'l'; }; -export type PopoverProps = Pick & +export type PopoverProps = Pick< + PopupProps, + 'anchorElement' | 'anchorRef' | 'strategy' | 'placement' | 'middlewares' +> & PopoverExternalProps & PopoverBehaviorProps & Partial; diff --git a/src/components/Popup/Popup.scss b/src/components/Popup/Popup.scss index acd49b2140..a975e415b3 100644 --- a/src/components/Popup/Popup.scss +++ b/src/components/Popup/Popup.scss @@ -17,25 +17,32 @@ $transition-distance: 10px; z-index: 1000; visibility: hidden; + width: max-content; + position: absolute; + // stylelint-disable-next-line csstools/use-logical + top: 0; + // stylelint-disable-next-line csstools/use-logical + left: 0; + &_open, &_exit_active { visibility: visible; } &_exit_active { - &[data-popper-placement*='bottom'] #{$block}__content { + &[data-floating-placement*='bottom'] #{$block}__content { animation-name: #{variables.$ns}popup-bottom; } - &[data-popper-placement*='top'] #{$block}__content { + &[data-floating-placement*='top'] #{$block}__content { animation-name: #{variables.$ns}popup-top; } - &[data-popper-placement*='left'] #{$block}__content { + &[data-floating-placement*='left'] #{$block}__content { animation-name: #{variables.$ns}popup-left; } - &[data-popper-placement*='right'] #{$block}__content { + &[data-floating-placement*='right'] #{$block}__content { animation-name: #{variables.$ns}popup-right; } } @@ -43,29 +50,29 @@ $transition-distance: 10px; // open state &_enter_active, &_appear_active { - &[data-popper-placement*='bottom'] #{$block}__content { + &[data-floating-placement*='bottom'] #{$block}__content { animation-name: #{variables.$ns}popup-bottom-open; } - &[data-popper-placement*='top'] #{$block}__content { + &[data-floating-placement*='top'] #{$block}__content { animation-name: #{variables.$ns}popup-top-open; } - &[data-popper-placement*='left'] #{$block}__content { + &[data-floating-placement*='left'] #{$block}__content { animation-name: #{variables.$ns}popup-left-open; } - &[data-popper-placement*='right'] #{$block}__content { + &[data-floating-placement*='right'] #{$block}__content { animation-name: #{variables.$ns}popup-right-open; } } // arrow - &[data-popper-placement*='bottom'] #{$block}__arrow { + &[data-floating-placement*='bottom'] #{$block}__arrow { inset-block-start: -$arrow-offset; } - &[data-popper-placement*='top'] #{$block}__arrow { + &[data-floating-placement*='top'] #{$block}__arrow { inset-block-end: -$arrow-offset; &-content { @@ -73,7 +80,7 @@ $transition-distance: 10px; } } - &[data-popper-placement*='left'] #{$block}__arrow { + &[data-floating-placement*='left'] #{$block}__arrow { // stylelint-disable-next-line csstools/use-logical right: -$arrow-offset; @@ -82,7 +89,7 @@ $transition-distance: 10px; } } - &[data-popper-placement*='right'] #{$block}__arrow { + &[data-floating-placement*='right'] #{$block}__arrow { // stylelint-disable-next-line csstools/use-logical left: -$arrow-offset; @@ -116,6 +123,10 @@ $transition-distance: 10px; } } + &__arrow { + position: absolute; + } + &__arrow-content { width: $arrow-size; height: $arrow-size; diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index 15768bf02b..a7d0ef50c4 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -2,11 +2,28 @@ import React from 'react'; +import { + arrow, + autoPlacement, + autoUpdate, + flip, + offset as floatingOffset, + limitShift, + shift, + useFloating, +} from '@floating-ui/react'; +import type { + Alignment, + FloatingRootContext, + Middleware, + Placement, + ReferenceType, + Strategy, +} from '@floating-ui/react'; import {CSSTransition} from 'react-transition-group'; import {useForkRef} from '../../hooks'; -import {usePopper, useRestoreFocus} from '../../hooks/private'; -import type {PopperAnchorRef, PopperPlacement, PopperProps} from '../../hooks/private'; +import {useRestoreFocus} from '../../hooks/private'; import {Portal} from '../Portal'; import type {DOMProps, QAProps} from '../types'; import {FocusTrap, useParentFocusTrap} from '../utils/FocusTrap'; @@ -16,13 +33,13 @@ import type {LayerExtendableProps} from '../utils/layer-manager/LayerManager'; import {getCSSTransitionClassNames} from '../utils/transition'; import {PopupArrow} from './PopupArrow'; +import {useAnchor} from './hooks'; +import type {PopupAnchorElement, PopupAnchorRef, PopupOffset, PopupPlacement} from './types'; +import {getOffsetValue, isAutoPlacement} from './utils'; import './Popup.scss'; -export type PopupPlacement = PopperPlacement; -export type PopupAnchorRef = PopperAnchorRef; - -export interface PopupProps extends DOMProps, LayerExtendableProps, PopperProps, QAProps { +export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { children?: React.ReactNode; /** Manages `Popup` visibility */ open?: boolean; @@ -30,6 +47,22 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, PopperProps, keepMounted?: boolean; /** Render an arrow pointing to the anchor */ hasArrow?: boolean; + /** Floating UI strategy */ + strategy?: Strategy; + /** floating element placement */ + placement?: PopupPlacement; + /** floating element offset relative to anchor */ + offset?: PopupOffset; + /** floating element anchor */ + anchorElement?: PopupAnchorElement | null; + /** floating element anchor ref object */ + anchorRef?: PopupAnchorRef; + /** Floating UI middlewares. If set, they will completely overwrite the default middlewares. */ + middlewares?: Middleware[]; + /** Floating UI context to provide interactions */ + floatingContext?: FloatingRootContext; + /** Additional floating element props to provide interactions */ + floatingProps?: Record; /** Do not use `LayerManager` on stacking popups */ disableLayer?: boolean; /** @deprecated Add onClick handler to children */ @@ -77,21 +110,25 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, PopperProps, } const b = block('popup'); -const ARROW_SIZE = 8; + export function Popup({ keepMounted = false, hasArrow = false, - offset = [0, 4], open, - placement, + strategy, + placement = 'top', + offset = 4, + anchorElement, anchorRef, + floatingContext, + floatingProps, disableEscapeKeyDown, disableOutsideClick, disableLayer, style, className, contentClassName, - modifiers = [], + middlewares, children, onEscapeKeyDown, onOutsideClick, @@ -107,7 +144,6 @@ export function Popup({ onTransitionExited, disablePortal, container, - strategy, qa, restoreFocus, restoreFocusRef, @@ -120,6 +156,39 @@ export function Popup({ 'aria-modal': ariaModal = focusTrap, }: PopupProps) { const containerRef = React.useRef(null); + const [arrowElement, setArrowElement] = React.useState(null); + + const anchor = useAnchor(anchorElement, anchorRef); + const offsetValue = getOffsetValue(offset, hasArrow); + + let placementValue: Placement | undefined; + let preventOverflowMiddleware: Middleware; + + if (Array.isArray(placement)) { + placementValue = placement[0]; + preventOverflowMiddleware = flip({ + altBoundary: disablePortal, + fallbackPlacements: placement.slice(1), + }); + } else if (isAutoPlacement(placement)) { + let alignment: Alignment | undefined; + if (placement === 'auto-start') { + alignment = 'start'; + } else if (placement === 'auto-end') { + alignment = 'end'; + } + + placementValue = undefined; + preventOverflowMiddleware = autoPlacement({ + altBoundary: disablePortal, + alignment, + }); + } else { + placementValue = placement; + preventOverflowMiddleware = flip({ + altBoundary: disablePortal, + }); + } useLayer({ open, @@ -128,27 +197,47 @@ export function Popup({ onEscapeKeyDown, onOutsideClick, onClose, - contentRefs: [anchorRef, containerRef], + contentRefs: [anchor.ref, containerRef], enabled: !disableLayer, type: 'popup', }); - const {attributes, styles, setPopperRef, setArrowRef} = usePopper({ - anchorRef, - placement, - // Take arrow size into offset account - offset: hasArrow ? [offset[0], offset[1] + ARROW_SIZE] : offset, + const { + refs, + floatingStyles, + placement: actualPlacement, + middlewareData, + } = useFloating({ + rootContext: floatingContext, strategy, - altBoundary: disablePortal, - modifiers: [ - // Properly display arrow within rounded container - {name: 'arrow', options: {enabled: hasArrow, padding: 4}}, - // Prevent border hiding - {name: 'preventOverflow', options: {padding: 1, altBoundary: disablePortal}}, - ...modifiers, + placement: placementValue, + open, + whileElementsMounted: open ? autoUpdate : undefined, + elements: { + // @ts-expect-error: Type 'Element | VirtualElement | undefined' is not assignable to type 'Element | null | undefined'. + reference: anchor.element, + }, + middleware: middlewares ?? [ + floatingOffset(offsetValue), + preventOverflowMiddleware, + shift({limiter: limitShift(), altBoundary: disablePortal}), + arrow({element: arrowElement, padding: 4}), ], }); - const handleRef = useForkRef(setPopperRef, containerRef, useParentFocusTrap()); + + const arrowStyles: React.CSSProperties = {}; + + if (hasArrow && middlewareData.arrow) { + const {x, y} = middlewareData.arrow; + arrowStyles.left = x; + arrowStyles.top = y; + } + + const handleRef = useForkRef( + refs.setFloating, + containerRef, + useParentFocusTrap(), + ); const containerProps = useRestoreFocus({ enabled: Boolean(restoreFocus && open), @@ -185,8 +274,8 @@ export function Popup({
{/* FIXME The onClick event handler is deprecated and should be removed */} @@ -210,11 +300,7 @@ export function Popup({ tabIndex={-1} > {hasArrow && ( - + )} {children}
diff --git a/src/components/Popup/PopupArrow.tsx b/src/components/Popup/PopupArrow.tsx index 1514c23065..8dbae52f23 100644 --- a/src/components/Popup/PopupArrow.tsx +++ b/src/components/Popup/PopupArrow.tsx @@ -14,13 +14,7 @@ interface PopupArrowProps { export function PopupArrow({styles, attributes, setArrowRef}: PopupArrowProps) { return ( -
+
diff --git a/src/components/Popup/README.md b/src/components/Popup/README.md index aa0208e512..ad1dedec88 100644 --- a/src/components/Popup/README.md +++ b/src/components/Popup/README.md @@ -8,13 +8,13 @@ import {Popup} from '@gravity-ui/uikit'; ``` -`Popup` can be used to display floating content above the page. It is a wrapper around [Popper.js](https://popper.js.org) +`Popup` can be used to display floating content above the page. It is a wrapper around [Floating UI](https://floating-ui.com) with some defaults. To manage `Popup` visibility, use the `open` property. The `Popup` child components are rendered inside the [`Portal`](../Portal) component. To disable this behavior, use the `disablePortal` property. ## Anchor -Ref object of the DOM element is passed to the `anchorRef` property to create a `Popper.js` instance. +To specify the anchor of a floating element, you can use either the `anchorElement` property. ## Properties -| Name | Description | Type | Default | -| :------------------- | :--------------------------------------------------------------------------------- | :--------------------------------------: | :------------------------------: | -| altBoundary | `altBoundary` parameter for `Popper.js` `offset` modifier | `boolean` | `false` | -| anchorRef | `Popper.js` anchor element. Can also be `popper.VirtualElement` | `PopupAnchorRef` | | -| autoFocus | While open, the focus will be set to the first interactive element in the content | `boolean` | `false` | -| children | Any React content | `React.ReactNode` | | -| className | HTML `class` attribute for root node | `string` | | -| container | DOM element children to be mounted to | `HTMLElement` | `document.body` | -| contentClassName | HTML `class` attribute for content node | `string` | | -| disableEscapeKeyDown | Do not trigger close on `Esc` | `boolean` | `false` | -| disableLayer | Do not use `LayerManager` on stacking popups | `boolean` | `false` | -| disableOutsideClick | Do not trigger close on outside clicks | `boolean` | `false` | -| disablePortal | Do not use `Portal` for children | `boolean` | `false` | -| focusTrap | Enable focus trapping behavior | `boolean` | `false` | -| hasArrow | Render an arrow pointing to the anchor | `boolean` | `false` | -| id | HTML `id` attribute | `string` | | -| keepMounted | `Popup` will not be removed from the DOM upon hiding | `boolean` | `false` | -| modifiers | `Popper.js` modifiers in addition to default: `arrow`, `offset`, `flip` | `Array` | `[0, 4]` | -| offset | `Popper.js` offset | `[number, number]` | `[0, 4]` | -| onBlur | `blur` event handler | `Function` | | -| onClose | Handle `Popup` close event | `Function` | | -| onEnterKeyDown | `Enter` press event handler | `Function` | | -| onEscapeKeyDown | `Esc` press event handler | `Function` | | -| onFocus | `focus` event handler | `Function` | | -| onMouseEnter | `mouseenter` event handler | `Function` | | -| onMouseLeave | `mouseleave` event handler | `Function` | | -| onOutsideClick | Outside click event handler | `Function` | | -| onTransitionEnter | On start open popup animation | `Function` | | -| onTransitionEntered | On finish open popup animation | `Function` | | -| onTransitionExit | On start close popup animation | `Function` | | -| onTransitionExited | On finish close popup animation | `Function` | | -| open | Manages `Popup` visibility | `boolean` | `false` | -| placement | `Popper.js` placement | `PopupPlacement` `Array` | | -| qa | Test attribute (`data-qa`) | `string` | | -| restoreFocus | If true, the focus will return to the anchor element | `boolean` | `false` | -| restoreFocusRef | Element the focus will be restored to | `React.RefObject` | | -| aria-labelledby | `aria-labelledby` attribute, prefer this attribute if you have visible caption | `string` | | -| aria-label | `aria-label` attribute, use this attribute only if you didn't have visible caption | `string` | | -| aria-modal | The `aria-modal` attribute indicates whether an element is modal when displayed. | `Booleanish` | value of `focusTrap` | -| role | The accessibility role for popup | `string` | `dialog` if `aria-modal` is true | -| strategy | `Popper.js` positioning strategy | `popper.PositioningStrategy` | `[0, 4]` | -| style | HTML `style` attribute for root node | `string` | | +| Name | Description | Type | Default | +| :------------------- | :----------------------------------------------------------------------------------------- | :-----------------------------------------------------------: | :------------------------------: | +| anchorElement | Anchor element. Can also be `VirtualElement` | `PopupAnchorElement` | | +| autoFocus | While open, the focus will be set to the first interactive element in the content | `boolean` | `false` | +| children | Any React content | `React.ReactNode` | | +| className | HTML `class` attribute for root node | `string` | | +| container | DOM element children to be mounted to | `HTMLElement` | `document.body` | +| contentClassName | HTML `class` attribute for content node | `string` | | +| disableEscapeKeyDown | Do not trigger close on `Esc` | `boolean` | `false` | +| disableLayer | Do not use `LayerManager` on stacking popups | `boolean` | `false` | +| disableOutsideClick | Do not trigger close on outside clicks | `boolean` | `false` | +| disablePortal | Do not use `Portal` for children | `boolean` | `false` | +| focusTrap | Enable focus trapping behavior | `boolean` | `false` | +| hasArrow | Render an arrow pointing to the anchor | `boolean` | `false` | +| id | HTML `id` attribute | `string` | | +| keepMounted | `Popup` 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 | `PopupOffset` | `4` | +| floatingContext | `Floating UI` context to provide interactions | `FloatingRootContext` | | +| floatingProps | Additional floating element props to provide interactions | `Record` | | +| onBlur | `blur` event handler | `Function` | | +| onClose | Handle `Popup` close event | `Function` | | +| onEnterKeyDown | `Enter` press event handler | `Function` | | +| onEscapeKeyDown | `Esc` press event handler | `Function` | | +| onFocus | `focus` event handler | `Function` | | +| onMouseEnter | `mouseenter` event handler | `Function` | | +| onMouseLeave | `mouseleave` event handler | `Function` | | +| onOutsideClick | Outside click event handler | `Function` | | +| onTransitionEnter | On start open popup animation | `Function` | | +| onTransitionEntered | On finish open popup animation | `Function` | | +| onTransitionExit | On start close popup animation | `Function` | | +| onTransitionExited | On finish close popup animation | `Function` | | +| open | Manages `Popup` visibility | `boolean` | `false` | +| placement | `Floating UI` placement | `Placement` `Array` `auto` `auto-start` `auto-end` | `top` | +| qa | Test attribute (`data-qa`) | `string` | | +| restoreFocus | If true, the focus will return to the anchor element | `boolean` | `false` | +| restoreFocusRef | Element the focus will be restored to | `React.RefObject` | | +| aria-labelledby | `aria-labelledby` attribute, prefer this attribute if you have visible caption | `string` | | +| aria-label | `aria-label` attribute, use this attribute only if you didn't have visible caption | `string` | | +| aria-modal | The `aria-modal` attribute indicates whether an element is modal when displayed. | `Booleanish` | value of `focusTrap` | +| role | The accessibility role for popup | `string` | `dialog` if `aria-modal` is true | +| strategy | `Floating UI` positioning strategy | `absolute` `fixed` | `absolute` | +| style | HTML `style` attribute for root node | `string` | | ## CSS API diff --git a/src/components/Popup/__stories__/Popup.stories.tsx b/src/components/Popup/__stories__/Popup.stories.tsx index 2824695128..1237f47313 100644 --- a/src/components/Popup/__stories__/Popup.stories.tsx +++ b/src/components/Popup/__stories__/Popup.stories.tsx @@ -6,7 +6,7 @@ import {useVirtualElementRef} from '../../../hooks'; import {Button} from '../../Button'; import {Text} from '../../Text'; import {Popup} from '../Popup'; -import type {PopupPlacement} from '../Popup'; +import type {PopupPlacement} from '../types'; const meta: Meta = { title: 'Components/Overlays/Popup', @@ -24,7 +24,12 @@ export const Default: Story = { return ( - setOpen(false)}> + setOpen(false)} + >
Popup content
{ + anchorElementRef.current = anchorElement ?? null; + }, [anchorElement]); + + if (anchorElement !== undefined) { + return {element: anchorElement, ref: anchorElementRef}; + } else if (anchorRef) { + return {element: anchorRef.current, ref: anchorRef}; + } + + return {element: undefined, ref: undefined}; +} diff --git a/src/components/Popup/index.ts b/src/components/Popup/index.ts index 95a491cb9d..d3e33e4b65 100644 --- a/src/components/Popup/index.ts +++ b/src/components/Popup/index.ts @@ -1 +1,2 @@ export * from './Popup'; +export * from './types'; diff --git a/src/components/Popup/types.ts b/src/components/Popup/types.ts new file mode 100644 index 0000000000..0e5c3337a4 --- /dev/null +++ b/src/components/Popup/types.ts @@ -0,0 +1,16 @@ +import type {OffsetOptions, Placement, VirtualElement} from '@floating-ui/react'; + +import type {AUTO_PLACEMENTS} from './constants'; + +export type AutoPlacement = (typeof AUTO_PLACEMENTS)[number]; + +export type PopupPlacement = AutoPlacement | Placement | Placement[]; + +export type PopupAnchorElement = Element | VirtualElement; + +export type PopupAnchorRef = React.RefObject; + +type RemoveFunction = T extends Function ? never : T; + +// floating-ui not exports `OffsetValue` type, so use this workarround +export type PopupOffset = RemoveFunction; diff --git a/src/components/Popup/utils.ts b/src/components/Popup/utils.ts new file mode 100644 index 0000000000..02e4d5ed9b --- /dev/null +++ b/src/components/Popup/utils.ts @@ -0,0 +1,19 @@ +import {ARROW_SIZE, AUTO_PLACEMENTS} from './constants'; +import type {AutoPlacement, PopupOffset} from './types'; + +export function getOffsetValue(offset: PopupOffset, hasArrow: boolean | undefined) { + let offsetValue = offset; + if (hasArrow) { + if (typeof offsetValue === 'number') { + offsetValue += ARROW_SIZE; + } else { + offsetValue = {...offsetValue, mainAxis: (offsetValue.mainAxis ?? 0) + ARROW_SIZE}; + } + } + + return offsetValue; +} + +export function isAutoPlacement(placement: string): placement is AutoPlacement { + return AUTO_PLACEMENTS.includes(placement as AutoPlacement); +} diff --git a/src/components/Select/README.md b/src/components/Select/README.md index cd5031273b..9d76ef85bf 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -1119,52 +1119,52 @@ LANDING_BLOCK--> ## Properties -| Name | Description | Type | Default | -| :-------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------- | :------------------------------------------------------- | -| className | Control className | `string` | | -| defaultValue | Default values that represent selected options in case of using uncontrolled state | `string[]` | | -| disabled | Indicates that the user cannot interact with the control | `boolean` | `false` | -| [filterable](#filtering-options) | Indicates that select popup have filter section | `boolean` | `false` | -| filterOption | Used to compare option with filter | `function` | | -| filterPlaceholder | Default filter input placeholder text | `string` | | -| [getOptionHeight](#render-options-with-different-heights) | Used to set height of customized user options | `function` | | -| getOptionGroupHeight | Used to set height of customized user option group | `function` | | -| hasClear | Enable displaying icon for clear selected options | `boolean` | `false` | -| id | HTML `id` attribute | `string` | | -| label | Control label | `string` | | -| loading | Add the loading item to the end of the options list. Works like persistant loading indicator while the options list is empty. | `boolean` | | -| [multiple](#selecting-multiple-options) | Indicates that multiple options can be selected in the list | `boolean` | `false` | -| name | Name of the control | `string` | | -| onBlur | Handler that is called when the element loses focus. | `function` | | -| filter | Controlled filter value | `string` | `''` | -| onFilterChange | Fires every time after changing filter | `function` | | -| onFocus | Handler that is called when the element receives focus. | `function` | | -| onLoadMore | Fires when loading indicator gets visible. | `function` | | -| onOpenChange | Fires every time after changing popup visibility | `function` | | -| onUpdate | Fires when an alteration to the Select value is committed by the user | `function` | | -| [options](#options) | Options to select | `(SelectOption \| SelectOptionGroup)[]` | | -| pin | Control border view | `string` | `'round-round'` | -| placeholder | Placeholder text | `string` | | -| popupClassName | Popup with options list className | `string` | | -| popupPlacement | `Popper.js` placement | `PopupPlacement` `Array` | `['bottom-start', 'bottom-end', 'top-start', 'top-end']` | -| [popupWidth](#popup-width) | Popup width | `number \| 'fit' \| 'outfit'` | `'outfit'` | -| qa | Test id attribute (`data-qa`) | `string` | | -| [renderControl](#render-custom-control) | Used to render user control | `function` | | -| renderEmptyOptions | Used to render node for an empty options list | `function` | | -| [renderFilter](#render-custom-filter-section) | Used to render user filter section | `function` | | -| [renderOption](#render-custom-options) | Used to render user options | `function` | | -| renderOptionGroup | Used to render user option groups | `function` | | -| [renderSelectedOption](#render-custom-selected-options) | Used to render user selected options | `function` | | -| [renderPopup](#render-custom-popup) | Used to render user popup content | `function` | | -| [size](#size) | Control / options size | `string` | `'m'` | -| value | Values that represent selected options | `string[]` | | -| view | Control view | `string` | `'normal'` | -| [virtualizationThreshold](#virtualized-list) | The threshold of the options count after which virtualization is enabled | `number` | `50` | -| [width](#control-width) | Control width | `string \| number` | `undefined` | -| errorMessage | Error text | `string` | | -| errorPlacement | Error placement | `outside` `inside` | `outside` | -| validationState | Validation state | `"invalid"` | | -| [hasCounter](#counter) | Indicates count of the selected options. Counter appears only when [multiple](#selecting-multiple-options) selection enabled. state | `boolean` | +| Name | Description | Type | Default | +| :-------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------- | :------------------------------------------------------- | +| className | Control className | `string` | | +| defaultValue | Default values that represent selected options in case of using uncontrolled state | `string[]` | | +| disabled | Indicates that the user cannot interact with the control | `boolean` | `false` | +| [filterable](#filtering-options) | Indicates that select popup have filter section | `boolean` | `false` | +| filterOption | Used to compare option with filter | `function` | | +| filterPlaceholder | Default filter input placeholder text | `string` | | +| [getOptionHeight](#render-options-with-different-heights) | Used to set height of customized user options | `function` | | +| getOptionGroupHeight | Used to set height of customized user option group | `function` | | +| hasClear | Enable displaying icon for clear selected options | `boolean` | `false` | +| id | HTML `id` attribute | `string` | | +| label | Control label | `string` | | +| loading | Add the loading item to the end of the options list. Works like persistant loading indicator while the options list is empty. | `boolean` | | +| [multiple](#selecting-multiple-options) | Indicates that multiple options can be selected in the list | `boolean` | `false` | +| name | Name of the control | `string` | | +| onBlur | Handler that is called when the element loses focus. | `function` | | +| filter | Controlled filter value | `string` | `''` | +| onFilterChange | Fires every time after changing filter | `function` | | +| onFocus | Handler that is called when the element receives focus. | `function` | | +| onLoadMore | Fires when loading indicator gets visible. | `function` | | +| onOpenChange | Fires every time after changing popup visibility | `function` | | +| onUpdate | Fires when an alteration to the Select value is committed by the user | `function` | | +| [options](#options) | Options to select | `(SelectOption \| SelectOptionGroup)[]` | | +| pin | Control border view | `string` | `'round-round'` | +| placeholder | Placeholder text | `string` | | +| popupClassName | Popup with options list className | `string` | | +| popupPlacement | Popup placement | `PopupPlacement` | `['bottom-start', 'bottom-end', 'top-start', 'top-end']` | +| [popupWidth](#popup-width) | Popup width | `number \| 'fit' \| 'outfit'` | `'outfit'` | +| qa | Test id attribute (`data-qa`) | `string` | | +| [renderControl](#render-custom-control) | Used to render user control | `function` | | +| renderEmptyOptions | Used to render node for an empty options list | `function` | | +| [renderFilter](#render-custom-filter-section) | Used to render user filter section | `function` | | +| [renderOption](#render-custom-options) | Used to render user options | `function` | | +| renderOptionGroup | Used to render user option groups | `function` | | +| [renderSelectedOption](#render-custom-selected-options) | Used to render user selected options | `function` | | +| [renderPopup](#render-custom-popup) | Used to render user popup content | `function` | | +| [size](#size) | Control / options size | `string` | `'m'` | +| value | Values that represent selected options | `string[]` | | +| view | Control view | `string` | `'normal'` | +| [virtualizationThreshold](#virtualized-list) | The threshold of the options count after which virtualization is enabled | `number` | `50` | +| [width](#control-width) | Control width | `string \| number` | `undefined` | +| errorMessage | Error text | `string` | | +| errorPlacement | Error placement | `outside` `inside` | `outside` | +| validationState | Validation state | `"invalid"` | | +| [hasCounter](#counter) | Indicates count of the selected options. Counter appears only when [multiple](#selecting-multiple-options) selection enabled. state | `boolean` | ## CSS API diff --git a/src/components/Select/components/SelectPopup/SelectPopup.tsx b/src/components/Select/components/SelectPopup/SelectPopup.tsx index f212de4e63..f3ef6c85d6 100644 --- a/src/components/Select/components/SelectPopup/SelectPopup.tsx +++ b/src/components/Select/components/SelectPopup/SelectPopup.tsx @@ -2,20 +2,20 @@ import React from 'react'; -import type {PopperPlacement} from '../../../../hooks/private'; import {Popup} from '../../../Popup'; +import type {PopupPlacement} from '../../../Popup'; import {Sheet} from '../../../Sheet'; import {block} from '../../../utils/cn'; -import {BORDER_WIDTH, SelectQa} from '../../constants'; +import {SelectQa} from '../../constants'; -import {getModifiers} from './modifiers'; +import {getMiddlewares} from './middlewares'; import type {SelectPopupProps} from './types'; import './SelectPopup.scss'; const b = block('select-popup'); -const DEFAULT_PLACEMENT: PopperPlacement = ['bottom-start', 'bottom-end', 'top-start', 'top-end']; +const DEFAULT_PLACEMENT: PopupPlacement = ['bottom-start', 'bottom-end', 'top-start', 'top-end']; export const SelectPopup = React.forwardRef( ( @@ -50,13 +50,12 @@ export const SelectPopup = React.forwardRef( qa={SelectQa.POPUP} anchorRef={ref as React.RefObject} placement={placement} - offset={[BORDER_WIDTH, BORDER_WIDTH]} open={open} onClose={handleClose} disablePortal={disablePortal} restoreFocus restoreFocusRef={controlRef} - modifiers={getModifiers({width, disablePortal, virtualized})} + middlewares={getMiddlewares({width, disablePortal, virtualized})} id={id} onTransitionExited={onAfterClose} > diff --git a/src/components/Select/components/SelectPopup/middlewares.ts b/src/components/Select/components/SelectPopup/middlewares.ts new file mode 100644 index 0000000000..2841bded68 --- /dev/null +++ b/src/components/Select/components/SelectPopup/middlewares.ts @@ -0,0 +1,87 @@ +import {flip, offset as floatingOffset, limitShift, shift, size} from '@floating-ui/react'; +import type {Middleware} from '@floating-ui/react'; + +import {BORDER_WIDTH, POPUP_MIN_WIDTH_IN_VIRTUALIZE_CASE} from '../../constants'; + +import type {SelectPopupProps} from './types'; + +const adjustBorderWidth = (width: number) => { + return width - BORDER_WIDTH * 2; +}; + +const getMinWidth = (referenceWidth: number, virtualized?: boolean) => { + if (virtualized) { + return referenceWidth > POPUP_MIN_WIDTH_IN_VIRTUALIZE_CASE + ? referenceWidth + : POPUP_MIN_WIDTH_IN_VIRTUALIZE_CASE; + } + + return adjustBorderWidth(referenceWidth); +}; + +const getPopupWidth = ( + width: SelectPopupProps['width'], + controlWidth: number, + virtualized?: boolean, +) => { + let popupWidth = controlWidth; + if (typeof width === 'number') { + popupWidth = width; + } else if (width === 'fit') { + popupWidth = adjustBorderWidth(controlWidth); + } else { + popupWidth = getMinWidth(controlWidth, virtualized); + } + + return `${popupWidth}px`; +}; + +export function sameWidthMiddleware( + args: Pick, +): Middleware { + const {width, virtualized} = args; + + return size({ + apply(state) { + const skip = + typeof width !== 'number' && Boolean(state.elements.floating.style.maxWidth); + + if (skip) { + return; + } + + const popupWidth = getPopupWidth(width, state.rects.reference.width, virtualized); + const floatingStyle: Record = {}; + + if (typeof width !== 'number' && width !== 'fit') { + floatingStyle.minWidth = popupWidth; + floatingStyle.width = undefined; + } else { + floatingStyle.minWidth = popupWidth; + floatingStyle.width = popupWidth; + } + + floatingStyle.maxWidth = `max(90vw, ${adjustBorderWidth( + state.rects.reference.width, + )}px)`; + + Object.assign(state.elements.floating.style, floatingStyle); + }, + }); +} + +export function getMiddlewares( + args: Pick, +): Middleware[] { + return [ + floatingOffset({mainAxis: BORDER_WIDTH, crossAxis: BORDER_WIDTH}), + flip({altBoundary: args.disablePortal}), + shift({ + limiter: limitShift(), + crossAxis: true, + padding: 10, + altBoundary: args.disablePortal, + }), + sameWidthMiddleware(args), + ]; +} diff --git a/src/components/Select/components/SelectPopup/modifiers.ts b/src/components/Select/components/SelectPopup/modifiers.ts deleted file mode 100644 index ad7a5b8017..0000000000 --- a/src/components/Select/components/SelectPopup/modifiers.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type {Modifier} from '@popperjs/core'; - -import {BORDER_WIDTH, POPUP_MIN_WIDTH_IN_VIRTUALIZE_CASE} from '../../constants'; - -import type {SelectPopupProps} from './types'; - -const adjustBorderWidth = (width: number) => { - return width - BORDER_WIDTH * 2; -}; - -const getMinWidth = (referenceWidth: number, virtualized?: boolean) => { - if (virtualized) { - return referenceWidth > POPUP_MIN_WIDTH_IN_VIRTUALIZE_CASE - ? referenceWidth - : POPUP_MIN_WIDTH_IN_VIRTUALIZE_CASE; - } - - return adjustBorderWidth(referenceWidth); -}; - -const getPopupWidth = ( - width: SelectPopupProps['width'], - controlWidth: number, - virtualized?: boolean, -) => { - let popupWidth = controlWidth; - if (typeof width === 'number') { - popupWidth = width; - } else if (width === 'fit') { - popupWidth = adjustBorderWidth(controlWidth); - } else { - popupWidth = getMinWidth(controlWidth, virtualized); - } - - return `${popupWidth}px`; -}; - -export const getModifiers = ( - args: Pick, -) => { - const {width, disablePortal, virtualized} = args; - - // set popper width styles according anchor rect - const sameWidth: Modifier<'sameWidth', {}> = { - name: 'sameWidth', - enabled: true, - phase: 'beforeWrite', - requires: ['computeStyles'], - fn: ({state, name}) => { - // prevents styles applying after popup being opened (in case of multiple selection) - if (state.modifiersData[`${name}#persistent`]?.skip) { - return; - } - - const popupWidth = getPopupWidth(width, state.rects.reference.width, virtualized); - if (typeof width !== 'number' && width !== 'fit') { - state.styles.popper.minWidth = popupWidth; - state.styles.popper.width = undefined; - } else { - state.styles.popper.minWidth = popupWidth; - state.styles.popper.width = popupWidth; - } - - state.styles.popper.maxWidth = `max(90vw, ${adjustBorderWidth( - state.rects.reference.width, - )}px)`; - - state.modifiersData[`${name}#persistent`] = { - skip: typeof width !== 'number', - }; - }, - effect: ({state, name}) => { - // All this code is workaround. Check https://popper.js.org/docs/v2/modifiers/community-modifiers/ - - // prevents styles applying after popup being opened (in case of multiple selection) - if (state.modifiersData[`${name}#persistent`]?.skip) { - return; - } - const popupWidth = getPopupWidth( - width, - (state.elements.reference as HTMLElement).offsetWidth, - virtualized, - ); - - if (typeof width !== 'number' && width !== 'fit') { - state.elements.popper.style.minWidth = popupWidth; - } else { - state.elements.popper.style.minWidth = popupWidth; - state.elements.popper.style.width = popupWidth; - } - - state.elements.popper.style.maxWidth = `max(90vw, ${ - (state.elements.reference as HTMLElement).offsetWidth - }px)`; - }, - }; - - // prevents the popper from being cut off by moving it so that it stays visible within its boundary area - const preventOverflow: Pick, 'name' | 'options'> = { - name: 'preventOverflow', - options: {padding: 10, altBoundary: disablePortal, altAxis: true}, - }; - - return [sameWidth, preventOverflow]; -}; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts index 7f88949748..4802688aa2 100644 --- a/src/components/Select/types.ts +++ b/src/components/Select/types.ts @@ -1,7 +1,7 @@ import type React from 'react'; -import type {PopperPlacement} from '../../hooks/private'; import type {UseOpenProps} from '../../hooks/useSelect/types'; +import type {PopupPlacement} from '../Popup'; import type {InputControlPin, InputControlSize, InputControlView} from '../controls'; import type {AriaLabelingProps, ControlGroupOption, QAProps} from '../types'; @@ -125,7 +125,7 @@ export type SelectProps = QAProps & className?: string; controlClassName?: string; popupClassName?: string; - popupPlacement?: PopperPlacement; + popupPlacement?: PopupPlacement; label?: string; placeholder?: React.ReactNode; filterPlaceholder?: string; diff --git a/src/components/Table/hoc/withTableActions/withTableActions.tsx b/src/components/Table/hoc/withTableActions/withTableActions.tsx index c00219c35c..947cbc0670 100644 --- a/src/components/Table/hoc/withTableActions/withTableActions.tsx +++ b/src/components/Table/hoc/withTableActions/withTableActions.tsx @@ -7,12 +7,12 @@ import _memoize from 'lodash/memoize'; import {useUniqId} from '../../../../hooks'; import {useBoolean} from '../../../../hooks/private'; -import type {PopperPlacement} from '../../../../hooks/private'; import {Button} from '../../../Button'; import {Icon} from '../../../Icon'; import {Menu} from '../../../Menu'; import type {MenuItemProps} from '../../../Menu'; import {Popup} from '../../../Popup'; +import type {PopupPlacement} from '../../../Popup'; import {block} from '../../../utils/cn'; import {getComponentName} from '../../../utils/getComponentName'; import type {TableColumnConfig, TableDataItem, TableProps} from '../../Table'; @@ -96,7 +96,7 @@ const bPopup = block('table-action-popup'); const menuCn = bPopup('menu'); const menuItemCn = bPopup('menu-item'); -const DEFAULT_PLACEMENT: PopperPlacement = ['bottom-end', 'top-end', 'auto']; +const DEFAULT_PLACEMENT: PopupPlacement = ['bottom-end', 'top-end']; type DefaultRowActionsProps = Pick< WithTableActionsProps, diff --git a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx index 840921b0a7..666b749f1c 100644 --- a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx +++ b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx @@ -12,10 +12,10 @@ import type { } from 'react-beautiful-dnd'; import {useUniqId} from '../../../../../hooks'; -import type {PopperPlacement} from '../../../../../hooks/private'; import {createOnKeyDownHandler} from '../../../../../hooks/useActionHandlers/useActionHandlers'; import {Button} from '../../../../Button'; import {Icon} from '../../../../Icon'; +import type {PopupPlacement} from '../../../../Popup'; import {Text} from '../../../../Text'; import {TreeSelect} from '../../../../TreeSelect/TreeSelect'; import type { @@ -276,7 +276,7 @@ export interface TableColumnSetupProps { onUpdate: (newSettings: TableSetting[]) => void; popupWidth?: TreeSelectProps['popupWidth']; - popupPlacement?: PopperPlacement; + popupPlacement?: PopupPlacement; /** * @deprecated diff --git a/src/components/Table/hoc/withTableSettings/withTableSettings.tsx b/src/components/Table/hoc/withTableSettings/withTableSettings.tsx index a9d1005a60..83164ca642 100644 --- a/src/components/Table/hoc/withTableSettings/withTableSettings.tsx +++ b/src/components/Table/hoc/withTableSettings/withTableSettings.tsx @@ -8,9 +8,9 @@ import _isEqual from 'lodash/isEqual'; import _isString from 'lodash/isString'; import _last from 'lodash/last'; -import type {PopperPlacement} from '../../../../hooks/private'; import {Button} from '../../../Button'; import {Icon} from '../../../Icon'; +import type {PopupPlacement} from '../../../Popup'; import type {TreeSelectProps} from '../../../TreeSelect'; import {block} from '../../../utils/cn'; import {getComponentName} from '../../../utils/getComponentName'; @@ -158,7 +158,7 @@ export type WithTableSettingsProps = WithTableSettingsBaseProps & const b = block('table'); -const POPUP_PLACEMENT: PopperPlacement = ['bottom-end', 'bottom', 'top-end', 'top', 'auto']; +const POPUP_PLACEMENT: PopupPlacement = ['bottom-end', 'bottom', 'top-end', 'top']; export function withTableSettings( Component: React.ComponentType & E>, diff --git a/src/components/TableColumnSetup/TableColumnSetup.tsx b/src/components/TableColumnSetup/TableColumnSetup.tsx index f233343651..42ea92c8c4 100644 --- a/src/components/TableColumnSetup/TableColumnSetup.tsx +++ b/src/components/TableColumnSetup/TableColumnSetup.tsx @@ -4,9 +4,9 @@ import React from 'react'; import {Gear} from '@gravity-ui/icons'; -import type {PopperPlacement} from '../../hooks/private'; import {Button} from '../Button'; import {Icon} from '../Icon'; +import type {PopupPlacement} from '../Popup'; import type {TableColumnConfig} from '../Table/Table'; import type {TableColumnSetupItem as NewTableColumnSetupItem} from '../Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup'; import {TableColumnSetup as NewTableColumnSetup} from '../Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup'; @@ -48,7 +48,7 @@ export interface TableColumnSetupProps { onUpdate: (updated: Item[]) => void; popupWidth?: number | 'fit' | undefined; - popupPlacement?: PopperPlacement; + popupPlacement?: PopupPlacement; getItemTitle?: (item: Item) => TableColumnSetupItem['title']; showStatus?: boolean; className?: string; diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index d9390f8a88..695079319c 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -59,7 +59,7 @@ export const Tooltip = (props: TooltipProps) => { style={style} open={tooltipVisible && !disabled} placement={placement} - anchorRef={{current: anchorElement}} + anchorElement={anchorElement} disablePortal={disablePortal} disableEscapeKeyDown disableOutsideClick diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index 6038b64ae9..0e3b716dd7 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -1,7 +1,7 @@ import type React from 'react'; -import type {PopperPlacement} from '../../hooks/private'; import type {UseOpenProps} from '../../hooks/useSelect/types'; +import type {PopupPlacement} from '../Popup'; import type {SelectPopupProps} from '../Select/components/SelectPopup/types'; import type { TreeListContainerProps, @@ -68,7 +68,7 @@ export interface TreeSelectProps defaultValue?: ListItemId[] | undefined; popupClassName?: string; popupWidth?: SelectPopupProps['width']; - placement?: PopperPlacement; + placement?: PopupPlacement; width?: 'auto' | 'max' | number; containerClassName?: string; popupDisablePortal?: boolean; diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx index a96422f85c..8f2f6f17e8 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -68,7 +68,7 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro style={{width: COMPONENT_WIDTH, height: '80vh', overflow: 'auto', borderRadius: 6}} anchorRef={controlWrapRef as React.RefObject} placement={['bottom-start', 'bottom-end', 'top-start', 'top-end']} - offset={[0, 10]} + offset={10} open={open} onClose={() => setOpen(false)} disablePortal diff --git a/src/components/utils/layer-manager/LayerManager.ts b/src/components/utils/layer-manager/LayerManager.ts index 6aa483855f..96594bbf2a 100644 --- a/src/components/utils/layer-manager/LayerManager.ts +++ b/src/components/utils/layer-manager/LayerManager.ts @@ -1,6 +1,6 @@ 'use client'; -import type {VirtualElement} from '@popperjs/core'; +import type {VirtualElement} from '@floating-ui/react'; import {KeyCode} from '../../../constants'; import {eventBroker} from '../event-broker'; diff --git a/src/hooks/private/index.ts b/src/hooks/private/index.ts index 9102dcd2f8..8a6e72f1e7 100644 --- a/src/hooks/private/index.ts +++ b/src/hooks/private/index.ts @@ -4,7 +4,6 @@ export * from './useCloseOnTimeout'; export * from './useConditionallyControlledState'; export * from './useElementSize'; export * from './useHover'; -export * from './usePopper'; export * from './useRadio'; export * from './useRadioGroup'; export * from './useRestoreFocus'; diff --git a/src/hooks/private/usePopper/README.md b/src/hooks/private/usePopper/README.md deleted file mode 100644 index 57ef0a4ff1..0000000000 --- a/src/hooks/private/usePopper/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# usePopper - -The `usePopper` hook wrap usePopper props and add few another helpful props - -## Properties - -| Name | Description | Type | Default | -| :---------- | :---------------------------------------------- | :-------------------------------------: | :-----: | -| anchorRef | Ref-link for anchor element | `React.RefObject` | | -| placement | Popper placement | `popper.Placement - popper.Placement[]` | | -| offset | Offset modifier | `[number, number]` | | -| modifiers | Popper modifiers | `Modifier[]` | | -| strategy | Popper position strategy | `popper.PositioningStrategy` | | -| altBoundary | Flag for check the reference's boundary context | `boolean` | | - -## Result - -- attributes -- styles -- setPopperRef -- setArrowRef diff --git a/src/hooks/private/usePopper/index.ts b/src/hooks/private/usePopper/index.ts deleted file mode 100644 index 675724cf45..0000000000 --- a/src/hooks/private/usePopper/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export {usePopper} from './usePopper'; -export type { - UsePopperProps, - PopperProps, - UsePopperResult, - PopperPlacement, - PopperOffset, - PopperModifiers, - PopperAnchorRef, -} from './usePopper'; diff --git a/src/hooks/private/usePopper/usePopper.ts b/src/hooks/private/usePopper/usePopper.ts deleted file mode 100644 index 4f3f1ad100..0000000000 --- a/src/hooks/private/usePopper/usePopper.ts +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; - -import type popper from '@popperjs/core'; -import {usePopper as useReactPopper} from 'react-popper'; -import type {Modifier} from 'react-popper'; - -import {useDirection} from '../../../components/theme'; - -export type PopperPlacement = popper.Placement | popper.Placement[]; -export type PopperOffset = [number, number]; -export type PopperModifiers = Modifier>[]; -export type PopperAnchorRef = React.RefObject; - -export interface PopperProps { - anchorRef?: PopperAnchorRef; - placement?: PopperPlacement; - offset?: [number, number]; - modifiers?: PopperModifiers; - strategy?: popper.PositioningStrategy; - altBoundary?: boolean; -} - -export interface UsePopperProps extends PopperProps {} - -export interface UsePopperResult { - attributes: { - [key: string]: - | { - [key: string]: string; - } - | undefined; - }; - styles: { - [key: string]: React.CSSProperties; - }; - setPopperRef: React.Dispatch>; - setArrowRef: React.Dispatch>; -} - -const DEFAULT_PLACEMENT: PopperPlacement = [ - 'bottom-start', - 'bottom', - 'bottom-end', - 'top-start', - 'top', - 'top-end', - 'right-start', - 'right', - 'right-end', - 'left-start', - 'left', - 'left-end', -]; - -const rtlOffsetFix: popper.Modifier<'rtlOffsetFix', {}> = { - name: 'rtlOffsetFix', - enabled: true, - phase: 'main', - requires: ['offset'], - fn({state}) { - if (!state.placement.startsWith('top') && !state.placement.startsWith('bottom')) { - return; - } - - const offsets = state.modifiersData.offset?.[state.placement]; - - if (!offsets) { - return; - } - - state.modifiersData.popperOffsets!.x -= offsets.x * 2; - }, -}; - -export function usePopper({ - anchorRef, - placement = DEFAULT_PLACEMENT, - offset, - modifiers = [], - strategy, - altBoundary, -}: UsePopperProps): UsePopperResult { - const [popperElement, setPopperElement] = React.useState(null); - const [arrowElement, setArrowElement] = React.useState(null); - const direction = useDirection(); - - const placements = React.useMemo(() => { - let items = Array.isArray(placement) ? placement : [placement]; - - if (direction === 'rtl') { - items = items.map( - (p) => - p.replace(/(top|bottom)-(start|end)/g, (match, position, value) => { - if (value === 'start') { - return position + '-end'; - } - if (value === 'end') { - return position + '-start'; - } - return match; - }) as popper.Placement, - ); - } - - return items; - }, [placement, direction]); - - const {attributes, styles} = useReactPopper(anchorRef?.current, popperElement, { - strategy, - modifiers: [ - {name: 'arrow', options: {element: arrowElement}}, - {name: 'offset', options: {offset, altBoundary}}, - {name: 'flip', options: {fallbackPlacements: placements.slice(1), altBoundary}}, - ...(direction === 'rtl' ? [rtlOffsetFix] : []), - ...modifiers, - ], - placement: placements[0], - }); - - return { - attributes, - styles, - setPopperRef: setPopperElement, - setArrowRef: setArrowElement, - }; -} diff --git a/src/hooks/useVirtualElementRef/useVirtualElementRef.ts b/src/hooks/useVirtualElementRef/useVirtualElementRef.ts index d45af8bdfd..d826817a89 100644 --- a/src/hooks/useVirtualElementRef/useVirtualElementRef.ts +++ b/src/hooks/useVirtualElementRef/useVirtualElementRef.ts @@ -1,6 +1,6 @@ import React from 'react'; -import type {VirtualElement} from '@popperjs/core'; +import type {VirtualElement} from '@floating-ui/react'; export type VirtualElementRect = { top?: number;