From 96564be396396fc11d595fa3eb80a7d60be69829 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 1 Aug 2022 10:46:29 +0100 Subject: [PATCH] GrafanaUI: Improved ClipboardButton success state (#52665) * Add new pill-shaped Indicator component * Use indicator for clipboard button success * show just check icon during success state * expose Indicator * move animation and positioning into Indicator component * update stories * update stories --- .../ClipboardButton/ClipboardButton.tsx | 63 ++++++++++-- .../components/InlineToast/InlineToast.mdx | 10 ++ .../InlineToast.story.internal.tsx | 59 +++++++++++ .../components/InlineToast/InlineToast.tsx | 97 +++++++++++++++++++ packages/grafana-ui/src/components/index.ts | 1 + 5 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 packages/grafana-ui/src/components/InlineToast/InlineToast.mdx create mode 100644 packages/grafana-ui/src/components/InlineToast/InlineToast.story.internal.tsx create mode 100644 packages/grafana-ui/src/components/InlineToast/InlineToast.tsx diff --git a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx index b5eabd32d70..5e021d9e330 100644 --- a/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx +++ b/packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.tsx @@ -1,6 +1,12 @@ +import { css, cx } from '@emotion/css'; import React, { useCallback, useRef, useState, useEffect } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; import { Button, ButtonProps } from '../Button'; +import { Icon } from '../Icon/Icon'; +import { InlineToast } from '../InlineToast/InlineToast'; export interface Props extends ButtonProps { /** A function that returns text to be copied */ @@ -22,6 +28,7 @@ export function ClipboardButton({ variant, ...buttonProps }: Props) { + const styles = useStyles2(getStyles); const [showCopySuccess, setShowCopySuccess] = useState(false); useEffect(() => { @@ -52,16 +59,31 @@ export function ClipboardButton({ }, [getText, onClipboardCopy, onClipboardError]); return ( - + <> + {showCopySuccess && ( + + Copied + + )} + + + ); } @@ -83,3 +105,24 @@ const copyText = async (text: string, buttonRef: React.MutableRefObject { + return { + button: css({ + position: 'relative', + }), + successButton: css({ + '> *': css({ + visibility: 'hidden', + }), + }), + successOverlay: css({ + position: 'absolute', + top: 0, + bottom: 0, + right: 0, + left: 0, + visibility: 'visible', // re-visible the overlay + }), + }; +}; diff --git a/packages/grafana-ui/src/components/InlineToast/InlineToast.mdx b/packages/grafana-ui/src/components/InlineToast/InlineToast.mdx new file mode 100644 index 00000000000..3bd02c1c0cb --- /dev/null +++ b/packages/grafana-ui/src/components/InlineToast/InlineToast.mdx @@ -0,0 +1,10 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { InlineToast } from './InlineToast'; + + + +# InlineToast + +Used to indicate temporal status near fields/components, such as a _Saved_ indicator next to a field, or a little _Copied!_ indicator above a button + + diff --git a/packages/grafana-ui/src/components/InlineToast/InlineToast.story.internal.tsx b/packages/grafana-ui/src/components/InlineToast/InlineToast.story.internal.tsx new file mode 100644 index 00000000000..8d7efcd6777 --- /dev/null +++ b/packages/grafana-ui/src/components/InlineToast/InlineToast.story.internal.tsx @@ -0,0 +1,59 @@ +import { Meta, Story } from '@storybook/react'; +import React, { useState } from 'react'; + +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { ClipboardButton } from '../ClipboardButton/ClipboardButton'; +import { Input } from '../Input/Input'; + +import { InlineToast as InlineToastImpl, InlineToastProps } from './InlineToast'; +import mdx from './InlineToast.mdx'; + +const story: Meta = { + title: 'InlineToast', + component: InlineToastImpl, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, + argTypes: { + // foo is the property we want to remove from the UI + referenceElement: { + table: { + disable: true, + }, + }, + }, +}; + +export default story; + +export const InlineToast: Story = (args) => { + const [el, setEl] = useState(null); + + return ( +
+ + Saved + + +
+ ); +}; +InlineToast.args = { + placement: 'right', + suffixIcon: 'check', +}; + +export const WithAButton: Story = () => { + return ( + 'hello world'}> + Copy surprise + + ); +}; + +WithAButton.parameters = { + controls: { hideNoControlsWarning: true }, +}; diff --git a/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx b/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx new file mode 100644 index 00000000000..d58f110dc50 --- /dev/null +++ b/packages/grafana-ui/src/components/InlineToast/InlineToast.tsx @@ -0,0 +1,97 @@ +import { css, cx, keyframes } from '@emotion/css'; +import { BasePlacement } from '@popperjs/core'; +import React, { useState } from 'react'; +import { usePopper } from 'react-popper'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { IconName } from '../../types'; +import { Icon } from '../Icon/Icon'; +import { Portal } from '../Portal/Portal'; + +export interface InlineToastProps { + children: React.ReactNode; + suffixIcon?: IconName; + referenceElement: HTMLElement | null; + placement: BasePlacement; +} + +export function InlineToast({ referenceElement, children, suffixIcon, placement }: InlineToastProps) { + const [indicatorElement, setIndicatorElement] = useState(null); + const popper = usePopper(referenceElement, indicatorElement, { placement }); + const styles = useStyles2(getStyles); + const placementStyles = useStyles2(getPlacementStyles); + + return ( + +
+ + {children && {children}} + {suffixIcon && } + +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + root: css({ + ...theme.typography.bodySmall, + willChange: 'transform', + background: theme.components.tooltip.background, + color: theme.components.tooltip.text, + padding: theme.spacing(0.5, 1.5), // get's an extra .5 of vertical padding to account for the rounded corners + borderRadius: 100, // just a sufficiently large value to ensure ends are completely rounded + display: 'inline-flex', + gap: theme.spacing(0.5), + alignItems: 'center', + }), + }; +}; + +const createAnimation = (fromX: string | number, fromY: string | number) => + keyframes({ + from: { + opacity: 0, + transform: `translate(${fromX}, ${fromY})`, + }, + + to: { + opacity: 1, + transform: 'translate(0, 0px)', + }, + }); + +const getPlacementStyles = (theme: GrafanaTheme2): Record => { + const gap = 1; + + const placementTopAnimation = createAnimation(0, theme.spacing(gap)); + const placementBottomAnimation = createAnimation(0, theme.spacing(gap * -1)); + const placementLeftAnimation = createAnimation(theme.spacing(gap), 0); + const placementRightAnimation = createAnimation(theme.spacing(gap * -1), 0); + + return { + top: css({ + marginBottom: theme.spacing(gap), + animation: `${placementTopAnimation} ease-out 100ms`, + }), + bottom: css({ + marginTop: theme.spacing(gap), + animation: `${placementBottomAnimation} ease-out 100ms`, + }), + left: css({ + marginRight: theme.spacing(gap), + animation: `${placementLeftAnimation} ease-out 100ms`, + }), + right: css({ + marginLeft: theme.spacing(gap), + animation: `${placementRightAnimation} ease-out 100ms`, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 266ef273a4d..6684a783fa6 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -21,6 +21,7 @@ export { TabbedContainer, TabConfig } from './TabbedContainer/TabbedContainer'; export { ClipboardButton } from './ClipboardButton/ClipboardButton'; export { Cascader, CascaderOption } from './Cascader/Cascader'; export { ButtonCascader } from './ButtonCascader/ButtonCascader'; +export { InlineToast } from './InlineToast/InlineToast'; export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';