mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 12:12:21 +08:00
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:
@ -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
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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} />
|
@ -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 },
|
||||
};
|
@ -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`,
|
||||
}),
|
||||
};
|
||||
};
|
@ -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';
|
||||
|
Reference in New Issue
Block a user