GrafanaUI: Support Tooltip as Dropdown child (#68521)

Co-authored-by: eledobleefe <laura.fernandez@grafana.com>
Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
Co-authored-by: L-M-K-B <48948963+L-M-K-B@users.noreply.github.com
This commit is contained in:
Josh Hunt 2023-05-16 11:15:53 +01:00 committed by GitHub
parent 51c15f3a89
commit 7b3221d494
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 143 additions and 62 deletions

View File

@ -61,8 +61,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
});
// 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 = (
<button className={cx(styles.button, className)} type={type} {...otherProps} ref={ref}>
<button className={cx(styles.button, className)} type={type} {...otherProps} ref={tooltip ? undefined : ref}>
{icon && <Icon name={icon} size={size} className={styles.icon} />}
{children && <span className={styles.content}>{children}</span>}
</button>
@ -70,7 +71,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
if (tooltip) {
return (
<Tooltip content={tooltip} placement={tooltipPlacement}>
<Tooltip ref={ref} content={tooltip} placement={tooltipPlacement}>
{button}
</Tooltip>
);
@ -123,8 +124,9 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
className
);
// When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632
const button = (
<a className={linkButtonStyles} {...otherProps} tabIndex={disabled ? -1 : 0} ref={ref}>
<a className={linkButtonStyles} {...otherProps} tabIndex={disabled ? -1 : 0} ref={tooltip ? undefined : ref}>
{icon && <Icon name={icon} size={size} className={styles.icon} />}
{children && <span className={styles.content}>{children}</span>}
</a>
@ -132,7 +134,7 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
if (tooltip) {
return (
<Tooltip content={tooltip} placement={tooltipPlacement}>
<Tooltip ref={ref} content={tooltip} placement={tooltipPlacement}>
{button}
</Tooltip>
);

View File

@ -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() {
<Button variant="secondary">Button</Button>
</Dropdown>
</StoryExample>
<StoryExample name="Icon button, placement=bottom-start">
<Dropdown overlay={menu} placement="bottom-start">
<Button variant="secondary" icon="bars" />
<IconButton tooltip="Open menu" variant="secondary" name="bars" />
</Dropdown>
</StoryExample>
</VerticalGroup>

View File

@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Button } from '../Button';
import { Menu } from '../Menu/Menu';
import { Dropdown } from './Dropdown';
describe('Dropdown', () => {
it('supports buttons with tooltips', async () => {
const menu = (
<Menu>
<Menu.Item label="View settings" />
</Menu>
);
render(
<Dropdown overlay={menu}>
<Button tooltip="Tooltip content">Open me</Button>
</Dropdown>
);
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();
});
});

View File

@ -61,15 +61,21 @@ export const IconButton = React.forwardRef<HTMLButtonElement, Props>(
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 = (
<button ref={ref} aria-label={ariaLabel || tooltipString} {...restProps} className={cx(styles.button, className)}>
<button
ref={tooltip ? undefined : ref}
aria-label={ariaLabel || tooltipString}
{...restProps}
className={cx(styles.button, className)}
>
<Icon name={name} size={limitedIconSize} className={styles.icon} type={iconType} />
</button>
);
if (tooltip) {
return (
<Tooltip content={tooltip} placement={tooltipPlacement}>
<Tooltip ref={ref} content={tooltip} placement={tooltipPlacement}>
{button}
</Tooltip>
);

View File

@ -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(
<Tooltip content="Cooltip content" ref={refFn}>
<span>On the page</span>
</Tooltip>
);
expect(refFn).toBeCalled();
});
it('forwards the mutable ref', () => {
const refObj: MutableRefObject<HTMLElement | null> = { current: null };
render(
<Tooltip content="Cooltip content" ref={refObj}>
<span>On the page</span>
</Tooltip>
);
expect(refObj.current).not.toBeNull();
});
});

View File

@ -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<HTMLElement, TooltipProps>(
({ 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 && (
<Portal>
<div ref={setTooltipRef} {...getTooltipProps({ className: style.container })}>
<div {...getArrowProps({ className: style.arrow })} />
{typeof content === 'string' && content}
{React.isValidElement(content) && React.cloneElement(content)}
{typeof content === 'function' &&
update &&
content({
updatePopperPosition: update,
})}
</div>
</Portal>
)}
</>
);
});
return (
<>
{React.cloneElement(children, {
ref: handleRef,
tabIndex: 0, // tooltip should be keyboard focusable
})}
{visible && (
<Portal>
<div ref={setTooltipRef} {...getTooltipProps({ className: style.container })}>
<div {...getArrowProps({ className: style.arrow })} />
{typeof content === 'string' && content}
{React.isValidElement(content) && React.cloneElement(content)}
{typeof content === 'function' &&
update &&
content({
updatePopperPosition: update,
})}
</div>
</Portal>
)}
</>
);
}
);
Tooltip.displayName = 'Tooltip';