GrafanaUI: Implement new component Toggletip (#64459)

pull/64534/head
Yahima Duarte 2 years ago committed by GitHub
parent 0b1ad0a879
commit e8c131eb6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 81
      packages/grafana-ui/src/components/Toggletip/Toggletip.mdx
  2. 178
      packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx
  3. 90
      packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx
  4. 144
      packages/grafana-ui/src/components/Toggletip/Toggletip.tsx
  5. 2
      packages/grafana-ui/src/components/Toggletip/index.ts
  6. 9
      packages/grafana-ui/src/components/Toggletip/types.ts
  7. 165
      packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
  8. 167
      packages/grafana-ui/src/utils/tooltipUtils.ts

@ -0,0 +1,81 @@
import { Props } from '@storybook/addon-docs/blocks';
import { Toggletip } from './Toggletip';
# Toggletip
Toggletips, similar to Tooltips, provide contextual support for users when needed. They are hidden by default, a UI trigger or text link are clicked to set them to their visible state.
Toggletips, unlike tooltips, are persistent until a user takes action to dismiss them by clicking on the required “X” (close) trigger.
Toggletips are capable of containing varying types of complex content including interactive components, buttons, and dropdowns.
## When to use
- Users need further context to understand or learn a topic
- Links to supporting documentation or content are needed to provide
- When an interactive element must be placed within the popover
- When content needs to persist for consumption until the user dismisses it
## When not to use
- When only a primary label or an auxiliary clarification is needed to be displayed (see: Tooltip)
- Do not house information critical to user’s task completion
- Do not request required information from a user to complete a task or workflow
## Content
Toggletips are able to house various types of content. Below is a potential list:
- Buttons
- Text links
- Dropdowns
- Selects
- Images/gifs/videos
- Various combinations of elements — ex: strings of text with buttons and an image
## Theme
There are currently 2 themes available for the Toggletip.
- Info
- Error
### Info
This is the default theme, usually used in forms to show more information.
### Error
Tooltip with a red background.
## Triggers
- Toggletips display on
- user click of UI trigger
- pressing ENTER or SPACE on a keyboard while the trigger element has focus
- Toggletips dismiss by:
- user click of close icon (x) — optional
- clicking outside of the popover container
- pressing the ESC key
### Usage
```tsx
function onClose() {
// code to execute when the toggletip is closed
}
return (
<Toogletip
content="Toggletip body"
title="This is the title of the Toggletip"
footer="Toggletip footer text"
closeButton={true}
onClose={onClose}
>
<Button type="button">
<Icon name="question-circle" />
</Button>
</Toogletip>
);
```
<Props of={Toggletip} />

@ -0,0 +1,178 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { Button } from '../Button';
import { ButtonSelect } from '../Dropdown/ButtonSelect';
import { InlineField } from '../Forms/InlineField';
import { Icon } from '../Icon/Icon';
import { Input } from '../Input/Input';
import { Select } from '../Select/Select';
import mdx from '../Toggletip/Toggletip.mdx';
import { Toggletip } from './Toggletip';
const meta: ComponentMeta<typeof Toggletip> = {
title: 'Overlays/Toggletip',
component: Toggletip,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
controls: {
exclude: ['onClose', 'children'],
},
},
argTypes: {
title: {
control: {
type: 'text',
},
},
content: {
control: {
type: 'text',
},
},
footer: {
control: {
type: 'text',
},
},
theme: {
control: {
type: 'select',
},
},
closeButton: {
control: {
type: 'boolean',
},
},
placement: {
control: {
type: 'select',
},
},
},
};
export const Basic: ComponentStory<typeof Toggletip> = ({
title,
content,
footer,
theme,
closeButton,
placement,
...args
}) => {
return (
<Toggletip
title={title}
content={content}
footer={footer}
theme={theme}
closeButton={closeButton}
placement={placement}
{...args}
>
<Button>Click to show Toggletip with header and footer!</Button>
</Toggletip>
);
};
Basic.args = {
title: 'Title of the Toggletip',
content: 'This is the content of the Toggletip',
footer: 'Footer of the Toggletip',
placement: 'auto',
closeButton: true,
theme: 'info',
};
export const HostingMultiElements: ComponentStory<typeof Toggletip> = ({ theme, closeButton, placement }) => {
const selectOptions: Array<SelectableValue<number>> = [
{ label: 'Sharilyn Markowitz', value: 1 },
{ label: 'Naomi Striplin', value: 2 },
{ label: 'Beau Bevel', value: 3 },
{ label: 'Garrett Starkes', value: 4 },
];
const dropdownOptions: Array<SelectableValue<string>> = [
{ label: 'Option A', value: 'a' },
{ label: 'Option B', value: 'b' },
{ label: 'Option C', value: 'c' },
];
const header = (
<div>
<Icon name="apps" />
&nbsp;<strong>Header title with icon</strong>
</div>
);
const body = (
<div>
<InlineField label="Users" labelWidth={15}>
<Select width={20} options={selectOptions} value={2} onChange={() => {}} />
</InlineField>
<InlineField label="Job Title" labelWidth={15}>
<Input value={'Professor'} width={20} />
</InlineField>
<InlineField label="My Button Select" labelWidth={15}>
<ButtonSelect
options={dropdownOptions}
value={dropdownOptions[2]}
variant={'primary'}
onChange={() => {}}
style={{ width: '160px' }}
></ButtonSelect>
</InlineField>
<div>
<br />
<span>Wants to know more?</span>&nbsp;
<a href="https://grafana.com/" target="_blank" rel="noreferrer">
<Icon name="link" />
&nbsp;Click here!
</a>
</div>
</div>
);
const footer = (
<div>
<Button type="button" variant="success" onClick={() => alert('Click on Save!')}>
Save on footer
</Button>
&nbsp;
<Button type="button" variant="destructive" onClick={() => alert('Click on Delete!')}>
Delete
</Button>
</div>
);
return (
<Toggletip
title={header}
content={body}
footer={footer}
theme={theme}
closeButton={closeButton}
placement={placement}
>
<Button type="button">Click to show Toggletip hosting multiple components!</Button>
</Toggletip>
);
};
HostingMultiElements.parameters = {
controls: {
hideNoControlsWarning: true,
exclude: ['title', 'content', 'footer', 'onClose', 'children'],
},
};
HostingMultiElements.args = {
placement: 'top',
closeButton: true,
theme: 'info',
};
export default meta;

@ -0,0 +1,90 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Button } from '../Button';
import { Toggletip } from './Toggletip';
describe('Toggletip', () => {
it('should display toogletip after click on "Click me!" button', async () => {
render(
<Toggletip placement="auto" content="Tooltip text">
<Button type="button" data-testid="myButton">
Click me!
</Button>
</Toggletip>
);
expect(screen.getByText('Click me!')).toBeInTheDocument();
const button = screen.getByTestId('myButton');
button.click();
await waitFor(() => expect(screen.getByTestId('toggletip-content')).toBeInTheDocument());
});
it('should close toogletip after click on close button', async () => {
const closeSpy = jest.fn();
render(
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
<Button type="button" data-testid="myButton">
Click me!
</Button>
</Toggletip>
);
const button = screen.getByTestId('myButton');
button.click();
await waitFor(() => expect(screen.getByTestId('toggletip-content')).toBeInTheDocument());
const closeButton = screen.getByTestId('toggletip-header-close');
expect(closeButton).toBeInTheDocument();
closeButton.click();
await waitFor(() => {
expect(closeSpy).toHaveBeenCalledTimes(1);
});
});
it('should close toogletip after press ESC', async () => {
const closeSpy = jest.fn();
render(
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
<Button type="button" data-testid="myButton">
Click me!
</Button>
</Toggletip>
);
const button = screen.getByTestId('myButton');
button.click();
await waitFor(() => expect(screen.getByTestId('toggletip-content')).toBeInTheDocument());
fireEvent.keyDown(global.document, {
code: 'Escape',
key: 'Escape',
keyCode: 27,
});
await waitFor(() => expect(closeSpy).toHaveBeenCalledTimes(1));
});
it('should display the toogletip after press ENTER', async () => {
const closeSpy = jest.fn();
render(
<Toggletip placement="auto" content="Tooltip text" onClose={closeSpy}>
<Button type="button" data-testid="myButton">
Click me!
</Button>
</Toggletip>
);
expect(screen.queryByTestId('toggletip-content')).not.toBeInTheDocument();
// open toggletip with enter
const button = screen.getByTestId('myButton');
button.focus();
userEvent.keyboard('{enter}');
await waitFor(() => expect(screen.getByTestId('toggletip-content')).toBeInTheDocument());
});
});

@ -0,0 +1,144 @@
import { Placement } from '@popperjs/core';
import React, { useCallback, useEffect, useRef } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { buildTooltipTheme } from '../../utils/tooltipUtils';
import { IconButton } from '../IconButton/IconButton';
import { Portal } from '../Portal/Portal';
import { ToggletipContent } from './types';
export interface ToggletipProps {
/** The theme used to display the toggletip */
theme?: 'info' | 'error';
/** The title to be displayed on the header */
title?: JSX.Element | string;
/** determine whether to show or not the close button **/
closeButton?: boolean;
/** Callback function to be called when the toggletip is closed */
onClose?: Function;
/** The preferred placement of the toggletip */
placement?: Placement;
/** The text or component that houses the content of the toggleltip */
content: ToggletipContent;
/** The text or component to be displayed on the toggletip's bottom */
footer?: JSX.Element | string;
/** The UI control users interact with to display toggletips */
children: JSX.Element;
}
export const Toggletip = React.memo(
({
children,
theme = 'info',
placement = 'auto',
content,
title,
closeButton = true,
onClose,
footer,
}: ToggletipProps) => {
const styles = useStyles2(getStyles);
const style = styles[theme];
const contentRef = useRef(null);
const [controlledVisible, setControlledVisible] = React.useState(false);
const closeToggletip = useCallback(() => {
setControlledVisible(false);
onClose?.();
}, [onClose]);
useEffect(() => {
if (controlledVisible) {
const handleKeyDown = (enterKey: KeyboardEvent) => {
if (enterKey.key === 'Escape') {
closeToggletip();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
return;
}, [controlledVisible, closeToggletip]);
const { getArrowProps, getTooltipProps, setTooltipRef, setTriggerRef, visible, update } = usePopperTooltip({
visible: controlledVisible,
placement: placement,
interactive: true,
offset: [0, 8],
trigger: 'click',
onVisibleChange: (value: boolean) => {
setControlledVisible(value);
if (!value) {
onClose?.();
}
},
});
return (
<>
{React.cloneElement(children, {
ref: setTriggerRef,
tabIndex: 0,
})}
{visible && (
<Portal>
<div
data-testid="toggletip-content"
ref={setTooltipRef}
{...getTooltipProps({ className: style.container })}
>
{Boolean(title) && <div className={style.header}>{title}</div>}
{closeButton && (
<div className={style.headerClose}>
<IconButton
aria-label="Close Toggletip"
name="times"
size="md"
data-testid="toggletip-header-close"
onClick={closeToggletip}
/>
</div>
)}
<div ref={contentRef} {...getArrowProps({ className: style.arrow })} />
<div className={style.body}>
{(typeof content === 'string' || React.isValidElement(content)) && content}
{typeof content === 'function' && update && content({ update })}
</div>
{Boolean(footer) && <div className={style.footer}>{footer}</div>}
</div>
</Portal>
)}
</>
);
}
);
Toggletip.displayName = 'Toggletip';
export const getStyles = (theme: GrafanaTheme2) => {
const info = buildTooltipTheme(
theme,
theme.components.tooltip.background,
theme.components.tooltip.background,
theme.components.tooltip.text,
{ topBottom: 3, rightLeft: 3 }
);
const error = buildTooltipTheme(
theme,
theme.colors.error.main,
theme.colors.error.main,
theme.colors.error.contrastText,
{ topBottom: 3, rightLeft: 3 }
);
return {
info,
error,
};
};

@ -0,0 +1,2 @@
export { Toggletip, type ToggletipProps } from './Toggletip';
export type { ToggletipContent, ToggletipContentProps } from './types';

@ -0,0 +1,9 @@
/**
* This API allows popovers to update Popper's position when e.g. popover content changes
* update is delivered to content by react-popper.
*/
export interface ToggletipContentProps {
update?: () => void;
}
export type ToggletipContent = string | React.ReactElement | ((props: ToggletipContentProps) => JSX.Element);

@ -1,10 +1,10 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { buildTooltipTheme } from '../../utils/tooltipUtils';
import { Portal } from '../Portal/Portal';
import { PopoverContent, TooltipPlacement } from './types';
@ -52,7 +52,7 @@ export const Tooltip = React.memo(({ children, theme, interactive, show, placeme
});
const styles = useStyles2(getStyles);
const containerStyle = styles[theme ?? 'info'];
const style = styles[theme ?? 'info'];
return (
<>
@ -62,8 +62,8 @@ export const Tooltip = React.memo(({ children, theme, interactive, show, placeme
})}
{visible && (
<Portal>
<div ref={setTooltipRef} {...getTooltipProps({ className: containerStyle })}>
<div {...getArrowProps({ className: 'tooltip-arrow' })} />
<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' &&
@ -80,154 +80,25 @@ export const Tooltip = React.memo(({ children, theme, interactive, show, placeme
Tooltip.displayName = 'Tooltip';
function getStyles(theme: GrafanaTheme2) {
function buildTooltipTheme(tooltipBg: string, tooltipBorder: string, tooltipText: string) {
return css`
background-color: ${tooltipBg};
border-radius: 3px;
border: 1px solid ${tooltipBorder};
box-shadow: ${theme.shadows.z2};
color: ${tooltipText};
font-size: ${theme.typography.bodySmall.fontSize};
padding: ${theme.spacing(0.5, 1)};
transition: opacity 0.3s;
z-index: ${theme.zIndex.tooltip};
max-width: 400px;
overflow-wrap: break-word;
&[data-popper-interactive='false'] {
pointer-events: none;
}
.tooltip-arrow {
height: 1rem;
position: absolute;
width: 1rem;
pointer-events: none;
}
.tooltip-arrow::before {
border-style: solid;
content: '';
display: block;
height: 0;
margin: auto;
width: 0;
}
.tooltip-arrow::after {
border-style: solid;
content: '';
display: block;
height: 0;
margin: auto;
position: absolute;
width: 0;
}
&[data-popper-placement*='bottom'] .tooltip-arrow {
left: 0;
margin-top: -10px;
top: 0;
}
&[data-popper-placement*='bottom'] .tooltip-arrow::before {
border-color: transparent transparent ${tooltipBorder} transparent;
border-width: 0 8px 7px 8px;
position: absolute;
top: -1px;
}
&[data-popper-placement*='bottom'] .tooltip-arrow::after {
border-color: transparent transparent ${tooltipBg} transparent;
border-width: 0 8px 7px 8px;
}
&[data-popper-placement*='top'] .tooltip-arrow {
bottom: 0;
left: 0;
margin-bottom: -11px;
}
&[data-popper-placement*='top'] .tooltip-arrow::before {
border-color: ${tooltipBorder} transparent transparent transparent;
border-width: 7px 8px 0 7px;
position: absolute;
top: 1px;
}
&[data-popper-placement*='top'] .tooltip-arrow::after {
border-color: ${tooltipBg} transparent transparent transparent;
border-width: 7px 8px 0 7px;
}
&[data-popper-placement*='right'] .tooltip-arrow {
left: 0;
margin-left: -11px;
}
&[data-popper-placement*='right'] .tooltip-arrow::before {
border-color: transparent ${tooltipBorder} transparent transparent;
border-width: 7px 6px 7px 0;
}
&[data-popper-placement*='right'] .tooltip-arrow::after {
border-color: transparent ${tooltipBg} transparent transparent;
border-width: 6px 7px 7px 0;
left: 2px;
top: 1px;
}
&[data-popper-placement*='left'] .tooltip-arrow {
margin-right: -10px;
right: 0;
}
&[data-popper-placement*='left'] .tooltip-arrow::before {
border-color: transparent transparent transparent ${tooltipBorder};
border-width: 7px 0px 6px 7px;
}
&[data-popper-placement*='left'] .tooltip-arrow::after {
border-color: transparent transparent transparent ${tooltipBg};
border-width: 6px 0 5px 5px;
left: 1px;
top: 1px;
}
code {
border: none;
display: inline;
background: ${colorManipulator.darken(tooltipBg, 0.1)};
color: ${tooltipText};
}
pre {
background: ${colorManipulator.darken(tooltipBg, 0.1)};
color: ${tooltipText};
}
a {
color: ${tooltipText};
text-decoration: underline;
}
a:hover {
text-decoration: none;
}
`;
}
export const getStyles = (theme: GrafanaTheme2) => {
const info = buildTooltipTheme(
theme,
theme.components.tooltip.background,
theme.components.tooltip.background,
theme.components.tooltip.text
theme.components.tooltip.text,
{ topBottom: 0.5, rightLeft: 1 }
);
const error = buildTooltipTheme(
theme,
theme.colors.error.main,
theme.colors.error.main,
theme.colors.error.contrastText,
{ topBottom: 0.5, rightLeft: 1 }
);
const error = buildTooltipTheme(theme.colors.error.main, theme.colors.error.main, theme.colors.error.contrastText);
return {
info: info,
info,
['info-alt']: info,
error,
};
}
};

@ -0,0 +1,167 @@
import { css } from '@emotion/css';
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
export function buildTooltipTheme(
theme: GrafanaTheme2,
tooltipBg: string,
toggletipBorder: string,
tooltipText: string,
tooltipPadding: { topBottom: number; rightLeft: number }
) {
return {
arrow: css`
height: 1rem;
width: 1rem;
position: absolute;
pointer-events: none;
&::before {
border-style: solid;
content: '';
display: block;
height: 0;
margin: auto;
width: 0;
}
&::after {
border-style: solid;
content: '';
display: block;
height: 0;
margin: auto;
position: absolute;
width: 0;
}
`,
container: css`
background-color: ${tooltipBg};
border-radius: 3px;
border: 1px solid ${toggletipBorder};
box-shadow: ${theme.shadows.z2};
color: ${tooltipText};
font-size: ${theme.typography.bodySmall.fontSize};
padding: ${theme.spacing(tooltipPadding.topBottom, tooltipPadding.rightLeft)};
transition: opacity 0.3s;
z-index: ${theme.zIndex.tooltip};
max-width: 400px;
overflow-wrap: break-word;
&[data-popper-interactive='false'] {
pointer-events: none;
}
&[data-popper-placement*='bottom'] > div[data-popper-arrow='true'] {
left: 0;
margin-top: -7px;
top: 0;
&::before {
border-color: transparent transparent ${toggletipBorder} transparent;
border-width: 0 8px 7px 8px;
position: absolute;
top: -1px;
}
&::after {
border-color: transparent transparent ${tooltipBg} transparent;
border-width: 0 8px 7px 8px;
}
}
&[data-popper-placement*='top'] > div[data-popper-arrow='true'] {
bottom: 0;
left: 0;
margin-bottom: -14px;
&::before {
border-color: ${toggletipBorder} transparent transparent transparent;
border-width: 7px 8px 0 7px;
position: absolute;
top: 1px;
}
&::after {
border-color: ${tooltipBg} transparent transparent transparent;
border-width: 7px 8px 0 7px;
}
}
&[data-popper-placement*='right'] > div[data-popper-arrow='true'] {
left: 0;
margin-left: -10px;
&::before {
border-color: transparent ${toggletipBorder} transparent transparent;
border-width: 7px 6px 7px 0;
}
&::after {
border-color: transparent ${tooltipBg} transparent transparent;
border-width: 6px 7px 7px 0;
left: 2px;
top: 1px;
}
}
&[data-popper-placement*='left'] > div[data-popper-arrow='true'] {
margin-right: -11px;
right: 0;
&::before {
border-color: transparent transparent transparent ${toggletipBorder};
border-width: 7px 0 6px 7px;
}
&::after {
border-color: transparent transparent transparent ${tooltipBg};
border-width: 6px 0 5px 5px;
left: 1px;
top: 1px;
}
}
code {
border: none;
display: inline;
background: ${colorManipulator.darken(tooltipBg, 0.1)};
color: ${tooltipText};
}
pre {
background: ${colorManipulator.darken(tooltipBg, 0.1)};
color: ${tooltipText};
}
a {
color: ${tooltipText};
text-decoration: underline;
}
a:hover {
text-decoration: none;
}
`,
headerClose: css`
color: ${theme.colors.text.secondary};
position: absolute;
right: ${theme.spacing(1)};
top: ${theme.spacing(1.5)};
background-color: transparent;
border: 0;
`,
header: css`
padding-top: ${theme.spacing(1)};
padding-bottom: ${theme.spacing(2)};
`,
body: css`
padding-top: ${theme.spacing(1)};
padding-bottom: ${theme.spacing(1)};
`,
footer: css`
padding-top: ${theme.spacing(2)};
padding-bottom: ${theme.spacing(1)};
`,
};
}
Loading…
Cancel
Save