From 8f218d821560685c4f5481b75970928e40e7195f Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Fri, 27 Dec 2024 18:22:59 +0300 Subject: [PATCH] feat!: refactor components to use Floating UI API (#1979) Co-authored-by: oynikishin <113184616+oynikishin@users.noreply.github.com> Co-authored-by: oynikishin --- package-lock.json | 22 +- package.json | 3 +- .../ActionTooltip/ActionTooltip.scss | 16 +- .../ActionTooltip/ActionTooltip.tsx | 112 ++--- src/components/ActionTooltip/README.md | 57 ++- .../__stories__/ActionTooltip.stories.tsx | 62 ++- .../__tests__/ActionTooltip.test.tsx | 72 +-- src/components/Dialog/Dialog.tsx | 220 +++++---- .../Dialog/DialogFooter/DialogFooter.tsx | 228 ++++----- src/components/Dialog/DialogPrivateContext.ts | 9 + .../Dialog/__stories__/DialogShowcase.tsx | 4 +- .../DropdownMenu/DropdownMenuPopup.tsx | 15 +- .../FilePreview/FilePreviewAction.tsx | 4 +- src/components/Modal/Modal.scss | 78 +--- src/components/Modal/Modal.tsx | 344 +++++++++----- .../Modal/__stories__/Modal.stories.tsx | 118 ++++- src/components/Modal/i18n/en.json | 4 + src/components/Modal/i18n/index.ts | 8 + src/components/Modal/i18n/ru.json | 4 + src/components/Popover/Popover.scss | 12 +- src/components/Popover/Popover.tsx | 9 +- .../Popover/__stories__/Popover.stories.tsx | 1 - .../Popover/__tests__/Popover.test.tsx | 44 +- src/components/Popover/types.ts | 4 +- src/components/Popup/Popup.scss | 223 +++------ src/components/Popup/Popup.tsx | 441 ++++++++++-------- src/components/Popup/PopupArrow.tsx | 15 +- src/components/Popup/__tests__/Popup.test.tsx | 22 +- src/components/Popup/constants.ts | 3 +- src/components/Popup/i18n/en.json | 3 + src/components/Popup/i18n/index.ts | 8 + src/components/Popup/i18n/ru.json | 3 + src/components/Popup/utils.ts | 73 ++- src/components/Portal/Portal.tsx | 29 +- src/components/Select/Select.tsx | 15 +- .../Select/__stories__/Select.stories.tsx | 16 +- .../components/SelectPopup/SelectPopup.tsx | 13 +- .../Select/components/SelectPopup/types.ts | 1 + src/components/Sheet/Sheet.tsx | 14 +- .../DefaultShowcase.stories.tsx | 10 + .../ToasterComponent/ToasterComponent.tsx | 14 +- .../ToasterComponent/ToasterPortal.tsx | 45 -- src/components/Tooltip/README.md | 71 ++- src/components/Tooltip/Tooltip.scss | 46 +- src/components/Tooltip/Tooltip.tsx | 207 +++++--- .../Tooltip/__stories__/Tooltip.stories.tsx | 52 ++- .../Tooltip/__tests__/Tooltip.test.tsx | 66 +-- .../__stories__/TreeSelect.stories.tsx | 5 + src/components/lab/Popover/Popover.tsx | 78 ++-- .../Popover/__stories__/Popover.stories.tsx | 34 +- .../components/PopupWithTogglerList.tsx | 3 +- src/components/utils/FocusTrap.tsx | 142 ------ src/demo/colors/ColorPanel.tsx | 5 +- src/hooks/index.ts | 1 - src/hooks/private/index.ts | 6 +- src/hooks/private/usePrevious/README.md | 13 + src/hooks/private/usePrevious/index.ts | 1 + src/hooks/private/usePrevious/usePrevious.ts | 11 + src/hooks/private/useRestoreFocus/README.md | 17 - src/hooks/private/useRestoreFocus/index.ts | 2 - .../useRestoreFocus/useRestoreFocus.tsx | 100 ---- src/hooks/useBodyScrollLock/README.md | 17 - src/hooks/useBodyScrollLock/index.ts | 2 - .../useBodyScrollLock/useBodyScrollLock.ts | 106 ----- 64 files changed, 1674 insertions(+), 1709 deletions(-) create mode 100644 src/components/Dialog/DialogPrivateContext.ts create mode 100644 src/components/Modal/i18n/en.json create mode 100644 src/components/Modal/i18n/index.ts create mode 100644 src/components/Modal/i18n/ru.json create mode 100644 src/components/Popup/i18n/en.json create mode 100644 src/components/Popup/i18n/index.ts create mode 100644 src/components/Popup/i18n/ru.json delete mode 100644 src/components/Toaster/ToasterComponent/ToasterPortal.tsx delete mode 100644 src/components/utils/FocusTrap.tsx create mode 100644 src/hooks/private/usePrevious/README.md create mode 100644 src/hooks/private/usePrevious/index.ts create mode 100644 src/hooks/private/usePrevious/usePrevious.ts delete mode 100644 src/hooks/private/useRestoreFocus/README.md delete mode 100644 src/hooks/private/useRestoreFocus/index.ts delete mode 100644 src/hooks/private/useRestoreFocus/useRestoreFocus.tsx delete mode 100644 src/hooks/useBodyScrollLock/README.md delete mode 100644 src/hooks/useBodyScrollLock/index.ts delete mode 100644 src/hooks/useBodyScrollLock/useBodyScrollLock.ts diff --git a/package-lock.json b/package-lock.json index cd0abed50b..198edd76b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,11 @@ "license": "MIT", "dependencies": { "@bem-react/classname": "^1.6.0", - "@floating-ui/react": "^0.26.28", + "@floating-ui/react": "^0.27.0", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.11.0", "@tanstack/react-virtual": "^3.10.8", "blueimp-md5": "^2.19.0", - "focus-trap": "^7.6.2", "lodash": "^4.17.21", "rc-slider": "^11.1.7", "react-beautiful-dnd": "^13.1.1", @@ -3177,17 +3176,18 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.26.28", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", - "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.0.tgz", + "integrity": "sha512-WLEksq7fJapXSJbmfiyq9pAW0a7ZFMEJToFE4oTDESxGjoa+nZu3YMjmZE2KvoUtQhqOK2yMMfWQFZyeWD0wGQ==", + "license": "MIT", "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" + "react": ">=17.0.0", + "react-dom": ">=17.0.0" } }, "node_modules/@floating-ui/react-dom": { @@ -12318,14 +12318,6 @@ "readable-stream": "^2.3.6" } }, - "node_modules/focus-trap": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", - "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", - "dependencies": { - "tabbable": "^6.2.0" - } - }, "node_modules/follow-redirects": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", diff --git a/package.json b/package.json index 9cc07c65d1..727bcd454c 100644 --- a/package.json +++ b/package.json @@ -135,12 +135,11 @@ }, "dependencies": { "@bem-react/classname": "^1.6.0", - "@floating-ui/react": "^0.26.28", + "@floating-ui/react": "^0.27.0", "@gravity-ui/i18n": "^1.7.0", "@gravity-ui/icons": "^2.11.0", "@tanstack/react-virtual": "^3.10.8", "blueimp-md5": "^2.19.0", - "focus-trap": "^7.6.2", "lodash": "^4.17.21", "rc-slider": "^11.1.7", "react-beautiful-dnd": "^13.1.1", diff --git a/src/components/ActionTooltip/ActionTooltip.scss b/src/components/ActionTooltip/ActionTooltip.scss index efa726b514..81b36b2432 100644 --- a/src/components/ActionTooltip/ActionTooltip.scss +++ b/src/components/ActionTooltip/ActionTooltip.scss @@ -4,15 +4,9 @@ $block: '.#{variables.$ns}action-tooltip'; #{$block} { - --g-popup-border-width: 0; - --g-popup-background-color: var(--g-color-base-float-heavy); - - &__content { - padding: 6px 12px; - color: var(--g-color-text-light-primary); - max-width: 300px; - box-sizing: border-box; - } + --g-tooltip-text-color: var(--g-color-text-light-primary); + --g-tooltip-background-color: var(--g-color-base-float-heavy); + --g-tooltip-padding: var(--g-spacing-2) var(--g-spacing-3); &__heading { display: flex; @@ -25,11 +19,11 @@ $block: '.#{variables.$ns}action-tooltip'; } &__hotkey { - margin-inline-start: 8px; + margin-inline-start: var(--g-spacing-2); } &__description { - margin-block-start: 4px; + margin-block-start: var(--g-spacing-1); color: var(--g-color-text-light-secondary); } } diff --git a/src/components/ActionTooltip/ActionTooltip.tsx b/src/components/ActionTooltip/ActionTooltip.tsx index 6b04dfb9ad..8a4a9f5a1c 100644 --- a/src/components/ActionTooltip/ActionTooltip.tsx +++ b/src/components/ActionTooltip/ActionTooltip.tsx @@ -2,90 +2,62 @@ import * as React from 'react'; -import {useForkRef} from '../../hooks'; -import {useTooltipVisible} from '../../hooks/private'; -import type {TooltipDelayProps} from '../../hooks/private'; import {Hotkey} from '../Hotkey'; import type {HotkeyProps} from '../Hotkey'; -import {Popup} from '../Popup'; -import type {PopupPlacement} from '../Popup'; +import {Tooltip} from '../Tooltip'; +import type {TooltipProps} from '../Tooltip'; import type {DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; -import {getElementRef} from '../utils/getElementRef'; import './ActionTooltip.scss'; -export interface ActionTooltipProps extends QAProps, DOMProps, TooltipDelayProps { - id?: string; - disablePortal?: boolean; - contentClassName?: string; - disabled?: boolean; - placement?: PopupPlacement; - children: React.ReactElement; +export interface ActionTooltipProps + extends QAProps, + DOMProps, + Omit { + /** Floating element title */ title: string; - hotkey?: HotkeyProps['value']; + /** Floating element description */ description?: React.ReactNode; + /** Floating element hotkey label */ + hotkey?: HotkeyProps['value']; } -const DEFAULT_PLACEMENT: PopupPlacement = ['bottom', 'top']; const b = block('action-tooltip'); - -export function ActionTooltip(props: ActionTooltipProps) { - const { - placement = DEFAULT_PLACEMENT, - title, - hotkey, - children, - className, - contentClassName, - description, - disabled = false, - style, - qa, - id, - disablePortal, - ...delayProps - } = props; - - const [anchorElement, setAnchorElement] = React.useState(null); - const tooltipVisible = useTooltipVisible(anchorElement, delayProps); - - const renderPopup = () => { - return ( - -
-
-
{title}
- {hotkey && } -
- {description &&
{description}
} +const DEFAULT_OPEN_DELAY = 500; +const DEFAULT_CLOSE_DELAY = 0; + +export function ActionTooltip({ + title, + description, + hotkey, + openDelay = DEFAULT_OPEN_DELAY, + closeDelay = DEFAULT_CLOSE_DELAY, + className, + ...restProps +}: ActionTooltipProps) { + const content = React.useMemo( + () => ( + +
+
{title}
+ {hotkey && }
- - ); - }; - - const child = React.Children.only(children); - const childRef = getElementRef(child); - - const ref = useForkRef(setAnchorElement, childRef); + {description &&
{description}
} +
+ ), + [title, description, hotkey], + ); return ( - - {React.cloneElement(child, {ref})} - {anchorElement ? renderPopup() : null} - + ); } diff --git a/src/components/ActionTooltip/README.md b/src/components/ActionTooltip/README.md index d5a1dd8c22..afefa8e119 100644 --- a/src/components/ActionTooltip/README.md +++ b/src/components/ActionTooltip/README.md @@ -4,31 +4,56 @@ -This is a simple text tip that uses its child node as an anchor. To work correctly, the anchor node must be able to handle mouse events and focus or blur events. +[`Tooltip`](../Tooltip/README.md) for labeling action buttons without descriptive text (e.g. icon buttons). ## Usage ```tsx import {ActionTooltip} from '@gravity-ui/uikit'; - +
Anchor
; ``` +## Anchor + +In order for `ActionTooltip` to work you should pass a valid `ReactElement` as a children which accepts `ref` property for `HTMLElement` +and other properties for `HTMLElement`. + +Alternatively, you can pass function as a children to provide ref and props manually to your underlying components: + +```tsx +import {ActionTooltip} from '@gravity-ui/uikit'; + + + {(props, ref) => } +; +``` + +## Controlled State + +By default `ActionTooltip` opens and hides by hovering the anchor. You can change this behaviour to manually set the open state. +Pass your state to the `open` prop and change it from `onOpenChange` callback. +`onOpenChange` callback has the following signature: `(open: boolean, event?: Event, reason: 'hover' | 'focus') => void`. + ## Properties -| Name | Description | Type | Default | -| :--------------- | --------------------------------------------------------------------------------------- | :----------------------------------------------: | :-----: | -| children | Anchor element for a `Tooltip`. It must accept a `ref` that will provide a DOM element. | `React.ReactElement` | | -| closeDelay | Number of ms to delay hiding the `Tooltip` after the hover ends | `number` | `0` | -| openDelay | Number of ms to delay showing the `Tooltip` once the hover starts | `number` | `250` | -| placement | `Tooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | | -| qa | `data-qa` HTML attribute, used for testing | `string` | | -| title | Tooltip title text | `string` | | -| description | Tooltip description text | `string` | | -| hotkey | Hotkeys assigned to an interface action | `string` | | -| id | Used for implementing the accessibility logic | `string` | | -| disablePortal | Disables using Portal for children | `boolean` | | -| contentClassName | HTML class attribute for the content node | `string` | | -| disabled | Prevents the popup from opening | `boolean` | `false` | +| Name | Description | Type | Default | +| :----------- | --------------------------------------------------------------------------- | :----------------------------------------------: | :--------: | +| children | Anchor element for the `ActionTooltip` | `React.ReactElement` `Function` | | +| className | `class` HTML attribute | `string` | | +| closeDelay | Number of ms to delay hiding the `ActionTooltip` after the hover ends | `number` | `0` | +| description | Description content | `React.ReactNode` | | +| disabled | Prevent the `ActionTooltip` from opening | `boolean` | | +| hotkey | `Hotkey` value to be shown in the top-end corner | [`Hotkey` value](../Hotkey/README.md#value) | | +| offset | `ActionTooltip` offset from its anchor | `number` | `4` | +| onOpenChange | Callback to handle open state change | `Function` | | +| open | Controlled open state | `boolean` | | +| openDelay | Number of ms to delay showing the `ActionTooltip` after the hover begins | `number` | `1000` | +| placement | `ActionTooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | `bottom` | +| qa | `data-qa` HTML attribute, used for testing | `string` | | +| strategy | The type of CSS position property to use. | `absolute` `fixed` | `absolute` | +| style | `style` HTML attribute | `React.CSSProperties` | | +| title | Title content | `string` | | +| trigger | Event type that should trigger opening. By default both hover and focus do. | `"focus"` | | diff --git a/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx b/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx index 49a9232e4f..8c4911ab85 100644 --- a/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx +++ b/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx @@ -1,22 +1,62 @@ -import type {StoryFn} from '@storybook/react'; +import {FloppyDisk} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; import {Button} from '../../Button'; +import {Icon} from '../../Icon'; import {ActionTooltip} from '../ActionTooltip'; -import type {ActionTooltipProps} from '../ActionTooltip'; -export default { +const meta: Meta = { title: 'Components/Overlays/ActionTooltip', component: ActionTooltip, + parameters: { + layout: 'centered', + a11y: { + element: '#storybook-root', + config: { + rules: [ + { + id: 'button-name', + // We set aria-attributes dynamically + enabled: false, + }, + ], + }, + }, + }, }; -const DefaultTemplate: StoryFn = (args) => ; +export default meta; -export const Default = DefaultTemplate.bind({}); +type Story = StoryObj; -Default.args = { - title: 'Tooltip text', - hotkey: 'mod+s', - description: - 'Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for previewing layouts and visual mockups.', - children: , +export const Default: Story = { + render: (args) => { + return ( + + + + ); + }, + args: { + title: 'Save', + }, +}; + +export const Hotkey: Story = { + ...Default, + args: { + ...Default.args, + hotkey: 'mod+s', + }, +}; + +export const Description: Story = { + ...Default, + args: { + ...Default.args, + description: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua', + }, }; diff --git a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx index b9b5a23b25..2be8d74af4 100644 --- a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx +++ b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx @@ -1,21 +1,12 @@ import userEvent from '@testing-library/user-event'; -import {createEvent, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {render, screen, waitFor} from '../../../../test-utils/utils'; import {ActionTooltip} from '../ActionTooltip'; -export function fireAnimationEndEvent(el: Node | Window, animationName = 'animation') { - const ev = createEvent.animationEnd(el, {animationName}); - Object.assign(ev, { - animationName, - }); - - fireEvent(el, ev); -} - test('should preserve ref on anchor element', () => { const ref = jest.fn(); render( - + -
- ); - - const buttonApply = ( -
- +
+ ); + + const handleOpenChange = React.useCallback>( + (isOpen, event, reason) => { + if (!isOpen && event && reason === 'escape-key') { + onTooltipEscapeKeyDown?.(event as KeyboardEvent); + } + }, + [onTooltipEscapeKeyDown], + ); + + const buttonApply = ( +
+ + {errorText && ( + - {textButtonApply} - - {errorText && ( - -
{errorText}
-
+
{errorText}
+
+ )} +
+ ); + + return ( +
+
{children}
+
+ {renderButtons ? ( + renderButtons(buttonApply, buttonCancel) + ) : ( + + {textButtonCancel && buttonCancel} + {textButtonApply && buttonApply} + )}
- ); - - return ( -
-
{children}
-
- {renderButtons ? ( - renderButtons(buttonApply, buttonCancel) - ) : ( - - {textButtonCancel && buttonCancel} - {textButtonApply && buttonApply} - - )} -
-
- ); - } - - private attachKeyDownListeners() { - setTimeout(() => { - window.addEventListener('keydown', this.handleKeyDown); - }, 0); - } - - private detachKeyDownListeners() { - window.removeEventListener('keydown', this.handleKeyDown); - } - - private handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - if (this.props.onClickButtonApply) { - this.props.onClickButtonApply(event); - } - } - }; +
+ ); } diff --git a/src/components/Dialog/DialogPrivateContext.ts b/src/components/Dialog/DialogPrivateContext.ts new file mode 100644 index 0000000000..4dbbd60336 --- /dev/null +++ b/src/components/Dialog/DialogPrivateContext.ts @@ -0,0 +1,9 @@ +import React from 'react'; + +export interface DialogPrivateContextProps { + initialFocusRef?: React.RefObject; + initialFocusAction?: 'apply' | 'cancel'; + onTooltipEscapeKeyDown?: (event: KeyboardEvent) => void; +} + +export const DialogPrivateContext = React.createContext({}); diff --git a/src/components/Dialog/__stories__/DialogShowcase.tsx b/src/components/Dialog/__stories__/DialogShowcase.tsx index 9113337c40..87d016c336 100644 --- a/src/components/Dialog/__stories__/DialogShowcase.tsx +++ b/src/components/Dialog/__stories__/DialogShowcase.tsx @@ -68,7 +68,7 @@ function OtherDialog() { keepMounted onEnterKeyDown={handleApply} qa="darthVader" - onTransitionEntered={() => { + onTransitionInComplete={() => { selectRef?.current?.focus(); setTimeout(() => setOpenSelect(true), 0); }} @@ -165,6 +165,7 @@ export function DialogShowcase() { className="my-custom-class-for-dialog" hasCloseButton onEnterKeyDown={handleApply} + initialFocus="apply" > ({ const handleMouseEnter = React.useCallback( (event: React.MouseEvent) => { setActiveMenuPath(path); - popupProps?.onMouseEnter?.(event); + (popupProps?.floatingProps?.onMouseEnter as React.MouseEventHandler | undefined)?.( + event, + ); }, [path, popupProps, setActiveMenuPath], ); @@ -65,7 +67,9 @@ export const DropdownMenuPopup = ({ const handleMouseLeave = React.useCallback( (event: React.MouseEvent) => { activateParent(); - popupProps?.onMouseLeave?.(event); + (popupProps?.floatingProps?.onMouseLeave as React.MouseEventHandler | undefined)?.( + event, + ); }, [activateParent, popupProps], ); @@ -144,8 +148,11 @@ export const DropdownMenuPopup = ({ onClose={onClose} placement="bottom-start" {...popupProps} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} + floatingProps={{ + ...popupProps?.floatingProps, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }} > {children || ( diff --git a/src/components/FilePreview/FilePreviewAction.tsx b/src/components/FilePreview/FilePreviewAction.tsx index d41bc16429..758a894792 100644 --- a/src/components/FilePreview/FilePreviewAction.tsx +++ b/src/components/FilePreview/FilePreviewAction.tsx @@ -13,7 +13,7 @@ export interface FilePreviewActionProps { disabled?: boolean; onClick?: React.MouseEventHandler; extraProps?: ButtonButtonProps | ButtonLinkProps; - tooltipExtraProps?: Omit; + tooltipExtraProps?: Omit; } export function FilePreviewAction({ @@ -27,7 +27,7 @@ export function FilePreviewAction({ tooltipExtraProps, }: FilePreviewActionProps) { return ( - + + +
+ +
+
+ +
- +
); }; + +function ModalWithPopups(props: ModalProps) { + const [topPopupOpen, setTopPopupOpen] = React.useState(false); + const [bottomPopupOpen, setBottomPopupOpen] = React.useState(false); + + const handleTogglePopups = React.useCallback(() => { + setTopPopupOpen(!topPopupOpen); + setBottomPopupOpen(!bottomPopupOpen); + }, [topPopupOpen, bottomPopupOpen]); + + const [popupAnchor, setPopupAnchor] = React.useState(null); + + React.useEffect(() => { + if (!props.open) { + setTopPopupOpen(false); + setBottomPopupOpen(false); + } + }, [props.open]); + + return ( + +
+ + +
Top popup
+
+ +
Bottom popup
+
+
+
+ ); +} + +function ModalWithModal(props: ModalProps) { + const [innerModalOpen, setInnerModalOpen] = React.useState(false); + + return ( + +
+ + +
Modal content
+
+
+
+ ); +} diff --git a/src/components/Modal/i18n/en.json b/src/components/Modal/i18n/en.json new file mode 100644 index 0000000000..047b02a79a --- /dev/null +++ b/src/components/Modal/i18n/en.json @@ -0,0 +1,4 @@ +{ + "close": "Close" +} + \ No newline at end of file diff --git a/src/components/Modal/i18n/index.ts b/src/components/Modal/i18n/index.ts new file mode 100644 index 0000000000..0aada620fc --- /dev/null +++ b/src/components/Modal/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'Modal'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/Modal/i18n/ru.json b/src/components/Modal/i18n/ru.json new file mode 100644 index 0000000000..2da2d1cad5 --- /dev/null +++ b/src/components/Modal/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "close": "Закрыть" +} + \ No newline at end of file diff --git a/src/components/Popover/Popover.scss b/src/components/Popover/Popover.scss index 7b0a9e1550..415f763b68 100644 --- a/src/components/Popover/Popover.scss +++ b/src/components/Popover/Popover.scss @@ -24,13 +24,11 @@ $block: '.#{variables.$ns}popover'; --_--close-offset: 8px; --_--close-size: 24px; - &-popup-content { - box-sizing: border-box; - min-height: 40px; - max-width: var(--g-popover-max-width, 300px); - padding: var(--g-popover-padding, var(--_--padding)); - cursor: default; - } + box-sizing: border-box; + min-height: 40px; + max-width: var(--g-popover-max-width, 300px); + padding: var(--g-popover-padding, var(--_--padding)); + cursor: default; &-title { @include mixins.text-subheader-3(); diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index cc7196f710..36410501d6 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -37,7 +37,6 @@ export const Popover = React.forwardRef diff --git a/src/components/Popover/__stories__/Popover.stories.tsx b/src/components/Popover/__stories__/Popover.stories.tsx index d2ee4b3734..7428505e33 100644 --- a/src/components/Popover/__stories__/Popover.stories.tsx +++ b/src/components/Popover/__stories__/Popover.stories.tsx @@ -75,7 +75,6 @@ const meta: Meta = { tooltipCancelButton: {control: 'object'}, tooltipOffset: {control: 'object'}, tooltipClassName: {control: 'text'}, - tooltipContentClassName: {control: 'text'}, className: {control: 'text'}, onClick: {action: 'onClick'}, onOpenChange: {action: 'onOpenChange'}, diff --git a/src/components/Popover/__tests__/Popover.test.tsx b/src/components/Popover/__tests__/Popover.test.tsx index 390ea3fdb2..47f70eedaa 100644 --- a/src/components/Popover/__tests__/Popover.test.tsx +++ b/src/components/Popover/__tests__/Popover.test.tsx @@ -1,5 +1,5 @@ import {setupTimersMock} from '../../../../test-utils/setupTimersMock'; -import {act, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {act, fireEvent, render, screen, waitFor} from '../../../../test-utils/utils'; import {Popover} from '../Popover'; import {PopoverBehavior, delayByBehavior} from '../config'; import type {PopoverProps} from '../types'; @@ -27,14 +27,16 @@ const waitForTooltipOpenedStateChange = (shouldOpen?: boolean) => const checkIfPopoverOpened = () => { const popover = screen.queryByTestId('popover-tooltip'); - expect(popover).toHaveClass('g-popup_open'); + expect(popover).toBeInTheDocument(); }; -const checkIfPopoverClosed = () => { +const checkIfPopoverClosed = async () => { const popover = screen.queryByTestId('popover-tooltip'); if (popover) { - expect(popover).not.toHaveClass('g-popup_open'); + await waitFor(() => { + expect(popover).not.toBeInTheDocument(); + }); } else { expect(true).toBe(true); } @@ -60,21 +62,21 @@ test('Can be opened/closed on hover/unhover', async () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.mouseEnter(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(true); }); checkIfPopoverOpened(); fireEvent.mouseLeave(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(false); }); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test("Doesn't close if the cursor is on the tooltip", () => { +test("Doesn't close if the cursor is on the tooltip", async () => { render( renderPopover({ openOnHover: true, @@ -84,21 +86,21 @@ test("Doesn't close if the cursor is on the tooltip", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.mouseEnter(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(true); }); const tooltip = screen.getByText(defaultTooltipContent); fireEvent.mouseLeave(popoverTrigger); fireEvent.mouseEnter(tooltip); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(false); }); checkIfPopoverOpened(); }); -test("Doesn't close on unhover if not autoclosable", () => { +test("Doesn't close on unhover if not autoclosable", async () => { render( renderPopover({ openOnHover: true, @@ -108,18 +110,18 @@ test("Doesn't close on unhover if not autoclosable", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.mouseEnter(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(true); }); fireEvent.mouseLeave(popoverTrigger); - act(() => { + await act(async () => { waitForTooltipOpenedStateChange(false); }); checkIfPopoverOpened(); }); -test('Can be opened/closed on click', () => { +test('Can be opened/closed on click', async () => { render( renderPopover({ openOnHover: false, @@ -134,10 +136,10 @@ test('Can be opened/closed on click', () => { fireEvent.click(popoverTrigger); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test("Can't be opened by click if onClick returns false", () => { +test("Can't be opened by click if onClick returns false", async () => { render( renderPopover({ openOnHover: false, @@ -150,10 +152,10 @@ test("Can't be opened by click if onClick returns false", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.click(popoverTrigger); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test("Can't be opened if disabled", () => { +test("Can't be opened if disabled", async () => { render( renderPopover({ disabled: true, @@ -163,10 +165,10 @@ test("Can't be opened if disabled", () => { const popoverTrigger = screen.getByText(defaultTriggerText); fireEvent.click(popoverTrigger); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); -test('Can be closed on click', () => { +test('Can be closed on click', async () => { render( renderPopover({ hasClose: true, @@ -179,7 +181,7 @@ test('Can be closed on click', () => { const closeButton = screen.getByRole('button', {name: 'Close'}); fireEvent.click(closeButton); - checkIfPopoverClosed(); + await checkIfPopoverClosed(); }); test('Calls close button click handler on close button click', () => { diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index cc936a6b05..db2dcfdea6 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -44,8 +44,6 @@ export interface PopoverExternalProps { tooltipOffset?: PopupOffset; /** Tooltip's css class */ tooltipClassName?: string; - /** Tooltip's content css class */ - tooltipContentClassName?: string; /** css class for the control */ className?: string; /** @@ -124,7 +122,7 @@ export type PopoverDefaultProps = { export type PopoverProps = Pick< PopupProps, - 'anchorElement' | 'anchorRef' | 'strategy' | 'placement' | 'middlewares' + 'anchorElement' | 'anchorRef' | 'strategy' | 'placement' > & PopoverExternalProps & PopoverBehaviorProps & diff --git a/src/components/Popup/Popup.scss b/src/components/Popup/Popup.scss index a975e415b3..377b767d00 100644 --- a/src/components/Popup/Popup.scss +++ b/src/components/Popup/Popup.scss @@ -7,6 +7,7 @@ $arrow-offset: 9px; $arrow-border: 5px; $arrow-circle-width: 28px; $arrow-circle-height: 30px; +$transition-duration: 100ms; $transition-distance: 10px; #{$block} { @@ -14,113 +15,61 @@ $transition-distance: 10px; --_--border-color: var(--g-popup-border-color, var(--g-color-line-generic-solid)); --_--border-width: var(--g-popup-border-width, 1px); - z-index: 1000; + position: relative; + border-radius: 4px; + background-color: var(--_--background-color); + box-shadow: + 0 0 0 var(--_--border-width) var(--_--border-color), + 0 8px 20px var(--_--border-width) var(--g-color-sfx-shadow); + outline: none; visibility: hidden; + transition-property: opacity, transform; + transition-timing-function: ease-out; - 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 { + &_open { visibility: visible; } - &_exit_active { - &[data-floating-placement*='bottom'] #{$block}__content { - animation-name: #{variables.$ns}popup-bottom; - } - - &[data-floating-placement*='top'] #{$block}__content { - animation-name: #{variables.$ns}popup-top; - } - - &[data-floating-placement*='left'] #{$block}__content { - animation-name: #{variables.$ns}popup-left; - } - - &[data-floating-placement*='right'] #{$block}__content { - animation-name: #{variables.$ns}popup-right; - } + & > :first-child:not(#{$block}__arrow), + & > #{$block}__arrow + * { + border-start-start-radius: inherit; + border-start-end-radius: inherit; } - // open state - &_enter_active, - &_appear_active { - &[data-floating-placement*='bottom'] #{$block}__content { - animation-name: #{variables.$ns}popup-bottom-open; - } - - &[data-floating-placement*='top'] #{$block}__content { - animation-name: #{variables.$ns}popup-top-open; - } - - &[data-floating-placement*='left'] #{$block}__content { - animation-name: #{variables.$ns}popup-left-open; - } - - &[data-floating-placement*='right'] #{$block}__content { - animation-name: #{variables.$ns}popup-right-open; - } + & > :last-child { + border-end-start-radius: inherit; + border-end-end-radius: inherit; } - // arrow - &[data-floating-placement*='bottom'] #{$block}__arrow { - inset-block-start: -$arrow-offset; + @at-root [data-floating-ui-status='open'] &, + [data-floating-ui-status='close'] & { + transition-duration: $transition-duration; } - &[data-floating-placement*='top'] #{$block}__arrow { - inset-block-end: -$arrow-offset; - - &-content { - transform: rotate(180deg); - } + @at-root [data-floating-ui-status='initial'] &, + [data-floating-ui-status='close'] & { + opacity: 0; + transform: translate(0, 0); } - &[data-floating-placement*='left'] #{$block}__arrow { - // stylelint-disable-next-line csstools/use-logical - right: -$arrow-offset; - - &-content { - transform: rotate(90deg); - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='bottom'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='bottom'] & { + transform: translateY($transition-distance); } - &[data-floating-placement*='right'] #{$block}__arrow { - // stylelint-disable-next-line csstools/use-logical - left: -$arrow-offset; - - &-content { - transform: rotate(-90deg); - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='top'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='top'] & { + transform: translateY(-$transition-distance); } - &__content { - position: relative; - animation-duration: 0.1s; - animation-timing-function: ease-out; - animation-fill-mode: forwards; - border-radius: 4px; - background-color: var(--_--background-color); - box-shadow: - 0 0 0 var(--_--border-width) var(--_--border-color), - 0 8px 20px var(--_--border-width) var(--g-color-sfx-shadow); - outline: none; - - // Corners rounding for content - & > :first-child:not(#{$block}__arrow), - & > #{$block}__arrow + * { - border-start-start-radius: inherit; - border-start-end-radius: inherit; - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='left'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='left'] & { + transform: translateX(-$transition-distance); + } - & > :last-child { - border-end-start-radius: inherit; - border-end-end-radius: inherit; - } + @at-root [data-floating-ui-status='initial'][data-floating-ui-placement*='right'] &, + [data-floating-ui-status='close'][data-floating-ui-placement*='right'] & { + transform: translateX($transition-distance); } &__arrow { @@ -163,92 +112,34 @@ $transition-distance: 10px; inset-block-end: -4px; } } -} -@keyframes #{variables.$ns}popup-bottom { - 0% { - opacity: 1; - transform: translateY(0); - } - 100% { - opacity: 0; - transform: translateY($transition-distance); - } -} - -@keyframes #{variables.$ns}popup-bottom-open { - 0% { - opacity: 0; - transform: translateY($transition-distance); - } - 100% { - opacity: 1; - transform: translateY(0); + @at-root [data-floating-ui-placement*='bottom'] #{$block}__arrow { + inset-block-start: -$arrow-offset; } -} -@keyframes #{variables.$ns}popup-top { - 0% { - opacity: 1; - transform: translateY(0); - } - 100% { - opacity: 0; - transform: translateY(-$transition-distance); - } -} + @at-root [data-floating-ui-placement*='top'] #{$block}__arrow { + inset-block-end: -$arrow-offset; -@keyframes #{variables.$ns}popup-top-open { - 0% { - opacity: 0; - transform: translateY(-$transition-distance); - } - 100% { - opacity: 1; - transform: translateY(0); + &-content { + transform: rotate(180deg); + } } -} -@keyframes #{variables.$ns}popup-left { - 0% { - opacity: 1; - transform: translateX(0); - } - 100% { - opacity: 0; - transform: translateX(-$transition-distance); - } -} + @at-root [data-floating-ui-placement*='left'] #{$block}__arrow { + // stylelint-disable-next-line csstools/use-logical + right: -$arrow-offset; -@keyframes #{variables.$ns}popup-left-open { - 0% { - opacity: 0; - transform: translateX(-$transition-distance); - } - 100% { - opacity: 1; - transform: translateX(0); + &-content { + transform: rotate(90deg); + } } -} -@keyframes #{variables.$ns}popup-right { - 0% { - opacity: 1; - transform: translateX(0); - } - 100% { - opacity: 0; - transform: translateX($transition-distance); - } -} + @at-root [data-floating-ui-placement*='right'] #{$block}__arrow { + // stylelint-disable-next-line csstools/use-logical + left: -$arrow-offset; -@keyframes #{variables.$ns}popup-right-open { - 0% { - opacity: 0; - transform: translateX($transition-distance); - } - 100% { - opacity: 1; - transform: translateX(0); + &-content { + transform: rotate(-90deg); + } } } diff --git a/src/components/Popup/Popup.tsx b/src/components/Popup/Popup.tsx index a07f8ac907..0c8a08f14f 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -3,46 +3,54 @@ import * as React from 'react'; import { + FloatingFocusManager, arrow, - autoPlacement, autoUpdate, - flip, offset as floatingOffset, limitShift, shift, + useDismiss, useFloating, + useInteractions, + useRole, + useTransitionStatus, } from '@floating-ui/react'; import type { - Alignment, + FloatingFocusManagerProps, FloatingRootContext, Middleware, - Placement, + OpenChangeReason, ReferenceType, Strategy, + UseFloatingOptions, + UseInteractionsReturn, + UseRoleProps, } from '@floating-ui/react'; -import {CSSTransition} from 'react-transition-group'; import {useForkRef} from '../../hooks'; -import {useRestoreFocus} from '../../hooks/private'; +import {usePrevious} from '../../hooks/private'; import {Portal} from '../Portal'; -import type {DOMProps, QAProps} from '../types'; -import {FocusTrap, useParentFocusTrap} from '../utils/FocusTrap'; +import type {AriaLabelingProps, DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; -import {useLayer} from '../utils/layer-manager'; -import type {LayerExtendableProps} from '../utils/layer-manager/LayerManager'; -import {getCSSTransitionClassNames} from '../utils/transition'; +import {filterDOMProps} from '../utils/filterDOMProps'; import {PopupArrow} from './PopupArrow'; +import {OVERFLOW_PADDING, TRANSITION_DURATION} from './constants'; import {useAnchor} from './hooks'; +import i18n from './i18n'; import type {PopupAnchorElement, PopupAnchorRef, PopupOffset, PopupPlacement} from './types'; -import {getOffsetValue, isAutoPlacement} from './utils'; +import {arrowStylesMiddleware, getOffsetOptions, getPlacementOptions} from './utils'; import './Popup.scss'; -export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { +export type PopupCloseReason = 'outsideClick' | 'escapeKeyDown'; + +export interface PopupProps extends DOMProps, AriaLabelingProps, QAProps { children?: React.ReactNode; /** Manages `Popup` visibility */ open?: boolean; + /** Callback for open state changes, when dismiss happens for example */ + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; /** `Popup` will not be removed from the DOM upon hiding */ keepMounted?: boolean; /** Render an arrow pointing to the anchor */ @@ -57,260 +65,279 @@ export interface PopupProps extends DOMProps, LayerExtendableProps, QAProps { anchorElement?: PopupAnchorElement | null; /** floating element anchor ref object */ anchorRef?: PopupAnchorRef; + /** Set up a getter for props that need to be passed to the anchor */ + setGetAnchorProps?: (getAnchorProps: UseInteractionsReturn['getReferenceProps']) => void; /** Floating UI middlewares. If set, they will completely overwrite the default middlewares. */ - middlewares?: Middleware[]; + floatingMiddlewares?: Middleware[]; /** Floating UI context to provide interactions */ 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 */ - onClick?: React.MouseEventHandler; - /** `mouseenter` event handler */ - onMouseEnter?: React.MouseEventHandler; - /** `mouseleave` event handler */ - onMouseLeave?: React.MouseEventHandler; - /** `focus` event handler */ - onFocus?: React.FocusEventHandler; - /** `blur` event handler */ - onBlur?: React.FocusEventHandler; - /** On start open popup animation void callback */ - onTransitionEnter?: VoidFunction; - /** On finish open popup animation void callback */ - onTransitionEntered?: VoidFunction; - /** On start close popup animation void callback */ - onTransitionExit?: VoidFunction; - /** On finish close popup animation void callback */ - onTransitionExited?: VoidFunction; + /** Manage focus when opened */ + autoFocus?: boolean; + /** If true focus is trapped inside the floating element */ + modalFocus?: boolean; + /** The initial element to be focused */ + initialFocus?: FloatingFocusManagerProps['initialFocus']; + /** Element which focus should be returned to */ + returnFocus?: FloatingFocusManagerProps['returnFocus']; + /** Do not add a11y dismiss buttons when managing focus */ + disableFocusVisuallyHiddenDismiss?: boolean; + /** + * This callback will be called when Escape key pressed on keyboard, or click outside was made + * This behaviour could be disabled with `disableEscapeKeyDown` + * and `disableOutsideClick` options + * + * @deprecated Use `onOpenChange` instead + */ + onClose?: (event: MouseEvent | KeyboardEvent, reason: PopupCloseReason) => void; + /** + * This callback will be called when Escape key pressed on keyboard + * This behaviour could be disabled with `disableEscapeKeyDown` option + * + * @deprecated Use `onOpenChange` instead + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void; + /** + * This callback will be called when click is outside of elements of "top layer" + * This behaviour could be disabled with `disableOutsideClick` option + * + * @deprecated Use `onOpenChange` instead + */ + onOutsideClick?: (event: MouseEvent) => void; + /** Do not dismiss on escape key press */ + disableEscapeKeyDown?: boolean; + /** Do not dismiss on outside click */ + disableOutsideClick?: boolean; /** Do not use `Portal` for children */ disablePortal?: boolean; - /** DOM element children to be mounted to */ - container?: HTMLElement; - /** HTML `class` attribute for content node */ - contentClassName?: string; - /** If true, the focus will return to the anchor element */ - restoreFocus?: boolean; - /** Element the focus will be restored to, depends on `restoreFocus` */ - restoreFocusRef?: React.RefObject; - /** `aria-label` attribute, use this attribute only if you didn't have visible caption */ - 'aria-label'?: React.AriaAttributes['aria-label']; - /** `aria-labelledby` attribute, prefer this attribute if you have visible caption */ - 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; - /** `aria-modal` attribute, default value is equal to focusTrap */ - 'aria-modal'?: React.AriaAttributes['aria-modal']; - /** `aria-role` attribute */ - role?: React.AriaRole; + /** ARIA role or special component role (select, combobox) */ + role?: UseRoleProps['role']; /** HTML `id` attribute */ id?: string; - /** Enable focus trapping behavior */ - focusTrap?: boolean; - /** While open, the focus will be set to the first interactive element in the content */ - autoFocus?: boolean; + /** CSS property `z-index` */ + zIndex?: number; + /** Callback called when `Popup` is opened and "in" transition is started */ + onTransitionIn?: () => void; + /** Callback called when `Popup` is opened and "in" transition is completed */ + onTransitionInComplete?: () => void; + /** Callback called when `Popup` is closed and "out" transition is started */ + onTransitionOut?: () => void; + /** Callback called when `Popup` is closed and "out" transition is completed */ + onTransitionOutComplete?: () => void; } const b = block('popup'); export function Popup({ - floatingRef, keepMounted = false, hasArrow = false, - open, + open = false, + onOpenChange, strategy, - placement = 'top', - offset = 4, + placement: placementProp = 'top', + offset: offsetProp = 4, anchorElement, anchorRef, + setGetAnchorProps, + floatingMiddlewares, floatingContext, floatingProps, - disableEscapeKeyDown, - disableOutsideClick, - disableLayer, + floatingRef, + modalFocus = false, + autoFocus = false, + initialFocus, + returnFocus = true, + disableFocusVisuallyHiddenDismiss = false, + onClose, + onEscapeKeyDown, + onOutsideClick, + disableEscapeKeyDown = false, + disableOutsideClick = false, style, className, - contentClassName, - middlewares, children, - onEscapeKeyDown, - onOutsideClick, - onClose, - onClick, - onMouseEnter, - onMouseLeave, - onFocus, - onBlur, - onTransitionEnter, - onTransitionEntered, - onTransitionExit, - onTransitionExited, - disablePortal, - container, + disablePortal = false, qa, - restoreFocus, - restoreFocusRef, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledBy, - role: roleProp, id, - focusTrap = false, - autoFocus = false, - 'aria-modal': ariaModal = focusTrap, + role: roleProp, + zIndex = 1000, + onTransitionIn, + onTransitionOut, + onTransitionInComplete, + onTransitionOutComplete, + ...restProps }: PopupProps) { - const containerRef = React.useRef(null); + const contentRef = React.useRef(null); const [arrowElement, setArrowElement] = React.useState(null); const anchor = useAnchor(anchorElement, anchorRef); - const offsetValue = getOffsetValue(offset, hasArrow); + const {offset} = getOffsetOptions(offsetProp, hasArrow); + const {placement, middleware: placementMiddleware} = getPlacementOptions( + placementProp, + disablePortal, + ); - let placementValue: Placement | undefined; - let preventOverflowMiddleware: Middleware; + const handleOpenChange = React.useCallback>( + (isOpen, event, reason) => { + onOpenChange?.(isOpen, event, reason); - 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'; - } + if (isOpen || !event) { + return; + } - placementValue = undefined; - preventOverflowMiddleware = autoPlacement({ - altBoundary: disablePortal, - alignment, - }); - } else { - placementValue = placement; - preventOverflowMiddleware = flip({ - altBoundary: disablePortal, - }); - } + const closeReason = reason === 'escape-key' ? 'escapeKeyDown' : 'outsideClick'; - useLayer({ - open, - disableEscapeKeyDown, - disableOutsideClick, - onEscapeKeyDown, - onOutsideClick, - onClose, - contentRefs: [anchor.ref, containerRef], - enabled: !disableLayer, - type: 'popup', - }); + if (closeReason === 'escapeKeyDown' && onEscapeKeyDown) { + onEscapeKeyDown(event as KeyboardEvent); + } + + if (closeReason === 'outsideClick' && onOutsideClick) { + onOutsideClick(event as MouseEvent); + } + + onClose?.(event as KeyboardEvent | MouseEvent, closeReason); + }, + [onOpenChange, onClose, onEscapeKeyDown, onOutsideClick], + ); const { refs, + elements, floatingStyles, - placement: actualPlacement, + placement: finalPlacement, middlewareData, + context, + update, } = useFloating({ rootContext: floatingContext, strategy, - placement: placementValue, + placement: placement, open, - whileElementsMounted: open ? autoUpdate : undefined, + onOpenChange: handleOpenChange, 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}), + middleware: floatingMiddlewares ?? [ + floatingOffset(offset), + placementMiddleware, + shift({ + padding: OVERFLOW_PADDING, + // Offset 22 is size of the arrow (18) + padding (4) + limiter: limitShift({offset: 4 + (hasArrow ? 18 : 0)}), + altBoundary: disablePortal, + }), + hasArrow && arrow({element: arrowElement, padding: 4}), + hasArrow && arrowStylesMiddleware(), ], }); - const arrowStyles: React.CSSProperties = {}; + const role = useRole(context, { + enabled: Boolean(roleProp || modalFocus), + role: roleProp ?? (modalFocus ? 'dialog' : undefined), + }); + const dismiss = useDismiss(context, { + enabled: !disableOutsideClick || !disableEscapeKeyDown, + outsidePress: !disableOutsideClick, + escapeKey: !disableEscapeKeyDown, + }); + + const {getReferenceProps, getFloatingProps} = useInteractions([role, dismiss]); - if (hasArrow && middlewareData.arrow) { - const {x, y} = middlewareData.arrow; - arrowStyles.left = x; - arrowStyles.top = y; - } + React.useLayoutEffect(() => { + setGetAnchorProps?.(getReferenceProps); + }, [setGetAnchorProps, getReferenceProps]); - const handleRef = useForkRef( - floatingRef, + const {isMounted, status} = useTransitionStatus(context, {duration: TRANSITION_DURATION}); + const previousStatus = usePrevious(status); + + React.useEffect(() => { + if (isMounted && elements.reference && elements.floating) { + return autoUpdate(elements.reference, elements.floating, update); + } + return; + }, [isMounted, elements, update]); + + const initialFocusRef = React.useRef(null); + const handleFloatingRef = useForkRef( refs.setFloating, - containerRef, - useParentFocusTrap(), + floatingRef, + initialFocusRef, ); - const containerProps = useRestoreFocus({ - enabled: Boolean(restoreFocus && open), - restoreFocusRef, - }); + const handleTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + // There are two simultaneous transitions running at the same time + // Use specific name to only notify once + if (status === 'open' && event.propertyName === 'transform') { + onTransitionInComplete?.(); + } + }, + [status, onTransitionInComplete], + ); - let role = roleProp; - if ((ariaModal === true || ariaModal === 'true') && !role) { - role = 'dialog'; - } + // Cannot use transitionend event for these callbacks due to unmounting from the DOM + React.useEffect(() => { + if (status === 'initial' && previousStatus === 'unmounted') { + onTransitionIn?.(); + } + if (status === 'close' && previousStatus === 'open') { + onTransitionOut?.(); + } + if (status === 'unmounted' && previousStatus === 'close') { + onTransitionOutComplete?.(); + } + }, [status, previousStatus, onTransitionIn, onTransitionOut, onTransitionOutComplete]); - return ( - containerRef.current?.addEventListener('animationend', done)} - classNames={getCSSTransitionClassNames(b)} - mountOnEnter={!keepMounted} - unmountOnExit={!keepMounted} - appear={true} - onEnter={() => { - onTransitionEnter?.(); - }} - onEntered={() => { - onTransitionEntered?.(); - }} - onExit={() => { - onTransitionExit?.(); - }} - onExited={() => { - onTransitionExited?.(); - }} - > - + return isMounted || keepMounted ? ( + +
- - {/* FIXME The onClick event handler is deprecated and should be removed */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} -
- {hasArrow && ( - - )} - {children} -
-
+
+ {hasArrow && ( + + )} + {children} +
-
-
- ); + + + ) : null; } diff --git a/src/components/Popup/PopupArrow.tsx b/src/components/Popup/PopupArrow.tsx index 2753858c9e..8f9dec376f 100644 --- a/src/components/Popup/PopupArrow.tsx +++ b/src/components/Popup/PopupArrow.tsx @@ -8,21 +8,22 @@ const b = block('popup'); interface PopupArrowProps { styles: React.CSSProperties; - attributes?: Record; - setArrowRef: (value: HTMLDivElement) => void; } -export function PopupArrow({styles, attributes, setArrowRef}: PopupArrowProps) { +export const PopupArrow = React.forwardRef(function PopupArrow( + {styles}, + ref, +) { return ( -
+
-
+
-
+
); -} +}); diff --git a/src/components/Popup/__tests__/Popup.test.tsx b/src/components/Popup/__tests__/Popup.test.tsx index 9d0613097a..da62062b82 100644 --- a/src/components/Popup/__tests__/Popup.test.tsx +++ b/src/components/Popup/__tests__/Popup.test.tsx @@ -23,14 +23,6 @@ describe('Popup', () => { expect(popup).toHaveClass(arbitratyClassName); }); - test('should pass arbitraty className to content', () => { - const arbitratyClassName = 'arbitratyClassName'; - render(); - const popup = screen.getByTestId(qaId); - /* eslint-disable-next-line testing-library/no-node-access */ - expect(popup.firstChild).toHaveClass(arbitratyClassName); - }); - test('should open on click', async () => { const btnText = 'Click me'; function Test() { @@ -57,27 +49,27 @@ describe('Popup', () => { expect(popup).not.toHaveAttribute('role'); }); - test('should set aria-modal to true and role to dialog if focusTrap is true', async () => { - render(); + test('should set aria-modal to true and role to dialog if modalFocus is true', async () => { + render(); const popup = screen.getByRole('dialog'); expect(popup).toHaveAttribute('aria-modal', 'true'); }); - test('should use role from props if focusTrap is true', async () => { - render(); + test('should use role from props if modalFocus is true', async () => { + render(); const popup = screen.getByRole('alertdialog'); expect(popup).toHaveAttribute('aria-modal', 'true'); }); - test('should use aria-modal from props if focusTrap is true', async () => { - render(); + test('should not set aria-modal from props if modalFocus is true', async () => { + render(); const popup = screen.getByTestId(qaId); expect(popup).not.toHaveAttribute('aria-modal'); expect(popup).not.toHaveAttribute('role'); }); test('should remove aria-modal if popup is closed', async () => { - render(); + render(); const popup = screen.getByRole('dialog'); expect(popup).not.toHaveAttribute('aria-modal'); }); diff --git a/src/components/Popup/constants.ts b/src/components/Popup/constants.ts index e0ab3783a9..d9ee5b3084 100644 --- a/src/components/Popup/constants.ts +++ b/src/components/Popup/constants.ts @@ -1,3 +1,4 @@ export const AUTO_PLACEMENTS = ['auto', 'auto-start', 'auto-end'] as const; - export const ARROW_SIZE = 8; +export const OVERFLOW_PADDING = 4; +export const TRANSITION_DURATION = 100; diff --git a/src/components/Popup/i18n/en.json b/src/components/Popup/i18n/en.json new file mode 100644 index 0000000000..0c5bb0e5a1 --- /dev/null +++ b/src/components/Popup/i18n/en.json @@ -0,0 +1,3 @@ +{ + "close": "Close" +} diff --git a/src/components/Popup/i18n/index.ts b/src/components/Popup/i18n/index.ts new file mode 100644 index 0000000000..93bc5a16b0 --- /dev/null +++ b/src/components/Popup/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'Popup'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/Popup/i18n/ru.json b/src/components/Popup/i18n/ru.json new file mode 100644 index 0000000000..eeeebe6d6b --- /dev/null +++ b/src/components/Popup/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "close": "Закрыть" +} diff --git a/src/components/Popup/utils.ts b/src/components/Popup/utils.ts index 02e4d5ed9b..bf833f70ca 100644 --- a/src/components/Popup/utils.ts +++ b/src/components/Popup/utils.ts @@ -1,19 +1,74 @@ -import {ARROW_SIZE, AUTO_PLACEMENTS} from './constants'; -import type {AutoPlacement, PopupOffset} from './types'; +import {autoPlacement, flip} from '@floating-ui/react'; +import type {Alignment, Middleware, Placement} from '@floating-ui/react'; -export function getOffsetValue(offset: PopupOffset, hasArrow: boolean | undefined) { - let offsetValue = offset; +import {ARROW_SIZE, AUTO_PLACEMENTS, OVERFLOW_PADDING} from './constants'; +import type {AutoPlacement, PopupOffset, PopupPlacement} from './types'; + +export function getOffsetOptions(offsetProp: PopupOffset, hasArrow: boolean | undefined) { + let offset = offsetProp; if (hasArrow) { - if (typeof offsetValue === 'number') { - offsetValue += ARROW_SIZE; + if (typeof offset === 'number') { + offset += ARROW_SIZE; } else { - offsetValue = {...offsetValue, mainAxis: (offsetValue.mainAxis ?? 0) + ARROW_SIZE}; + offset = {...offset, mainAxis: (offset.mainAxis ?? 0) + ARROW_SIZE}; } } - return offsetValue; + return {offset}; } -export function isAutoPlacement(placement: string): placement is AutoPlacement { +function isAutoPlacement(placement: string): placement is AutoPlacement { return AUTO_PLACEMENTS.includes(placement as AutoPlacement); } + +export function getPlacementOptions(placementProp: PopupPlacement, disablePortal?: boolean) { + let placement: Placement | undefined; + let middleware: Middleware; + + if (Array.isArray(placementProp)) { + placement = placementProp[0]; + middleware = flip({ + padding: OVERFLOW_PADDING, + altBoundary: disablePortal, + fallbackPlacements: placementProp.slice(1), + }); + } else if (isAutoPlacement(placementProp)) { + let alignment: Alignment | undefined; + if (placementProp === 'auto-start') { + alignment = 'start'; + } else if (placementProp === 'auto-end') { + alignment = 'end'; + } + + placement = undefined; + middleware = autoPlacement({ + padding: OVERFLOW_PADDING, + altBoundary: disablePortal, + alignment, + }); + } else { + placement = placementProp; + middleware = flip({ + padding: OVERFLOW_PADDING, + altBoundary: disablePortal, + }); + } + + return {placement, middleware}; +} + +export const arrowStylesMiddleware = (): Middleware => ({ + name: 'arrowStyles', + fn({middlewareData}) { + if (!middlewareData.arrow) { + return {}; + } + + return { + data: { + left: middlewareData.arrow.x, + top: middlewareData.arrow.y, + }, + }; + }, +}); diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index 1422cf6a02..3c9ea8fba8 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; -import ReactDOM from 'react-dom'; +import {FloatingPortal} from '@floating-ui/react'; import {usePortalContainer} from '../../hooks'; import {ThemeProvider} from '../theme'; @@ -29,16 +29,19 @@ export function Portal({container, children, disablePortal}: PortalProps) { return {children}; } - return containerNode - ? ReactDOM.createPortal( - scoped ? ( - - {children} - - ) : ( - children - ), - containerNode, - ) - : null; + if (containerNode) { + return ( + + {scoped ? ( + + {children} + + ) : ( + children + )} + + ); + } + + return null; } diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 392d046d16..fe72b980f3 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -221,14 +221,6 @@ export const Select = React.forwardRef(function disabled: filterable, }); - React.useEffect(() => { - if (open) { - if (filterable) { - filterRef.current?.focus(); - } - } - }, [open, filterable]); - const mods: CnMods = { ...(width === 'max' && {width}), }; @@ -355,6 +347,13 @@ export const Select = React.forwardRef(function virtualized={virtualized} mobile={mobile} placement={popupPlacement} + onAfterOpen={ + filterable + ? () => { + filterRef.current?.focus(); + } + : undefined + } onAfterClose={ filterable ? () => { diff --git a/src/components/Select/__stories__/Select.stories.tsx b/src/components/Select/__stories__/Select.stories.tsx index 29ba95f5e7..3bad3ef027 100644 --- a/src/components/Select/__stories__/Select.stories.tsx +++ b/src/components/Select/__stories__/Select.stories.tsx @@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {Select} from '..'; import {Button} from '../../Button'; +import {Flex} from '../../layout'; import {SelectPopupWidthShowcase} from './SelectPopupWidthShowcase'; import {SelectShowcase} from './SelectShowcase'; @@ -32,12 +33,15 @@ type Story = StoryObj; export const Default = { render: (args) => ( - + + + + ), } satisfies Story; diff --git a/src/components/Select/components/SelectPopup/SelectPopup.tsx b/src/components/Select/components/SelectPopup/SelectPopup.tsx index f0cdb799e1..e877455577 100644 --- a/src/components/Select/components/SelectPopup/SelectPopup.tsx +++ b/src/components/Select/components/SelectPopup/SelectPopup.tsx @@ -21,6 +21,7 @@ export const SelectPopup = React.forwardRef( ( { handleClose, + onAfterOpen, onAfterClose, width, open, @@ -46,18 +47,20 @@ export const SelectPopup = React.forwardRef( ) : ( } placement={placement} open={open} onClose={handleClose} disablePortal={disablePortal} - restoreFocus - restoreFocusRef={controlRef} - middlewares={getMiddlewares({width, disablePortal, virtualized})} + autoFocus + initialFocus={-1} + returnFocus={controlRef} + floatingMiddlewares={getMiddlewares({width, disablePortal, virtualized})} id={id} - onTransitionExited={onAfterClose} + onTransitionIn={onAfterOpen} + onTransitionOutComplete={onAfterClose} > {children} diff --git a/src/components/Select/components/SelectPopup/types.ts b/src/components/Select/components/SelectPopup/types.ts index 2925047a29..e4d71b65a3 100644 --- a/src/components/Select/components/SelectPopup/types.ts +++ b/src/components/Select/components/SelectPopup/types.ts @@ -15,5 +15,6 @@ export type SelectPopupProps = { disablePortal?: boolean; virtualized?: boolean; id?: string; + onAfterOpen?: () => void; onAfterClose?: () => void; }; diff --git a/src/components/Sheet/Sheet.tsx b/src/components/Sheet/Sheet.tsx index a23ac820ca..48f3d194fc 100644 --- a/src/components/Sheet/Sheet.tsx +++ b/src/components/Sheet/Sheet.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; -import {useBodyScrollLock} from '../../hooks'; +import {FloatingOverlay} from '@floating-ui/react'; + import {Portal} from '../Portal/Portal'; import type {QAProps} from '../types'; @@ -48,8 +49,6 @@ export const Sheet = ({ const [open, setOpen] = React.useState(visible); const [prevVisible, setPrevVisible] = React.useState(visible); - useBodyScrollLock({enabled: open}); - if (!prevVisible && visible) { setOpen(true); } @@ -71,7 +70,12 @@ export const Sheet = ({ return ( -
+ -
+
); }; diff --git a/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx index ab4866ad1f..43c54da755 100644 --- a/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx +++ b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx @@ -31,6 +31,16 @@ const EXTRA_INNER_CONTENT_MORE_THAN_VIEWPORT = getRandomText(3000); export default { title: 'Components/Overlays/Sheet', component: Sheet, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], } as Meta; export const Default: StoryFn = ({ diff --git a/src/components/Toaster/ToasterComponent/ToasterComponent.tsx b/src/components/Toaster/ToasterComponent/ToasterComponent.tsx index 4ea0555efe..04b7f2e885 100644 --- a/src/components/Toaster/ToasterComponent/ToasterComponent.tsx +++ b/src/components/Toaster/ToasterComponent/ToasterComponent.tsx @@ -2,19 +2,21 @@ import * as React from 'react'; +import {Portal} from '../../Portal'; import {useMobile} from '../../mobile'; +import {block} from '../../utils/cn'; import {ToastsContext} from '../Provider/ToastsContext'; import {ToastList} from '../ToastList/ToastList'; import {useToaster} from '../hooks/useToaster'; -import {ToasterPortal} from './ToasterPortal'; - interface Props { className?: string; mobile?: boolean; hasPortal?: boolean; } +const b = block('toaster'); + export function ToasterComponent({className, mobile, hasPortal = true}: Props) { const defaultMobile = useMobile(); const {remove} = useToaster(); @@ -29,9 +31,11 @@ export function ToasterComponent({className, mobile, hasPortal = true}: Props) { } return ( - - {toaster} - + +
+ {toaster} +
+
); } diff --git a/src/components/Toaster/ToasterComponent/ToasterPortal.tsx b/src/components/Toaster/ToasterComponent/ToasterPortal.tsx deleted file mode 100644 index 1bba43ac89..0000000000 --- a/src/components/Toaster/ToasterComponent/ToasterPortal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -import * as React from 'react'; - -import {Portal} from '../../Portal'; -import {block} from '../../utils/cn'; - -type Props = React.PropsWithChildren<{ - className: string; - mobile?: boolean; -}>; - -const b = block('toaster'); - -export function ToasterPortal({children, className, mobile}: Props) { - const el = React.useRef( - typeof document === 'undefined' ? undefined : document.createElement('div'), - ); - - React.useEffect(() => { - const container = el.current; - - if (!container) { - return undefined; - } - - document.body.appendChild(container); - - return () => { - document.body.removeChild(container); - }; - }, []); - - React.useEffect(() => { - if (!el.current) { - return; - } - - el.current.className = b({mobile}, className); - }, [className, mobile]); - - return {children}; -} - -ToasterPortal.displayName = 'ToasterPortal'; diff --git a/src/components/Tooltip/README.md b/src/components/Tooltip/README.md index 5c481d2dcb..83efb59bbd 100644 --- a/src/components/Tooltip/README.md +++ b/src/components/Tooltip/README.md @@ -4,9 +4,8 @@ -This is a simple text tip that uses its child node as an anchor. This component accepts only text content and may be an excellent alternative to the browser title with its small size and increased appearance delay. - -A tooltip may have a light or dark theme. +A simple text tip that uses its child node as an anchor. This component accepts only text content and may be an excellent +alternative to the browser's `title` attribute with its small size and long appearance delay. ## Usage @@ -18,18 +17,58 @@ import {Tooltip} from '@gravity-ui/uikit'; ; ``` +## Anchor + +In order for `Tooltip` to work you should pass a valid `ReactElement` as a children which accepts `ref` property for `HTMLElement` +and other properties for `HTMLElement`. + +Alternatively, you can pass function as a children to provide ref and props manually to your underlying components: + +```tsx +import {Tooltip} from '@gravity-ui/uikit'; + + + {(props, ref) => } +; +``` + +## Controlled State + +By default `Tooltip` opens and hides by hovering the anchor. You can change this behaviour to manually set the open state. +Pass your state to the `open` prop and change it from `onOpenChange` callback. +`onOpenChange` callback has the following signature: `(open: boolean, event?: Event, reason: 'hover' | 'focus') => void`. + +## Role + +`Tooltip` accepts the `role` property which changes how it should act it terms of accessibility. +`tooltip` role should be used when anchor has its own text and `label` role otherwise (e.g. in icon button). + ## Properties -| Name | Description | Type | Default | -| :--------------- | --------------------------------------------------------------------------------------- | :----------------------------------------------: | :-----: | -| children | Anchor element for a `Tooltip`. It must accept a `ref` that will provide a DOM element. | `React.ReactElement` | | -| closeDelay | Number of ms to delay hiding the `Tooltip` after the hover ends | `number` | `0` | -| openDelay | Number of ms to delay showing the `Tooltip` once the hover starts | `number` | `1000` | -| placement | `Tooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | | -| qa | `data-qa` HTML attribute, used for testing | `string` | | -| content | The content that will be shown in the `Tooltip` | `React.ReactNode` | | -| id | Used for implementing the accessibility logic | `string` | | -| disablePortal | Disables using Portal for children | `boolean` | | -| contentClassName | `class` HTML attribute for the content node | `string` | | -| className | `class` HTML attribute for popup | `string` | | -| disabled | Prevents the popup from opening | `boolean` | `false` | +| Name | Description | Type | Default | +| :----------- | --------------------------------------------------------------------------- | :----------------------------------------------: | :---------: | +| children | Anchor element for the `Tooltip` | `React.ReactElement` `Function` | | +| className | `class` HTML attribute | `string` | | +| closeDelay | Number of ms to delay hiding the `Tooltip` after the hover ends | `number` | `0` | +| content | Content that will be shown in the `Tooltip` | `React.ReactNode` | | +| disabled | Prevent the `Tooltip` from opening | `boolean` | | +| offset | `Tooltip` offset from its anchor | `number` | `4` | +| onOpenChange | Callback to handle open state change | `Function` | | +| open | Controlled open state | `boolean` | | +| openDelay | Number of ms to delay showing the `Tooltip` after the hover begins | `number` | `1000` | +| placement | `Tooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | `bottom` | +| qa | `data-qa` HTML attribute, used for testing | `string` | | +| role | The role `Tooltip` is used for | `"tooltip"` `"label"` | `"tooltip"` | +| strategy | The type of CSS position property to use. | `absolute` `fixed` | `absolute` | +| style | `style` HTML attribute | `React.CSSProperties` | | +| trigger | Event type that should trigger opening. By default both hover and focus do. | `"focus"` | | + +## CSS API + +| Name | Description | +| :----------------------------- | :--------------- | +| `--g-tooltip-text-color` | Text color | +| `--g-tooltip-background-color` | Background color | +| `--g-tooltip-padding` | Padding | +| `--g-tooltip-border-radius` | Border radius | +| `--g-tooltip-box-shadow` | Shadow | diff --git a/src/components/Tooltip/Tooltip.scss b/src/components/Tooltip/Tooltip.scss index 7af3701423..ed6b06b998 100644 --- a/src/components/Tooltip/Tooltip.scss +++ b/src/components/Tooltip/Tooltip.scss @@ -3,33 +3,31 @@ $block: '.#{variables.$ns}tooltip'; #{$block} { - // [class] for increasing specificity - &[class] { - --g-popup-border-width: 0; + --_--text-color: var(--g-tooltip-text-color, var(--g-color-text-primary)); + --_--background-color: var(--g-tooltip-background-color, var(--g-color-base-float)); + --_--padding: var(--g-tooltip-padding, var(--g-spacing-1) var(--g-spacing-2)); + --_--border-radius: var(--g-tooltip-border-radius, 4px); + --_--box-shadow: var(--g-tooltip-box-shadow, 0 1px 5px 0 var(--g-color-sfx-shadow)); - > div { - padding: 4px 8px; - max-width: 360px; - box-sizing: border-box; - box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.15); - animation-duration: 1ms; - } - } + // -webkit-line-clamp will not work without display: -webkit-box; + /* stylelint-disable-next-line */ + display: -webkit-box; - &__content { - // -webkit-line-clamp will not work without display: -webkit-box; - /* stylelint-disable-next-line */ - display: -webkit-box; + -webkit-box-orient: vertical; + -moz-box-orient: vertical; + -ms-box-orient: vertical; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -ms-box-orient: vertical; + -webkit-line-clamp: 20; + -moz-line-clamp: 20; + -ms-line-clamp: 20; - -webkit-line-clamp: 20; - -moz-line-clamp: 20; - -ms-line-clamp: 20; + box-sizing: border-box; + padding: var(--_--padding); + max-width: 360px; + background-color: var(--_--background-color); + box-shadow: var(--_--box-shadow); + border-radius: var(--_--border-radius); - overflow: hidden; - text-overflow: ellipsis; - } + overflow: hidden; + text-overflow: ellipsis; } diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 1356feaedb..f1715a0877 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -2,88 +2,169 @@ import * as React from 'react'; -import {useForkRef} from '../../hooks'; -import {useTooltipVisible} from '../../hooks/private'; -import type {TooltipDelayProps} from '../../hooks/private'; -import {Popup} from '../Popup'; -import type {PopupPlacement} from '../Popup'; -import {Text} from '../Text'; +import { + autoUpdate, + limitShift, + offset, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; +import type {OpenChangeReason, Strategy} from '@floating-ui/react'; + +import {useControlledState, useForkRef} from '../../hooks'; +import type {PopupOffset, PopupPlacement} from '../Popup'; +import {OVERFLOW_PADDING} from '../Popup/constants'; +import {getPlacementOptions} from '../Popup/utils'; +import {Portal} from '../Portal'; import type {DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; import {getElementRef} from '../utils/getElementRef'; import './Tooltip.scss'; -export interface TooltipProps extends QAProps, DOMProps, TooltipDelayProps { - id?: string; +export interface TooltipProps extends QAProps, DOMProps { + /** Anchor node */ + children: + | ((props: Record, ref: React.Ref) => React.ReactElement) + | React.ReactElement; + /** Controls open state */ + open?: boolean; + /** Callback for open state changes, when dismiss happens for example */ + onOpenChange?: (open: boolean, event?: Event, reason?: OpenChangeReason) => void; + /** Floating UI strategy */ + strategy?: Strategy; + /** Floating element placement */ + placement?: PopupPlacement; + /** Floating element offset relative to anchor */ + offset?: PopupOffset; + /** Disabled state */ disabled?: boolean; + /** Floating element content */ content?: React.ReactNode; - placement?: PopupPlacement; - children: React.ReactElement; - contentClassName?: string; - disablePortal?: boolean; + /** Event that should trigger opening */ + trigger?: 'focus'; + /** Role applied to the floating element */ + role?: 'tooltip' | 'label'; + /** Delay in ms before open */ + openDelay?: number; + /** Delay in ms before close */ + closeDelay?: number; } const b = block('tooltip'); -const DEFAULT_PLACEMENT: PopupPlacement = ['bottom', 'top']; - -export const Tooltip = (props: TooltipProps) => { - const { - children, - content, - disabled, - placement = DEFAULT_PLACEMENT, - qa, - id, - className, - style, - disablePortal, - contentClassName, - openDelay = 1000, - closeDelay, - } = props; +const DEFAULT_OPEN_DELAY = 1000; +const DEFAULT_CLOSE_DELAY = 0; +const DEFAULT_PLACEMENT: PopupPlacement = 'bottom'; +const DEFAULT_OFFSET = 4; +export function Tooltip({ + children, + open, + onOpenChange, + strategy, + placement: placementProp = DEFAULT_PLACEMENT, + offset: offsetProp = DEFAULT_OFFSET, + disabled, + content, + trigger, + role: roleProp = 'tooltip', + openDelay = DEFAULT_OPEN_DELAY, + closeDelay = DEFAULT_CLOSE_DELAY, + className, + style, + qa, +}: TooltipProps) { const [anchorElement, setAnchorElement] = React.useState(null); - const tooltipVisible = useTooltipVisible(anchorElement, { - openDelay, - closeDelay, - preventTriggerOnFocus: true, + const {placement, middleware: placementMiddleware} = getPlacementOptions(placementProp, false); + + const [isOpen, setIsOpen] = useControlledState(open, false, onOpenChange); + + const {refs, floatingStyles, context} = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + strategy, + placement, + middleware: [ + offset(offsetProp), + placementMiddleware, + shift({ + padding: OVERFLOW_PADDING, + limiter: limitShift({ + offset: ({rects, placement}) => { + const referenceProp: Record = { + top: 'width', + bottom: 'width', + left: 'height', + right: 'height', + }; + return {mainAxis: rects.reference[referenceProp[placement.split('-')[0]]]}; + }, + }), + }), + ], + whileElementsMounted: autoUpdate, + elements: { + reference: anchorElement, + }, }); - const renderPopup = () => { - return ( - -
- - {content} - -
-
- ); - }; + const hover = useHover(context, { + enabled: !disabled && trigger !== 'focus', + delay: {open: openDelay, close: closeDelay}, + move: false, + }); + const focus = useFocus(context, {enabled: !disabled}); + const role = useRole(context, { + role: roleProp, + }); + const dismiss = useDismiss(context, { + outsidePress: false, + }); - const child = React.Children.only(children); - const childRef = getElementRef(child); + const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, role, dismiss]); - const ref = useForkRef(setAnchorElement, childRef); + const anchorRef = useForkRef( + setAnchorElement, + React.isValidElement(children) ? getElementRef(children) : undefined, + ); + const anchorProps = React.isValidElement(children) + ? getReferenceProps(children.props) + : getReferenceProps(); + const anchorNode = React.isValidElement(children) + ? React.cloneElement(children, { + ref: anchorRef, + ...anchorProps, + }) + : children(anchorProps, anchorRef); return ( - {React.cloneElement(child, {ref})} - {anchorElement ? renderPopup() : null} + {anchorNode} + {isOpen && !disabled ? ( + +
+
+ {content} +
+
+
+ ) : null}
); -}; +} diff --git a/src/components/Tooltip/__stories__/Tooltip.stories.tsx b/src/components/Tooltip/__stories__/Tooltip.stories.tsx index 1e3a9c4ebc..840b0a18fc 100644 --- a/src/components/Tooltip/__stories__/Tooltip.stories.tsx +++ b/src/components/Tooltip/__stories__/Tooltip.stories.tsx @@ -1,11 +1,16 @@ +import {action} from '@storybook/addon-actions'; import type {Meta, StoryObj} from '@storybook/react'; import {Button} from '../../Button'; +import {Flex} from '../../layout'; import {Tooltip} from '../Tooltip'; const meta: Meta = { title: 'Components/Overlays/Tooltip', component: Tooltip, + parameters: { + layout: 'centered', + }, }; export default meta; @@ -16,12 +21,53 @@ export const Default: Story = { render: (args) => { return ( - + ); }, args: { - id: 'tooltip-id', - content: 'Hello world!', + content: 'Content', + onOpenChange: action('onOpenChange'), + }, +}; + +export const Delay: Story = { + render: (args) => ( + + + + + + + + + ), + args: { + ...Default.args, + }, +}; + +export const OnlyFocus: Story = { + ...Default, + args: { + ...Default.args, + trigger: 'focus', + }, +}; + +export const Disabled: Story = { + ...Default, + args: { + ...Default.args, + disabled: true, + }, +}; + +export const LongText: Story = { + ...Default, + args: { + ...Default.args, + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', }, }; diff --git a/src/components/Tooltip/__tests__/Tooltip.test.tsx b/src/components/Tooltip/__tests__/Tooltip.test.tsx index 98e94c14d4..e53cd1e664 100644 --- a/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -1,21 +1,12 @@ import userEvent from '@testing-library/user-event'; -import {createEvent, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {render, screen, waitFor} from '../../../../test-utils/utils'; import {Tooltip} from '../Tooltip'; -export function fireAnimationEndEvent(el: Node | Window, animationName = 'animation') { - const ev = createEvent.animationEnd(el, {animationName}); - Object.assign(ev, { - animationName, - }); - - fireEvent(el, ev); -} - test('should preserve ref on anchor element', () => { const ref = jest.fn(); render( - + - + @@ -67,8 +68,33 @@ export const SafePolygon: Story = { ...Default, args: { ...Default.args, - delay: 0, + openDelay: 0, + closeDelay: 0, offset: 50, enableSafePolygon: true, }, }; + +export const FocusManagement: Story = { + render: (args) => ( + + + + + + + + + + + + ), + args: { + ...Default.args, + content: ( +
+ Content with Link and +
+ ), + }, +}; diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx index 3b037922c6..d7a44271e5 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -72,8 +72,7 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro open={open} onClose={() => setOpen(false)} disablePortal - restoreFocus - restoreFocusRef={controlRef} + returnFocus={controlRef} > void; - removeNode: (id: string) => void; -} - -const focusTrapContext = React.createContext(undefined); - -interface FocusTrapProps { - enabled?: boolean; - /** @deprecated Use autoFocus instead */ - disableAutoFocus?: boolean; - autoFocus?: boolean; - children: React.ReactElement; -} -export function FocusTrap({ - children, - enabled = true, - disableAutoFocus, - autoFocus = true, -}: FocusTrapProps) { - const nodeRef = React.useRef(null); - - const setAutoFocusRef = React.useRef(!disableAutoFocus && autoFocus); - React.useEffect(() => { - setAutoFocusRef.current = !disableAutoFocus && autoFocus; - }); - - const focusTrapRef = React.useRef(); - - const containersRef = React.useRef>({}); - const updateContainerElements = React.useCallback(() => { - focusTrapRef.current?.updateContainerElements([ - nodeRef.current!, - ...Object.values(containersRef.current), - ]); - }, []); - - const actions = React.useMemo( - () => ({ - addNode(id: string, node: HTMLElement) { - if (containersRef.current[id] !== node && !nodeRef.current?.contains(node)) { - containersRef.current[id] = node; - updateContainerElements(); - } - }, - removeNode(id: string) { - if (containersRef.current[id]) { - delete containersRef.current[id]; - updateContainerElements(); - } - }, - }), - [updateContainerElements], - ); - - const handleNodeRef = React.useCallback( - (node: HTMLElement | null) => { - if (enabled && node) { - nodeRef.current = node; - if (!focusTrapRef.current) { - focusTrapRef.current = createFocusTrap([], { - initialFocus: () => setAutoFocusRef.current && getFocusElement(node), - fallbackFocus: () => node, - returnFocusOnDeactivate: false, - escapeDeactivates: false, - clickOutsideDeactivates: false, - allowOutsideClick: true, - }); - } - updateContainerElements(); - focusTrapRef.current.activate(); - } else { - focusTrapRef.current?.deactivate(); - nodeRef.current = null; - } - }, - [enabled, updateContainerElements], - ); - - const child = React.Children.only(children); - if (!React.isValidElement(child)) { - throw new Error('Children must contain only one valid element'); - } - const childRef = getElementRef(child); - - const ref = useForkRef(handleNodeRef, childRef); - - return ( - - {React.cloneElement(child, {ref})} - - ); -} - -export function useParentFocusTrap() { - const actions = React.useContext(focusTrapContext); - const id = useUniqId(); - - return React.useMemo(() => { - if (!actions) { - return undefined; - } - - return (node: HTMLElement | null) => { - if (node) { - actions.addNode(id, node); - } else { - actions.removeNode(id); - } - }; - }, [actions, id]); -} - -function getFocusElement(root: HTMLElement) { - if ( - !(document.activeElement instanceof HTMLElement) || - !root.contains(document.activeElement) - ) { - if (!root.hasAttribute('tabIndex')) { - if (process.env.NODE_ENV !== 'production') { - // used only in dev build - // eslint-disable-next-line no-console - console.error('@gravity-ui/uikit: focus-trap content node does node accept focus.'); - } - root.setAttribute('tabIndex', '-1'); - } - return root; - } - - return document.activeElement; -} diff --git a/src/demo/colors/ColorPanel.tsx b/src/demo/colors/ColorPanel.tsx index 3260f59e25..e1239064e7 100644 --- a/src/demo/colors/ColorPanel.tsx +++ b/src/demo/colors/ColorPanel.tsx @@ -4,7 +4,6 @@ import {Bulb} from '@gravity-ui/icons'; import ReactCopyToClipboard from 'react-copy-to-clipboard'; import {ActionTooltip, Button, Icon} from '../../components'; -import {useUniqId} from '../../hooks'; import './ColorPanel.scss'; @@ -26,7 +25,6 @@ const switchBackgroundTitle = 'Switch background'; export function ColorPanel(props: ColorPanelProps) { const [currentBackgroundIndex, setCurrentBackgroundIndex] = React.useState(0); - const tooltipId = useUniqId(); function rotateBackground() { setCurrentBackgroundIndex((index) => (index + 1) % BACKGROUND_LIST.length); @@ -61,7 +59,7 @@ export function ColorPanel(props: ColorPanelProps) { return (
- + diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c19b579ae2..e5e67492c2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,5 @@ export * from './useActionHandlers'; export * from './useAsyncActionHandler'; -export * from './useBodyScrollLock'; export * from './useControlledState'; export * from './useFileInput'; export * from './useFocusWithin'; diff --git a/src/hooks/private/index.ts b/src/hooks/private/index.ts index 8a6e72f1e7..524ace2662 100644 --- a/src/hooks/private/index.ts +++ b/src/hooks/private/index.ts @@ -3,10 +3,10 @@ export * from './useCheckbox'; export * from './useCloseOnTimeout'; export * from './useConditionallyControlledState'; export * from './useElementSize'; +export * from './useFormResetHandler'; export * from './useHover'; +export * from './usePrevious'; export * from './useRadio'; export * from './useRadioGroup'; -export * from './useRestoreFocus'; -export * from './useUpdateEffect'; export * from './useTooltipVisible'; -export * from './useFormResetHandler'; +export * from './useUpdateEffect'; diff --git a/src/hooks/private/usePrevious/README.md b/src/hooks/private/usePrevious/README.md new file mode 100644 index 0000000000..88a8b4293d --- /dev/null +++ b/src/hooks/private/usePrevious/README.md @@ -0,0 +1,13 @@ +# usePrevious + +The `usePrevious` hook stores the previous value during renders + +## Properties + +| Name | Description | Type | Default | +| :---- | :------------------------------- | :---: | :-----: | +| value | Any value, usually from useState | `any` | | + +## Result + +Previous value diff --git a/src/hooks/private/usePrevious/index.ts b/src/hooks/private/usePrevious/index.ts new file mode 100644 index 0000000000..4215ffc95e --- /dev/null +++ b/src/hooks/private/usePrevious/index.ts @@ -0,0 +1 @@ +export {usePrevious} from './usePrevious'; diff --git a/src/hooks/private/usePrevious/usePrevious.ts b/src/hooks/private/usePrevious/usePrevious.ts new file mode 100644 index 0000000000..aa722865ee --- /dev/null +++ b/src/hooks/private/usePrevious/usePrevious.ts @@ -0,0 +1,11 @@ +import React from 'react'; + +export function usePrevious(value: T): T | undefined { + const currentRef = React.useRef(value); + const previousRef = React.useRef(); + if (currentRef.current !== value) { + previousRef.current = currentRef.current; + currentRef.current = value; + } + return previousRef.current; +} diff --git a/src/hooks/private/useRestoreFocus/README.md b/src/hooks/private/useRestoreFocus/README.md deleted file mode 100644 index a640bcd6c2..0000000000 --- a/src/hooks/private/useRestoreFocus/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# useRestoreFocus - -The `useRestoreFocus` hook restore focus - -## Properties - -| Name | Description | Type | Default | -| :-------------- | :------------------------- | :---------------: | :-----: | -| enabled | Enabled flag | `boolean` | | -| restoreFocusRef | Ref-link for restore focus | `React.RefObject` | | -| focusTrapped | Focus trapped flag | `boolean` | | - -## Result - -| Name | Description | Type | -| :------ | :--------------- | :---------------------------------: | -| onFocus | OnFocus callback | `(event: React.FocusEvent) => void` | diff --git a/src/hooks/private/useRestoreFocus/index.ts b/src/hooks/private/useRestoreFocus/index.ts deleted file mode 100644 index a1c50d98e0..0000000000 --- a/src/hooks/private/useRestoreFocus/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {useRestoreFocus} from './useRestoreFocus'; -export type {UseRestoreFocusProps, UseRestoreFocusResult} from './useRestoreFocus'; diff --git a/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx b/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx deleted file mode 100644 index 4e2f9e099e..0000000000 --- a/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import * as React from 'react'; - -import {isFocusable, isTabbable} from 'tabbable'; - -export interface UseRestoreFocusProps { - enabled: boolean; - restoreFocusRef?: React.RefObject; - focusTrapped?: boolean; -} - -export interface UseRestoreFocusResult { - onFocus: (event: React.FocusEvent) => void; -} - -export function useRestoreFocus({ - enabled, - restoreFocusRef, - focusTrapped, -}: UseRestoreFocusProps): UseRestoreFocusResult { - const ref = React.useRef(null); - - const initialActiveElementRef = React.useRef(null); - const lastActiveElementRef = React.useRef(null); - - const handleFocus = (event: React.FocusEvent) => { - if (enabled && initialActiveElementRef.current === null) { - initialActiveElementRef.current = event.relatedTarget as HTMLElement | null; - lastActiveElementRef.current = initialActiveElementRef.current; - ref.current = (restoreFocusRef?.current || initialActiveElementRef.current) ?? null; - } - }; - - React.useEffect(() => { - if (!enabled) { - return undefined; - } - - const handleFocusIn = (event: FocusEvent) => { - const element = event.target; - if (!focusTrapped && element instanceof HTMLElement && isTabbable(element)) { - lastActiveElementRef.current = element; - } - }; - const handlePointerDown = (event: MouseEvent | TouchEvent) => { - const element = event.target; - if (element instanceof HTMLElement && isTabbable(element)) { - lastActiveElementRef.current = element; - } else { - lastActiveElementRef.current = null; - } - }; - - window.addEventListener('focusin', handleFocusIn); - window.addEventListener('mousedown', handlePointerDown); - window.addEventListener('touchstart', handlePointerDown); - return () => { - window.removeEventListener('focusin', handleFocusIn); - window.removeEventListener('mousedown', handlePointerDown); - window.removeEventListener('touchstart', handlePointerDown); - }; - }, [enabled, focusTrapped]); - - React.useEffect(() => { - if (enabled) { - ref.current = (restoreFocusRef?.current || initialActiveElementRef.current) ?? null; - } else { - ref.current = null; - } - }); - - React.useEffect(() => { - if (!enabled) { - return undefined; - } - - return () => { - let element = ref.current; - const lastActive = lastActiveElementRef.current; - if (lastActive && document.contains(lastActive) && isTabbable(lastActive)) { - element = lastActive; - } - if ( - element && - typeof element.focus === 'function' && - document.contains(element) && - isFocusable(element) - ) { - if (element !== document.activeElement) { - setTimeout(() => { - element?.focus(); - }, 0); - } - initialActiveElementRef.current = null; - lastActiveElementRef.current = null; - } - }; - }, [enabled]); - - return {onFocus: handleFocus}; -} diff --git a/src/hooks/useBodyScrollLock/README.md b/src/hooks/useBodyScrollLock/README.md deleted file mode 100644 index fbd0b0a033..0000000000 --- a/src/hooks/useBodyScrollLock/README.md +++ /dev/null @@ -1,17 +0,0 @@ - - -# useBodyScrollLock - - - -```tsx -import {useBodyScrollLock} from '@gravity-ui/uikit'; -``` - -The `useBodyScrollLock` hook helps to blocks scrolling on the body element. - -## Properties - -| Name | Description | Type | Default | -| :------ | :---------- | :-------: | :-----: | -| enabled | Enable flag | `boolean` | | diff --git a/src/hooks/useBodyScrollLock/index.ts b/src/hooks/useBodyScrollLock/index.ts deleted file mode 100644 index 59656edbbe..0000000000 --- a/src/hooks/useBodyScrollLock/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {useBodyScrollLock} from './useBodyScrollLock'; -export type {BodyScrollLockProps, UseBodyScrollLockProps} from './useBodyScrollLock'; diff --git a/src/hooks/useBodyScrollLock/useBodyScrollLock.ts b/src/hooks/useBodyScrollLock/useBodyScrollLock.ts deleted file mode 100644 index 12a1baa2c8..0000000000 --- a/src/hooks/useBodyScrollLock/useBodyScrollLock.ts +++ /dev/null @@ -1,106 +0,0 @@ -import * as React from 'react'; - -const PROPERTY_PADDING_RIGHT = 'padding-right'; -const PROPERTY_PADDING_BOTTOM = 'padding-bottom'; -const PROPERTY_OVERFLOW = 'overflow'; - -const STORED_BODY_STYLE_KEYS = [ - PROPERTY_OVERFLOW, - PROPERTY_PADDING_RIGHT, - PROPERTY_PADDING_BOTTOM, -] as const; - -type StoredBodyStyleKeys = (typeof STORED_BODY_STYLE_KEYS)[number]; -type StoredBodyStyle = Partial>; - -function getStoredStyles(): StoredBodyStyle { - const styles: StoredBodyStyle = {}; - - for (const property of STORED_BODY_STYLE_KEYS) { - styles[property] = document.body.style.getPropertyValue(property); - } - - return styles; -} - -export interface UseBodyScrollLockProps { - enabled: boolean; -} - -export type BodyScrollLockProps = UseBodyScrollLockProps; - -let locks = 0; -let storedBodyStyle: StoredBodyStyle = {}; - -export function useBodyScrollLock({enabled}: UseBodyScrollLockProps) { - React.useLayoutEffect(() => { - if (enabled) { - locks++; - - if (locks === 1) { - setBodyStyles(); - } - - return () => { - locks--; - if (locks === 0) { - restoreBodyStyles(); - } - }; - } - - return undefined; - }, [enabled]); -} - -function setBodyStyles() { - const yScrollbarWidth = getYScrollbarWidth(); - const xScrollbarWidth = getXScrollbarWidth(); - const bodyPadding = getBodyComputedPadding(); - - storedBodyStyle = getStoredStyles(); - - document.body.style.setProperty(PROPERTY_OVERFLOW, 'hidden'); - - if (yScrollbarWidth) { - document.body.style.setProperty( - PROPERTY_PADDING_RIGHT, - `${bodyPadding.right + yScrollbarWidth}px`, - ); - } - if (xScrollbarWidth) { - document.body.style.setProperty( - PROPERTY_PADDING_BOTTOM, - `${bodyPadding.bottom + xScrollbarWidth}px`, - ); - } -} - -function restoreBodyStyles() { - for (const property of STORED_BODY_STYLE_KEYS) { - const storedProperty = storedBodyStyle[property]; - if (storedProperty) { - document.body.style.setProperty(property, storedProperty); - } else { - document.body.style.removeProperty(property); - } - } -} - -function getYScrollbarWidth() { - return window.innerWidth - document.documentElement.clientWidth; -} - -function getXScrollbarWidth() { - return window.innerHeight - document.documentElement.clientHeight; -} - -function getBodyComputedPadding() { - const computedStyle = window.getComputedStyle(document.body); - return { - top: Number.parseFloat(computedStyle.paddingTop), - right: Number.parseFloat(computedStyle.paddingRight), - bottom: Number.parseFloat(computedStyle.paddingBottom), - left: Number.parseFloat(computedStyle.paddingLeft), - }; -}