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
This commit is contained in:
Josh Hunt
2022-08-01 10:46:29 +01:00
committed by GitHub
parent 1666871d48
commit 96564be396
5 changed files with 220 additions and 10 deletions

View File

@ -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 (
<Button
onClick={copyTextCallback}
icon={showCopySuccess ? 'check' : icon}
variant={showCopySuccess ? 'success' : variant}
aria-label={showCopySuccess ? 'Copied' : undefined}
{...buttonProps}
ref={buttonRef}
>
{children}
</Button>
<>
{showCopySuccess && (
<InlineToast placement="top" referenceElement={buttonRef.current}>
Copied
</InlineToast>
)}
<Button
onClick={copyTextCallback}
icon={icon}
variant={showCopySuccess ? 'success' : variant}
aria-label={showCopySuccess ? 'Copied' : undefined}
{...buttonProps}
className={cx(styles.button, showCopySuccess && styles.successButton)}
ref={buttonRef}
>
{children}
{showCopySuccess && (
<div className={styles.successOverlay}>
<Icon name="check" />
</div>
)}
</Button>
</>
);
}
@ -83,3 +105,24 @@ const copyText = async (text: string, buttonRef: React.MutableRefObject<HTMLButt
input.remove();
}
};
const getStyles = (theme: GrafanaTheme2) => {
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
}),
};
};

View File

@ -0,0 +1,10 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { InlineToast } from './InlineToast';
<Meta title="MDX|InlineToast" component={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
<Props of={InlineToast} />

View File

@ -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<InlineToastProps> = (args) => {
const [el, setEl] = useState<null | HTMLInputElement>(null);
return (
<div>
<InlineToastImpl {...args} referenceElement={el}>
Saved
</InlineToastImpl>
<Input ref={setEl} />
</div>
);
};
InlineToast.args = {
placement: 'right',
suffixIcon: 'check',
};
export const WithAButton: Story<InlineToastProps> = () => {
return (
<ClipboardButton icon="copy" getText={() => 'hello world'}>
Copy surprise
</ClipboardButton>
);
};
WithAButton.parameters = {
controls: { hideNoControlsWarning: true },
};

View File

@ -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<HTMLElement | null>(null);
const popper = usePopper(referenceElement, indicatorElement, { placement });
const styles = useStyles2(getStyles);
const placementStyles = useStyles2(getPlacementStyles);
return (
<Portal>
<div
style={{ display: 'inline-block', ...popper.styles.popper }}
{...popper.attributes.popper}
ref={setIndicatorElement}
>
<span className={cx(styles.root, placementStyles[placement])}>
{children && <span>{children}</span>}
{suffixIcon && <Icon name={suffixIcon} />}
</span>
</div>
</Portal>
);
}
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<InlineToastProps['placement'], string> => {
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`,
}),
};
};

View File

@ -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';