mirror of https://github.com/grafana/grafana
GrafanaUI: Implement new component Toggletip (#64459)
parent
0b1ad0a879
commit
e8c131eb6f
@ -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" /> |
||||
<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> |
||||
<a href="https://grafana.com/" target="_blank" rel="noreferrer"> |
||||
<Icon name="link" /> |
||||
Click here! |
||||
</a> |
||||
</div> |
||||
</div> |
||||
); |
||||
const footer = ( |
||||
<div> |
||||
<Button type="button" variant="success" onClick={() => alert('Click on Save!')}> |
||||
Save on footer |
||||
</Button> |
||||
|
||||
<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,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); |
@ -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…
Reference in new issue