From e8c131eb6f77a36b8f7d07df4e4fd725999af502 Mon Sep 17 00:00:00 2001 From: Yahima Duarte Date: Thu, 9 Mar 2023 16:12:38 +0100 Subject: [PATCH] GrafanaUI: Implement new component Toggletip (#64459) --- .../src/components/Toggletip/Toggletip.mdx | 81 ++++++++ .../components/Toggletip/Toggletip.story.tsx | 178 ++++++++++++++++++ .../components/Toggletip/Toggletip.test.tsx | 90 +++++++++ .../src/components/Toggletip/Toggletip.tsx | 144 ++++++++++++++ .../src/components/Toggletip/index.ts | 2 + .../src/components/Toggletip/types.ts | 9 + .../src/components/Tooltip/Tooltip.tsx | 165 ++-------------- packages/grafana-ui/src/utils/tooltipUtils.ts | 167 ++++++++++++++++ 8 files changed, 689 insertions(+), 147 deletions(-) create mode 100644 packages/grafana-ui/src/components/Toggletip/Toggletip.mdx create mode 100644 packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx create mode 100644 packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx create mode 100644 packages/grafana-ui/src/components/Toggletip/Toggletip.tsx create mode 100644 packages/grafana-ui/src/components/Toggletip/index.ts create mode 100644 packages/grafana-ui/src/components/Toggletip/types.ts create mode 100644 packages/grafana-ui/src/utils/tooltipUtils.ts diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx b/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx new file mode 100644 index 00000000000..da6bdbdbfa3 --- /dev/null +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.mdx @@ -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 ( + + + +); +``` + + diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx new file mode 100644 index 00000000000..326e00b232b --- /dev/null +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.story.tsx @@ -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 = { + 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 = ({ + title, + content, + footer, + theme, + closeButton, + placement, + ...args +}) => { + return ( + + + + ); +}; +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 = ({ theme, closeButton, placement }) => { + const selectOptions: Array> = [ + { label: 'Sharilyn Markowitz', value: 1 }, + { label: 'Naomi Striplin', value: 2 }, + { label: 'Beau Bevel', value: 3 }, + { label: 'Garrett Starkes', value: 4 }, + ]; + const dropdownOptions: Array> = [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + { label: 'Option C', value: 'c' }, + ]; + const header = ( +
+ +  Header title with icon +
+ ); + const body = ( +
+ + + + + {}} + style={{ width: '160px' }} + > + +
+
+ Wants to know more?  + + +  Click here! + +
+
+ ); + const footer = ( +
+ +   + +
+ ); + + return ( + + + + ); +}; + +HostingMultiElements.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'onClose', 'children'], + }, +}; +HostingMultiElements.args = { + placement: 'top', + closeButton: true, + theme: 'info', +}; + +export default meta; diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx new file mode 100644 index 00000000000..bc54d8579bb --- /dev/null +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.test.tsx @@ -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( + + + + ); + 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( + + + + ); + 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( + + + + ); + 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( + + + + ); + + 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()); + }); +}); diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx new file mode 100644 index 00000000000..fd5c50de7d6 --- /dev/null +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -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 && ( + +
+ {Boolean(title) &&
{title}
} + {closeButton && ( +
+ +
+ )} +
+
+ {(typeof content === 'string' || React.isValidElement(content)) && content} + {typeof content === 'function' && update && content({ update })} +
+ {Boolean(footer) &&
{footer}
} +
+ + )} + + ); + } +); + +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, + }; +}; diff --git a/packages/grafana-ui/src/components/Toggletip/index.ts b/packages/grafana-ui/src/components/Toggletip/index.ts new file mode 100644 index 00000000000..f8dd556ecb7 --- /dev/null +++ b/packages/grafana-ui/src/components/Toggletip/index.ts @@ -0,0 +1,2 @@ +export { Toggletip, type ToggletipProps } from './Toggletip'; +export type { ToggletipContent, ToggletipContentProps } from './types'; diff --git a/packages/grafana-ui/src/components/Toggletip/types.ts b/packages/grafana-ui/src/components/Toggletip/types.ts new file mode 100644 index 00000000000..4f3332b0810 --- /dev/null +++ b/packages/grafana-ui/src/components/Toggletip/types.ts @@ -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); diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index b5e3a78cab7..8ff8c6fb3b5 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -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 && ( -
-
+
+
{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, }; -} +}; diff --git a/packages/grafana-ui/src/utils/tooltipUtils.ts b/packages/grafana-ui/src/utils/tooltipUtils.ts new file mode 100644 index 00000000000..15a9f125858 --- /dev/null +++ b/packages/grafana-ui/src/utils/tooltipUtils.ts @@ -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)}; + `, + }; +}