mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
51c15f3a89
commit
7b3221d494
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user