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

View File

@ -4,6 +4,7 @@ import React from 'react';
import { StoryExample } from '../../utils/storybook/StoryExample'; import { StoryExample } from '../../utils/storybook/StoryExample';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { Button } from '../Button'; import { Button } from '../Button';
import { IconButton } from '../IconButton/IconButton';
import { VerticalGroup } from '../Layout/Layout'; import { VerticalGroup } from '../Layout/Layout';
import { Menu } from '../Menu/Menu'; import { Menu } from '../Menu/Menu';
@ -41,9 +42,10 @@ export function Examples() {
<Button variant="secondary">Button</Button> <Button variant="secondary">Button</Button>
</Dropdown> </Dropdown>
</StoryExample> </StoryExample>
<StoryExample name="Icon button, placement=bottom-start"> <StoryExample name="Icon button, placement=bottom-start">
<Dropdown overlay={menu} placement="bottom-start"> <Dropdown overlay={menu} placement="bottom-start">
<Button variant="secondary" icon="bars" /> <IconButton tooltip="Open menu" variant="secondary" name="bars" />
</Dropdown> </Dropdown>
</StoryExample> </StoryExample>
</VerticalGroup> </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 styles = getStyles(theme, limitedIconSize, variant);
const tooltipString = typeof tooltip === 'string' ? tooltip : ''; 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 = ( 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} /> <Icon name={name} size={limitedIconSize} className={styles.icon} type={iconType} />
</button> </button>
); );
if (tooltip) { if (tooltip) {
return ( return (
<Tooltip content={tooltip} placement={tooltipPlacement}> <Tooltip ref={ref} content={tooltip} placement={tooltipPlacement}>
{button} {button}
</Tooltip> </Tooltip>
); );

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import React from 'react'; import React, { MutableRefObject } from 'react';
import { Tooltip } from './Tooltip'; import { Tooltip } from './Tooltip';
@ -14,4 +14,28 @@ describe('Tooltip', () => {
); );
expect(screen.getByText('Link with tooltip')).toBeInTheDocument(); 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 { usePopperTooltip } from 'react-popper-tooltip';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
@ -21,62 +21,77 @@ export interface TooltipProps {
interactive?: boolean; interactive?: boolean;
} }
export const Tooltip = React.memo(({ children, theme, interactive, show, placement, content }: TooltipProps) => { export const Tooltip = React.forwardRef<HTMLElement, TooltipProps>(
const [controlledVisible, setControlledVisible] = React.useState(show); ({ children, theme, interactive, show, placement, content }, forwardedRef) => {
const [controlledVisible, setControlledVisible] = React.useState(show);
useEffect(() => { useEffect(() => {
if (controlledVisible !== false) { if (controlledVisible !== false) {
const handleKeyDown = (enterKey: KeyboardEvent) => { const handleKeyDown = (enterKey: KeyboardEvent) => {
if (enterKey.key === 'Escape') { if (enterKey.key === 'Escape') {
setControlledVisible(false); 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); [forwardedRef, setTriggerRef]
return () => { );
document.removeEventListener('keydown', handleKeyDown);
};
} else {
return;
}
}, [controlledVisible]);
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({ return (
visible: controlledVisible, <>
placement: placement, {React.cloneElement(children, {
interactive: interactive, ref: handleRef,
delayHide: interactive ? 100 : 0, tabIndex: 0, // tooltip should be keyboard focusable
delayShow: 150, })}
offset: [0, 8], {visible && (
trigger: ['hover', 'focus'], <Portal>
onVisibleChange: setControlledVisible, <div ref={setTooltipRef} {...getTooltipProps({ className: style.container })}>
}); <div {...getArrowProps({ className: style.arrow })} />
{typeof content === 'string' && content}
const styles = useStyles2(getStyles); {React.isValidElement(content) && React.cloneElement(content)}
const style = styles[theme ?? 'info']; {typeof content === 'function' &&
update &&
return ( content({
<> updatePopperPosition: update,
{React.cloneElement(children, { })}
ref: setTriggerRef, </div>
tabIndex: 0, // tooltip should be keyboard focusable </Portal>
})} )}
{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'; Tooltip.displayName = 'Tooltip';