diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index a90e029e14e..7a7631ea625 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -61,8 +61,9 @@ export const Button = React.forwardRef( }); // In order to standardise Button please always consider using IconButton when you need a button with an icon only + // When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632 const button = ( - @@ -70,7 +71,7 @@ export const Button = React.forwardRef( if (tooltip) { return ( - + {button} ); @@ -123,8 +124,9 @@ export const LinkButton = React.forwardRef( className ); + // When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632 const button = ( - + {icon && } {children && {children}} @@ -132,7 +134,7 @@ export const LinkButton = React.forwardRef( if (tooltip) { return ( - + {button} ); diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.story.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.story.tsx index 46e7079ccbe..44f4794688f 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.story.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.story.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { StoryExample } from '../../utils/storybook/StoryExample'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { Button } from '../Button'; +import { IconButton } from '../IconButton/IconButton'; import { VerticalGroup } from '../Layout/Layout'; import { Menu } from '../Menu/Menu'; @@ -41,9 +42,10 @@ export function Examples() { + - + + ); + + const button = screen.getByRole('button', { name: 'Open me' }); + + await userEvent.hover(button); + expect(await screen.findByText('Tooltip content')).toBeVisible(); // tooltip appears on a delay + + await userEvent.click(button); + expect(screen.queryByText('View settings')).toBeVisible(); + }); +}); diff --git a/packages/grafana-ui/src/components/IconButton/IconButton.tsx b/packages/grafana-ui/src/components/IconButton/IconButton.tsx index a4c1b846c60..9c508075ebf 100644 --- a/packages/grafana-ui/src/components/IconButton/IconButton.tsx +++ b/packages/grafana-ui/src/components/IconButton/IconButton.tsx @@ -61,15 +61,21 @@ export const IconButton = React.forwardRef( const styles = getStyles(theme, limitedIconSize, variant); const tooltipString = typeof tooltip === 'string' ? tooltip : ''; + // When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632 const button = ( - ); if (tooltip) { return ( - + {button} ); diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx index 68dbc689ce0..bfd5b5b77b0 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import React from 'react'; +import React, { MutableRefObject } from 'react'; import { Tooltip } from './Tooltip'; @@ -14,4 +14,28 @@ describe('Tooltip', () => { ); expect(screen.getByText('Link with tooltip')).toBeInTheDocument(); }); + + it('forwards the function ref', () => { + const refFn = jest.fn(); + + render( + + On the page + + ); + + expect(refFn).toBeCalled(); + }); + + it('forwards the mutable ref', () => { + const refObj: MutableRefObject = { current: null }; + + render( + + On the page + + ); + + expect(refObj.current).not.toBeNull(); + }); }); diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 8ff8c6fb3b5..4169a1ad8a2 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { usePopperTooltip } from 'react-popper-tooltip'; import { GrafanaTheme2 } from '@grafana/data'; @@ -21,62 +21,77 @@ export interface TooltipProps { interactive?: boolean; } -export const Tooltip = React.memo(({ children, theme, interactive, show, placement, content }: TooltipProps) => { - const [controlledVisible, setControlledVisible] = React.useState(show); +export const Tooltip = React.forwardRef( + ({ children, theme, interactive, show, placement, content }, forwardedRef) => { + const [controlledVisible, setControlledVisible] = React.useState(show); - useEffect(() => { - if (controlledVisible !== false) { - const handleKeyDown = (enterKey: KeyboardEvent) => { - if (enterKey.key === 'Escape') { - setControlledVisible(false); + useEffect(() => { + if (controlledVisible !== false) { + const handleKeyDown = (enterKey: KeyboardEvent) => { + if (enterKey.key === 'Escape') { + setControlledVisible(false); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } else { + return; + } + }, [controlledVisible]); + + const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({ + visible: controlledVisible, + placement: placement, + interactive: interactive, + delayHide: interactive ? 100 : 0, + delayShow: 150, + offset: [0, 8], + trigger: ['hover', 'focus'], + onVisibleChange: setControlledVisible, + }); + + const styles = useStyles2(getStyles); + const style = styles[theme ?? 'info']; + + const handleRef = useCallback( + (ref: HTMLElement | null) => { + setTriggerRef(ref); + + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef) { + forwardedRef.current = ref; } - }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - } else { - return; - } - }, [controlledVisible]); + }, + [forwardedRef, setTriggerRef] + ); - const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({ - visible: controlledVisible, - placement: placement, - interactive: interactive, - delayHide: interactive ? 100 : 0, - delayShow: 150, - offset: [0, 8], - trigger: ['hover', 'focus'], - onVisibleChange: setControlledVisible, - }); - - const styles = useStyles2(getStyles); - const style = styles[theme ?? 'info']; - - return ( - <> - {React.cloneElement(children, { - ref: setTriggerRef, - tabIndex: 0, // tooltip should be keyboard focusable - })} - {visible && ( - -
-
- {typeof content === 'string' && content} - {React.isValidElement(content) && React.cloneElement(content)} - {typeof content === 'function' && - update && - content({ - updatePopperPosition: update, - })} -
- - )} - - ); -}); + return ( + <> + {React.cloneElement(children, { + ref: handleRef, + tabIndex: 0, // tooltip should be keyboard focusable + })} + {visible && ( + +
+
+ {typeof content === 'string' && content} + {React.isValidElement(content) && React.cloneElement(content)} + {typeof content === 'function' && + update && + content({ + updatePopperPosition: update, + })} +
+ + )} + + ); + } +); Tooltip.displayName = 'Tooltip';