diff --git a/package-lock.json b/package-lock.json index c11370b6ce..2f8fbb6273 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 3cbe219647..9ce8d2eeb9 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 7d372d6ccc..fb5e567d8a 100644 --- a/src/components/ActionTooltip/ActionTooltip.tsx +++ b/src/components/ActionTooltip/ActionTooltip.tsx @@ -2,90 +2,62 @@ import 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 5b40131568..6c5443dc2c 100644 --- a/src/components/ActionTooltip/README.md +++ b/src/components/ActionTooltip/README.md @@ -4,32 +4,56 @@ -A simple text tip that uses its children node as an anchor. For correct functioning, 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 | An anchor element for a `Tooltip`. 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` after the hover begins | `number` | `250` | -| placement | `Tooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | | -| qa | HTML `data-qa` attribute, used in tests | `string` | | -| title | Tooltip title text | `string` | | -| description | Tooltip description text | `string` | | -| hotkey | Hot keys that are assigned to an interface action. | `string` | | -| id | This prop is used to help implement the accessibility logic. | `string` | | -| disablePortal | Do not use Portal for children | `boolean` | | -| contentClassName | HTML class attribute for content node | `string` | | -| disabled | Prevent popup from opening | `boolean` | `false` | +| Name | Description | Type | Default | +| :----------- | --------------------------------------------------------------------------- | :----------------------------------------------: | :--------: | +| children | An anchor element for the `ActionTooltip` | `React.ReactElement` `Function` | | +| className | HTML class 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 | HTML `data-qa` attribute, used in tests | `string` | | +| strategy | The type of CSS position property to use. | `absolute` `fixed` | `absolute` | +| style | HTML style 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 edd23e7e25..f9c4adcc78 100644 --- a/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx +++ b/src/components/ActionTooltip/__stories__/ActionTooltip.stories.tsx @@ -1,24 +1,52 @@ import React from 'react'; -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', + }, }; -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 338026f763..3ab81899bb 100644 --- a/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx +++ b/src/components/ActionTooltip/__tests__/ActionTooltip.test.tsx @@ -2,22 +2,13 @@ import React from 'react'; 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 b3e0039b7a..a4c6605fac 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 be75401df5..1484dfec27 100644 --- a/src/components/FilePreview/FilePreviewAction.tsx +++ b/src/components/FilePreview/FilePreviewAction.tsx @@ -14,7 +14,7 @@ export interface FilePreviewActionProps { extraProps?: | React.ButtonHTMLAttributes | React.AnchorHTMLAttributes; - tooltipExtraProps?: Omit; + tooltipExtraProps?: Omit; } export function FilePreviewAction({ @@ -28,10 +28,9 @@ 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 btnRef = React.useRef(null); + + React.useEffect(() => { + if (!props.open) { + setTopPopupOpen(false); + setBottomPopupOpen(false); + } + }, [props.open]); + + return ( + +
+ + +
Top popup
+
+ +
Bottom popup
+
+
+
+ ); +} 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 d3d46950e8..9596538095 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 42fb8cee50..a71efc178e 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 7dd3423bf8..296ccf7f52 100644 --- a/src/components/Popover/__tests__/Popover.test.tsx +++ b/src/components/Popover/__tests__/Popover.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; 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'; @@ -29,14 +29,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); } @@ -62,21 +64,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, @@ -86,21 +88,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, @@ -110,18 +112,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, @@ -136,10 +138,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, @@ -152,10 +154,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, @@ -165,10 +167,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, @@ -181,7 +183,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 f26b850c31..6f62224399 100644 --- a/src/components/Popup/Popup.tsx +++ b/src/components/Popup/Popup.tsx @@ -3,46 +3,54 @@ import 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 8dbae52f23..f6fc740f9a 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 b69f964328..c231616de4 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 dee2817682..3c38209550 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -2,7 +2,7 @@ import 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 22dba73d51..bb627e1d77 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 2a2588623e..82417fe125 100644 --- a/src/components/Select/__stories__/Select.stories.tsx +++ b/src/components/Select/__stories__/Select.stories.tsx @@ -4,6 +4,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'; @@ -34,12 +35,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 f3ef6c85d6..57fbc89635 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 597d754261..b88d4d1379 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 3cf14bd329..fe2cea3446 100644 --- a/src/components/Sheet/Sheet.tsx +++ b/src/components/Sheet/Sheet.tsx @@ -2,7 +2,8 @@ import 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 7b0f1bde4a..651fc2f323 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/ToasterPortal.tsx b/src/components/Toaster/ToasterComponent/ToasterPortal.tsx index ab4355abd5..43ecc5f008 100644 --- a/src/components/Toaster/ToasterComponent/ToasterPortal.tsx +++ b/src/components/Toaster/ToasterComponent/ToasterPortal.tsx @@ -31,15 +31,13 @@ export function ToasterPortal({children, className, mobile}: Props) { }; }, []); - React.useEffect(() => { - if (!el.current) { - return; - } - - el.current.className = b({mobile}, className); - }, [className, mobile]); - - return {children}; + return ( + +
+ {children} +
+
+ ); } ToasterPortal.displayName = 'ToasterPortal'; diff --git a/src/components/Tooltip/README.md b/src/components/Tooltip/README.md index 3b444d0921..92d6072717 100644 --- a/src/components/Tooltip/README.md +++ b/src/components/Tooltip/README.md @@ -4,9 +4,8 @@ -A simple text tip that uses its children 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. - -Tooltip has a light and dark theme. +A simple text tip that uses its children 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 | An anchor element for a `Tooltip`. 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` after the hover begins | `number` | `1000` | -| placement | `Tooltip` position relative to its anchor | [`PopupPlacement`](../Popup/README.md#placement) | | -| qa | HTML `data-qa` attribute, used in tests | `string` | | -| content | Content that will be shown in the `Tooltip` | `React.ReactNode` | | -| id | This prop is used to help implement the accessibility logic. | `string` | | -| disablePortal | Do not use Portal for children | `boolean` | | -| contentClassName | HTML class attribute for content node | `string` | | -| className | HTML class attribute for popup | `string` | | -| disabled | Prevent popup from opening | `boolean` | `false` | +| Name | Description | Type | Default | +| :----------- | --------------------------------------------------------------------------- | :----------------------------------------------: | :---------: | +| children | An anchor element for the `Tooltip` | `React.ReactElement` `Function` | | +| className | HTML class 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 | HTML `data-qa` attribute, used in tests | `string` | | +| role | The role `Tooltip` is used for | `"tooltip"` `"label"` | `"tooltip"` | +| strategy | The type of CSS position property to use. | `absolute` `fixed` | `absolute` | +| style | HTML style 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 695079319c..b9f8a6e994 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -2,88 +2,147 @@ import 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, + 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 {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({limiter: limitShift()})], + 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 child = React.Children.only(children); - const childRef = getElementRef(child); + const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, role]); - 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 58d2017d95..3e2d9ee432 100644 --- a/src/components/Tooltip/__stories__/Tooltip.stories.tsx +++ b/src/components/Tooltip/__stories__/Tooltip.stories.tsx @@ -1,13 +1,18 @@ import React from 'react'; +import {action} from '@storybook/addon-actions'; import type {Meta, StoryObj} from '@storybook/react'; import {Button} from '../../Button'; +import {Flex} from '../../layout'; import {Tooltip} from '../Tooltip'; const meta: Meta = { title: 'Components/Overlays/Tooltip', component: Tooltip, + parameters: { + layout: 'centered', + }, }; export default meta; @@ -18,12 +23,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 a609d74dd4..0925135499 100644 --- a/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -2,22 +2,13 @@ import React from 'react'; 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 8f2f6f17e8..65ce4f22d2 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 b6a70c430e..b1d471e5f3 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 6bdd05502e..0000000000 --- a/src/hooks/private/useRestoreFocus/useRestoreFocus.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import 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 44c171596b..0000000000 --- a/src/hooks/useBodyScrollLock/useBodyScrollLock.ts +++ /dev/null @@ -1,106 +0,0 @@ -import 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), - }; -}