diff --git a/src/components/ActionTooltip/ActionTooltip.scss b/src/components/ActionTooltip/ActionTooltip.scss index 38742e3995..efa726b514 100644 --- a/src/components/ActionTooltip/ActionTooltip.scss +++ b/src/components/ActionTooltip/ActionTooltip.scss @@ -4,7 +4,12 @@ $block: '.#{variables.$ns}action-tooltip'; #{$block} { - &__layout { + --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; } diff --git a/src/components/ActionTooltip/ActionTooltip.tsx b/src/components/ActionTooltip/ActionTooltip.tsx index 29f8ec2cbb..1d13cc670e 100644 --- a/src/components/ActionTooltip/ActionTooltip.tsx +++ b/src/components/ActionTooltip/ActionTooltip.tsx @@ -1,44 +1,85 @@ import React from 'react'; -import {Hotkey} from '../Hotkey'; -import type {HotkeyProps} from '../Hotkey'; -import {Tooltip} from '../Tooltip'; -import type {TooltipProps} from '../Tooltip'; +import {useForkRef} from '../../hooks'; +import {type TooltipDelayProps, useTooltipVisible} from '../../hooks/private'; +import {Hotkey, type HotkeyProps} from '../Hotkey'; +import {Popup, type PopupPlacement} from '../Popup'; +import type {DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; import './ActionTooltip.scss'; -const b = block('action-tooltip'); - -export interface ActionTooltipProps - extends Pick< - TooltipProps, - 'children' | 'disabled' | 'placement' | 'openDelay' | 'closeDelay' | 'className' | 'qa' - > { +export interface ActionTooltipProps extends QAProps, DOMProps, TooltipDelayProps { + id?: string; + disablePortal?: boolean; + contentClassName?: string; + disabled?: boolean; + placement?: PopupPlacement; + children: React.ReactElement; title: string; hotkey?: HotkeyProps['value']; description?: React.ReactNode; } +const DEFAULT_PLACEMENT: PopupPlacement = ['bottom', 'top']; +const b = block('action-tooltip'); + export function ActionTooltip(props: ActionTooltipProps) { - const {title, hotkey, description, children, ...tooltipProps} = props; + const { + placement = DEFAULT_PLACEMENT, + title, + hotkey, + children, + className, + contentClassName, + description, + disabled = false, + style, + qa, + id, + disablePortal, + ...delayProps + } = props; - return ( - + const [anchorElement, setAnchorElement] = React.useState(null); + const tooltipVisible = useTooltipVisible(anchorElement, delayProps); + + const renderPopup = () => { + return ( + +
{title}
{hotkey && }
{description &&
{description}
} - - } - > - {children} - +
+
+ ); + }; + + const child = React.Children.only(children); + const childRef = (child as any).ref; + + const ref = useForkRef(setAnchorElement, childRef); + + return ( + + {React.cloneElement(child, {ref})} + {anchorElement ? renderPopup() : null} + ); } diff --git a/src/components/ActionTooltip/README.md b/src/components/ActionTooltip/README.md new file mode 100644 index 0000000000..5b40131568 --- /dev/null +++ b/src/components/ActionTooltip/README.md @@ -0,0 +1,35 @@ + + +# ActionTooltip + + + +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. + +## Usage + +```tsx +import {ActionTooltip} from '@gravity-ui/uikit'; + + +
Anchor
+
; +``` + +## 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` | diff --git a/src/components/ActionTooltip/__tests__/ActionTooltip.tsx b/src/components/ActionTooltip/__tests__/ActionTooltip.tsx new file mode 100644 index 0000000000..d443fb5cd5 --- /dev/null +++ b/src/components/ActionTooltip/__tests__/ActionTooltip.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import {createEvent, fireEvent, render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +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( + + ); @@ -327,10 +327,10 @@ export const ToasterDemo = ({ ); - const errorToastBtn = ( - ); @@ -375,11 +375,11 @@ export const ToasterDemo = ({ return ( -

{defaultToastBtn}

+

{normalToastBtn}

{infoToastBtn}

{successToastBtn}

{warningToastBtn}

-

{errorToastBtn}

+

{dangerToastBtn}

{utilityToastBtn}

{customToastBtn}

{toastWithLongContent}

diff --git a/src/components/Toaster/types.ts b/src/components/Toaster/types.ts index fe74158bbb..72dce8616a 100644 --- a/src/components/Toaster/types.ts +++ b/src/components/Toaster/types.ts @@ -5,7 +5,7 @@ export type ToasterArgs = { mobile?: boolean; }; -export type ToastType = 'info' | 'success' | 'warning' | 'error' | 'utility'; +export type ToastTheme = 'normal' | 'info' | 'success' | 'warning' | 'danger' | 'utility'; export type ToastAction = { onClick: VoidFunction; @@ -20,7 +20,7 @@ export type ToastProps = { className?: string; autoHiding?: number | false; content?: React.ReactNode; - type?: ToastType; + theme?: ToastTheme; isClosable?: boolean; actions?: ToastAction[]; diff --git a/src/components/Tooltip/README.md b/src/components/Tooltip/README.md index 563463204a..3b444d0921 100644 --- a/src/components/Tooltip/README.md +++ b/src/components/Tooltip/README.md @@ -4,8 +4,9 @@ -A simple text tip that uses its children node as an anchor. To function correctly, the anchor node -must be able to handle mouse events and focus or blur events. +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. ## Usage @@ -19,11 +20,16 @@ import {Tooltip} from '@gravity-ui/uikit'; ## Properties -| Name | Description | Type | Default | -| :--------- | --------------------------------------------------------------------------------------- | :----------------------------------------------: | :-----: | -| children | An anchor element for a `Tooltip`. Must accept a `ref` that will provide a DOM element. | `React.ReactElement` | | -| content | Content that will be shown in the `Tooltip` | `React.ReactNode` | | -| 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` | | +| 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` | diff --git a/src/components/Tooltip/Tooltip.scss b/src/components/Tooltip/Tooltip.scss index 87ca706048..85358bed37 100644 --- a/src/components/Tooltip/Tooltip.scss +++ b/src/components/Tooltip/Tooltip.scss @@ -3,16 +3,36 @@ $block: '.#{variables.$ns}tooltip'; #{$block} { - --g-popup-border-width: 0; - --g-popup-background-color: var(--g-color-base-float-heavy); + // [class] for increasing specificity + &[class] { + --g-popup-border-width: 0; - &__popup-content { - // prevent glitch between two nearby tooltip refs - pointer-events: none; + > 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: unset; + animation-timing-function: unset; + animation-fill-mode: unset; + } } &__content { - padding: 6px 12px; - color: var(--g-color-text-light-primary); + // -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-line-clamp: 20; + -moz-line-clamp: 20; + -ms-line-clamp: 20; + + overflow: hidden; + text-overflow: ellipsis; } } diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index ce62e2504b..2f0b8a2bd0 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import {KeyCode} from '../../constants'; import {useForkRef} from '../../hooks'; -import {useBoolean} from '../../hooks/private'; +import {type TooltipDelayProps, useTooltipVisible} from '../../hooks/private'; import {Popup} from '../Popup'; import type {PopupPlacement} from '../Popup'; +import {Text} from '../Text'; import type {DOMProps, QAProps} from '../types'; import {block} from '../utils/cn'; @@ -20,36 +20,53 @@ export interface TooltipProps extends QAProps, DOMProps, TooltipDelayProps { disablePortal?: boolean; } -interface TooltipDelayProps { - openDelay?: number; - 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} = props; + const { + children, + content, + disabled, + placement = DEFAULT_PLACEMENT, + qa, + id, + className, + style, + disablePortal, + contentClassName, + openDelay = 1000, + closeDelay, + } = props; + const [anchorElement, setAnchorElement] = React.useState(null); - const tooltipVisible = useTooltipVisible(anchorElement, props); + const tooltipVisible = useTooltipVisible(anchorElement, { + openDelay, + closeDelay, + preventTriggerOnFocus: true, + }); const renderPopup = () => { return ( -
{content}
+
+ + {content} + +
); }; @@ -66,69 +83,3 @@ export const Tooltip = (props: TooltipProps) => {
); }; - -function useTooltipVisible( - anchor: HTMLElement | null, - {openDelay = 250, closeDelay}: TooltipDelayProps, -) { - const [tooltipVisible, showTooltip, hideTooltip] = useBoolean(false); - const timeoutRef = React.useRef(); - const isFocusWithinRef = React.useRef(false); - - React.useEffect(() => { - if (!anchor) { - return undefined; - } - - function handleMouseEnter() { - clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(showTooltip, openDelay); - } - - function handleMouseLeave() { - clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(hideTooltip, closeDelay); - } - - function handleFocusWithin(e: FocusEvent) { - if (!isFocusWithinRef.current && document.activeElement === e.target) { - isFocusWithinRef.current = true; - clearTimeout(timeoutRef.current); - showTooltip(); - } - } - - function handleBlurWithin(e: FocusEvent) { - if ( - isFocusWithinRef.current && - !(e.currentTarget as Element).contains(e.relatedTarget as Element) - ) { - isFocusWithinRef.current = false; - clearTimeout(timeoutRef.current); - hideTooltip(); - } - } - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === KeyCode.ESCAPE) { - clearTimeout(timeoutRef.current); - hideTooltip(); - } - } - - anchor.addEventListener('mouseenter', handleMouseEnter); - anchor.addEventListener('mouseleave', handleMouseLeave); - anchor.addEventListener('focus', handleFocusWithin); - anchor.addEventListener('blur', handleBlurWithin); - anchor.addEventListener('keydown', handleKeyDown); - return () => { - anchor.removeEventListener('mouseenter', handleMouseEnter); - anchor.removeEventListener('mouseleave', handleMouseLeave); - anchor.removeEventListener('focus', handleFocusWithin); - anchor.removeEventListener('blur', handleBlurWithin); - anchor.removeEventListener('keydown', handleKeyDown); - }; - }, [anchor, showTooltip, hideTooltip, openDelay, closeDelay]); - - return tooltipVisible; -} diff --git a/src/components/Tooltip/__stories__/Tooltip.stories.tsx b/src/components/Tooltip/__stories__/Tooltip.stories.tsx index 42ef00d7ad..58d2017d95 100644 --- a/src/components/Tooltip/__stories__/Tooltip.stories.tsx +++ b/src/components/Tooltip/__stories__/Tooltip.stories.tsx @@ -18,9 +18,7 @@ export const Default: Story = { render: (args) => { return ( - + ); }, diff --git a/src/components/Tooltip/__tests__/Tooltip.test.tsx b/src/components/Tooltip/__tests__/Tooltip.test.tsx index 4b7846b78b..68e83b4155 100644 --- a/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -49,7 +49,7 @@ test('should show tooltip on hover and hide on un hover', async () => { expect(tooltip).not.toBeInTheDocument(); }); -test('should show tooltip on focus and hide on blur', async () => { +test('should not show tooltip on focus', async () => { const user = userEvent.setup(); render( @@ -62,15 +62,8 @@ test('should show tooltip on focus and hide on blur', async () => { await user.tab(); expect(button).toHaveFocus(); - const tooltip = await screen.findByRole('tooltip'); - - expect(tooltip).toBeVisible(); - - await user.tab(); + const tooltip = screen.queryByRole('tooltip'); - fireAnimationEndEvent(tooltip); - - expect(button).not.toHaveFocus(); expect(tooltip).not.toBeInTheDocument(); }); @@ -85,7 +78,7 @@ test('should hide on press Escape', async () => { const button = await screen.findByRole('button'); await user.tab(); - expect(button).toHaveFocus(); + await user.hover(button); const tooltip = await screen.findByRole('tooltip'); @@ -98,31 +91,3 @@ test('should hide on press Escape', async () => { expect(button).toHaveFocus(); expect(tooltip).not.toBeInTheDocument(); }); - -test('should show on focus and hide on un hover', async () => { - const user = userEvent.setup(); - render( - -