Commit c9e4feda by Dominik Prokop Committed by GitHub

Feat: Introduce Button and LinkButton components to @grafana/ui (#16228)

- Bumped Storybook to v5
- Introduced Emotion
- Add additional config for storybook (combinations add-on, default padding in preview pane)
- Added basic react based button components
- Introduced AbstractButton, Button and LinkButton components together with stories
- Exposed button components from @grafana/ui
parent 232b7fd6
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
"@babel/preset-env": "^7.3.4", "@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.3.3", "@babel/preset-typescript": "^7.3.3",
"@emotion/core": "^10.0.10",
"@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1", "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
"@types/angular": "^1.6.6", "@types/angular": "^1.6.6",
"@types/chalk": "^2.2.0", "@types/chalk": "^2.2.0",
......
import { configure, addDecorator } from '@storybook/react'; import { configure, addDecorator } from '@storybook/react';
import { withKnobs } from '@storybook/addon-knobs'; import { withKnobs } from '@storybook/addon-knobs';
import { withTheme } from '../src/utils/storybook/withTheme'; import { withTheme } from '../src/utils/storybook/withTheme';
import { withPaddedStory } from '../src/utils/storybook/withPaddedStory';
// @ts-ignore // @ts-ignore
import lightTheme from '../../../public/sass/grafana.light.scss'; import lightTheme from '../../../public/sass/grafana.light.scss';
...@@ -20,6 +21,7 @@ const handleThemeChange = (theme: string) => { ...@@ -20,6 +21,7 @@ const handleThemeChange = (theme: string) => {
const req = require.context('../src/components', true, /.story.tsx$/); const req = require.context('../src/components', true, /.story.tsx$/);
addDecorator(withKnobs); addDecorator(withKnobs);
addDecorator(withPaddedStory);
addDecorator(withTheme(handleThemeChange)); addDecorator(withTheme(handleThemeChange));
function loadStories() { function loadStories() {
......
const path = require('path'); const path = require('path');
module.exports = (baseConfig, env, config) => { module.exports = ({config, mode}) => {
config.module.rules.push({ config.module.rules.push({
test: /\.(ts|tsx)$/, test: /\.(ts|tsx)$/,
use: [ use: [
{ {
loader: require.resolve('awesome-typescript-loader'), loader: require.resolve('awesome-typescript-loader'),
options: {
configFileName: path.resolve(__dirname+'/../tsconfig.json')
}
}, },
], ],
}); });
...@@ -56,9 +59,5 @@ module.exports = (baseConfig, env, config) => { ...@@ -56,9 +59,5 @@ module.exports = (baseConfig, env, config) => {
}); });
config.resolve.extensions.push('.ts', '.tsx'); config.resolve.extensions.push('.ts', '.tsx');
// Remove pure js loading rules as Storybook's Babel config is causing problems when mixing ES6 and CJS
// More about the problem we encounter: https://github.com/webpack/webpack/issues/4039
config.module.rules = config.module.rules.filter(rule => rule.test.toString() !== /\.(mjs|jsx?)$/.toString());
return config; return config;
}; };
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
"react-dom": "^16.8.4", "react-dom": "^16.8.4",
"react-highlight-words": "0.11.0", "react-highlight-words": "0.11.0",
"react-popper": "^1.3.0", "react-popper": "^1.3.0",
"react-storybook-addon-props-combinations": "^1.1.0",
"react-transition-group": "^2.2.1", "react-transition-group": "^2.2.1",
"react-virtualized": "^9.21.0", "react-virtualized": "^9.21.0",
"tether": "^1.4.0", "tether": "^1.4.0",
...@@ -39,10 +40,11 @@ ...@@ -39,10 +40,11 @@
"tinycolor2": "^1.4.1" "tinycolor2": "^1.4.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "^4.1.7", "@storybook/addon-actions": "^5.0.5",
"@storybook/addon-info": "^4.1.6", "@storybook/addon-info": "^5.0.5",
"@storybook/addon-knobs": "^4.1.7", "@storybook/addon-knobs": "^5.0.5",
"@storybook/react": "^4.1.4", "@storybook/react": "^5.0.5",
"@storybook/theming": "^5.0.5",
"@types/classnames": "^2.2.6", "@types/classnames": "^2.2.6",
"@types/d3": "^5.7.0", "@types/d3": "^5.7.0",
"@types/jest": "^23.3.2", "@types/jest": "^23.3.2",
...@@ -50,17 +52,19 @@ ...@@ -50,17 +52,19 @@
"@types/lodash": "^4.14.119", "@types/lodash": "^4.14.119",
"@types/node": "^10.12.18", "@types/node": "^10.12.18",
"@types/papaparse": "^4.5.9", "@types/papaparse": "^4.5.9",
"@types/pretty-format": "^20.0.1",
"@types/react": "^16.8.8", "@types/react": "^16.8.8",
"@types/react-custom-scrollbars": "^4.0.5", "@types/react-custom-scrollbars": "^4.0.5",
"@types/react-test-renderer": "^16.0.3", "@types/react-test-renderer": "^16.0.3",
"@types/react-transition-group": "^2.0.15", "@types/react-transition-group": "^2.0.15",
"@types/storybook__addon-actions": "^3.4.1", "@types/storybook__addon-actions": "^3.4.1",
"@types/storybook__addon-info": "^3.4.2", "@types/storybook__addon-info": "^4.1.1",
"@types/storybook__addon-knobs": "^4.0.0", "@types/storybook__addon-knobs": "^4.0.0",
"@types/storybook__react": "^4.0.0", "@types/storybook__react": "^4.0.0",
"@types/tether-drop": "^1.4.8", "@types/tether-drop": "^1.4.8",
"@types/tinycolor2": "^1.4.1", "@types/tinycolor2": "^1.4.1",
"awesome-typescript-loader": "^5.2.1", "awesome-typescript-loader": "^5.2.1",
"pretty-format": "^24.5.0",
"react-docgen-typescript-loader": "^3.0.0", "react-docgen-typescript-loader": "^3.0.0",
"react-docgen-typescript-webpack-plugin": "^1.1.0", "react-docgen-typescript-webpack-plugin": "^1.1.0",
"react-test-renderer": "^16.7.0", "react-test-renderer": "^16.7.0",
......
import React, { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
import tinycolor from 'tinycolor2';
import { css, cx } from 'emotion';
import { Themeable, GrafanaTheme } from '../../types';
import { selectThemeVariant } from '../../themes/selectThemeVariant';
export enum ButtonVariant {
Primary = 'primary',
Secondary = 'secondary',
Danger = 'danger',
Inverse = 'inverse',
Transparent = 'transparent',
}
export enum ButtonSize {
ExtraSmall = 'xs',
Small = 'sm',
Medium = 'md',
Large = 'lg',
ExtraLarge = 'xl',
}
export interface CommonButtonProps {
size?: ButtonSize;
variant?: ButtonVariant;
/**
* icon prop is a temporary solution. It accepts lefacy icon class names for the icon to be rendered.
* TODO: migrate to a component when we are going to migrate icons to @grafana/ui
*/
icon?: string;
className?: string;
}
export interface LinkButtonProps extends CommonButtonProps, AnchorHTMLAttributes<HTMLAnchorElement> {}
export interface ButtonProps extends CommonButtonProps, ButtonHTMLAttributes<HTMLButtonElement> {}
interface AbstractButtonProps extends CommonButtonProps, Themeable {
renderAs: React.ComponentType<CommonButtonProps> | string;
}
const buttonVariantStyles = (
from: string,
to: string,
textColor: string,
textShadowColor = 'rgba(0, 0, 0, 0.1)',
invert = false
) => css`
background: linear-gradient(to bottom, ${from}, ${to});
color: ${textColor};
text-shadow: 0 ${invert ? '1px' : '-1px'} ${textShadowColor};
&:hover {
background: ${from};
color: ${textColor};
}
&:focus {
background: ${from};
outline: none;
}
`;
const getButtonStyles = (theme: GrafanaTheme, size: ButtonSize, variant: ButtonVariant, withIcon: boolean) => {
const borderRadius = theme.border.radius.sm;
let padding,
background,
fontSize,
iconDistance,
fontWeight = theme.typography.weight.semibold;
switch (size) {
case ButtonSize.ExtraSmall:
padding = `${theme.spacing.xs} ${theme.spacing.sm}`;
fontSize = theme.typography.size.xs;
iconDistance = theme.spacing.xs;
break;
case ButtonSize.Small:
padding = `${theme.spacing.xs} ${theme.spacing.sm}`;
fontSize = theme.typography.size.sm;
iconDistance = theme.spacing.xs;
break;
case ButtonSize.Large:
padding = `${theme.spacing.md} ${theme.spacing.lg}`;
fontSize = theme.typography.size.lg;
fontWeight = theme.typography.weight.regular;
iconDistance = theme.spacing.sm;
break;
case ButtonSize.ExtraLarge:
padding = `${theme.spacing.md} ${theme.spacing.lg}`;
fontSize = theme.typography.size.lg;
fontWeight = theme.typography.weight.regular;
iconDistance = theme.spacing.sm;
break;
default:
padding = `${theme.spacing.sm} ${theme.spacing.md}`;
iconDistance = theme.spacing.sm;
fontSize = theme.typography.size.base;
}
switch (variant) {
case ButtonVariant.Primary:
background = buttonVariantStyles(theme.colors.greenBase, theme.colors.greenShade, theme.colors.white);
break;
case ButtonVariant.Secondary:
background = buttonVariantStyles(theme.colors.blueBase, theme.colors.blueShade, theme.colors.white);
break;
case ButtonVariant.Danger:
background = buttonVariantStyles(theme.colors.redBase, theme.colors.redShade, theme.colors.white);
break;
case ButtonVariant.Inverse:
const from = selectThemeVariant({ light: theme.colors.gray5, dark: theme.colors.dark6 }, theme.type) as string;
const to = selectThemeVariant(
{
light: tinycolor(from)
.darken(5)
.toString(),
dark: tinycolor(from)
.lighten(4)
.toString(),
},
theme.type
) as string;
background = buttonVariantStyles(from, to, theme.colors.link, 'rgba(0, 0, 0, 0.1)', true);
break;
case ButtonVariant.Transparent:
background = css`
${buttonVariantStyles('', '', theme.colors.link, 'rgba(0, 0, 0, 0.1)', true)};
background: transparent;
`;
break;
}
return {
button: css`
label: button;
display: inline-block;
font-weight: ${fontWeight};
font-size: ${fontSize};
font-family: ${theme.typography.fontFamily.sansSerif};
line-height: ${theme.typography.lineHeight.xs};
padding: ${padding};
text-align: ${withIcon ? 'left' : 'center'};
vertical-align: middle;
cursor: pointer;
border: none;
border-radius: ${borderRadius};
${background};
&[disabled],
&:disabled {
cursor: not-allowed;
opacity: 0.65;
box-shadow: none;
}
`,
iconWrap: css`
label: button-icon-wrap;
display: flex;
align-items: center;
`,
icon: css`
label: button-icon;
margin-right: ${iconDistance};
`,
};
};
export const AbstractButton: React.FunctionComponent<AbstractButtonProps> = ({
renderAs,
theme,
size = ButtonSize.Medium,
variant = ButtonVariant.Primary,
className,
icon,
children,
...otherProps
}) => {
const buttonStyles = getButtonStyles(theme, size, variant, !!icon);
const nonHtmlProps = {
theme,
size,
variant,
};
const finalClassName = cx(buttonStyles.button, className);
const finalChildren = icon ? (
<span className={buttonStyles.iconWrap}>
<i className={cx([icon, buttonStyles.icon])} />
<span>{children}</span>
</span>
) : (
children
);
const finalProps =
typeof renderAs === 'string'
? {
...otherProps,
className: finalClassName,
children: finalChildren,
}
: {
...otherProps,
...nonHtmlProps,
className: finalClassName,
children: finalChildren,
};
return React.createElement(renderAs, finalProps);
};
AbstractButton.displayName = 'AbstractButton';
import { storiesOf } from '@storybook/react';
import { Button, LinkButton } from './Button';
import { ButtonSize, ButtonVariant, CommonButtonProps } from './AbstractButton';
// @ts-ignore
import withPropsCombinations from 'react-storybook-addon-props-combinations';
import { action } from '@storybook/addon-actions';
import { ThemeableCombinationsRowRenderer } from '../../utils/storybook/CombinationsRowRenderer';
import { select, boolean } from '@storybook/addon-knobs';
const ButtonStories = storiesOf('UI/Button', module);
const defaultProps = {
onClick: [action('Button clicked')],
children: ['Click, click!'],
};
const variants = {
size: [ButtonSize.ExtraSmall, ButtonSize.Small, ButtonSize.Medium, ButtonSize.Large, ButtonSize.ExtraLarge],
variant: [
ButtonVariant.Primary,
ButtonVariant.Secondary,
ButtonVariant.Danger,
ButtonVariant.Inverse,
ButtonVariant.Transparent,
],
};
const combinationOptions = {
CombinationRenderer: ThemeableCombinationsRowRenderer,
};
const renderButtonStory = (buttonComponent: React.ComponentType<CommonButtonProps>) => {
const isDisabled = boolean('Disable button', false);
return withPropsCombinations(
buttonComponent,
{ ...variants, ...defaultProps, disabled: [isDisabled] },
combinationOptions
)();
};
ButtonStories.add('as button element', () => renderButtonStory(Button));
ButtonStories.add('as link element', () => renderButtonStory(LinkButton));
ButtonStories.add('with icon', () => {
const iconKnob = select(
'Icon',
{
Plus: 'fa fa-plus',
User: 'fa fa-user',
Gear: 'fa fa-gear',
Annotation: 'gicon gicon-add-annotation',
},
'fa fa-plus'
);
return withPropsCombinations(Button, { ...variants, ...defaultProps, icon: [iconKnob] }, combinationOptions)();
});
import React, { useContext } from 'react';
import { AbstractButton, ButtonProps, ButtonSize, LinkButtonProps } from './AbstractButton';
import { ThemeContext } from '../../themes';
const getSizeNameComponentSegment = (size: ButtonSize) => {
switch (size) {
case ButtonSize.ExtraSmall:
return 'ExtraSmall';
case ButtonSize.Small:
return 'Small';
case ButtonSize.Large:
return 'Large';
case ButtonSize.ExtraLarge:
return 'ExtraLarge';
default:
return 'Medium';
}
};
const buttonFactory: <T>(renderAs: string, size: ButtonSize, displayName: string) => React.ComponentType<T> = (
renderAs,
size,
displayName
) => {
const ButtonComponent: React.FunctionComponent<any> = props => {
const theme = useContext(ThemeContext);
return <AbstractButton {...props} size={size} renderAs={renderAs} theme={theme} />;
};
ButtonComponent.displayName = displayName;
return ButtonComponent;
};
export const Button: React.FunctionComponent<ButtonProps> = props => {
const theme = useContext(ThemeContext);
return <AbstractButton {...props} renderAs="button" theme={theme} />;
};
Button.displayName = 'Button';
export const LinkButton: React.FunctionComponent<LinkButtonProps> = props => {
const theme = useContext(ThemeContext);
return <AbstractButton {...props} renderAs="a" theme={theme} />;
};
LinkButton.displayName = 'LinkButton';
export const ExtraSmallButton = buttonFactory<ButtonProps>(
'button',
ButtonSize.ExtraSmall,
`${getSizeNameComponentSegment(ButtonSize.ExtraSmall)}Button`
);
export const SmallButton = buttonFactory<ButtonProps>(
'button',
ButtonSize.Small,
`${getSizeNameComponentSegment(ButtonSize.Small)}Button`
);
export const LargeButton = buttonFactory<ButtonProps>(
'button',
ButtonSize.Large,
`${getSizeNameComponentSegment(ButtonSize.Large)}Button`
);
export const ExtraLargeButton = buttonFactory<ButtonProps>(
'button',
ButtonSize.ExtraLarge,
`${getSizeNameComponentSegment(ButtonSize.ExtraLarge)}Button`
);
export const ExtraSmallLinkButton = buttonFactory<LinkButtonProps>(
'a',
ButtonSize.ExtraSmall,
`${getSizeNameComponentSegment(ButtonSize.ExtraSmall)}LinkButton`
);
export const SmallLinkButton = buttonFactory<LinkButtonProps>(
'a',
ButtonSize.Small,
`${getSizeNameComponentSegment(ButtonSize.Small)}LinkButton`
);
export const LargeLinkButton = buttonFactory<LinkButtonProps>(
'a',
ButtonSize.Large,
`${getSizeNameComponentSegment(ButtonSize.Large)}LinkButton`
);
export const ExtraLargeLinkButton = buttonFactory<LinkButtonProps>(
'a',
ButtonSize.ExtraLarge,
`${getSizeNameComponentSegment(ButtonSize.ExtraLarge)}LinkButton`
);
...@@ -5,6 +5,8 @@ export { Popper } from './Tooltip/Popper'; ...@@ -5,6 +5,8 @@ export { Popper } from './Tooltip/Popper';
export { Portal } from './Portal/Portal'; export { Portal } from './Portal/Portal';
export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar'; export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
export * from './Button/Button';
// Select // Select
export { Select, AsyncSelect, SelectOptionItem } from './Select/Select'; export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
export { IndicatorsContainer } from './Select/IndicatorsContainer'; export { IndicatorsContainer } from './Select/IndicatorsContainer';
......
import React from 'react';
import { css } from 'emotion';
import { withTheme } from '../../themes';
import { Themeable } from '../../types';
import { selectThemeVariant } from '../../themes/selectThemeVariant';
import prettyFormat from 'pretty-format';
const detailsRenderer: (combinationProps: any) => JSX.Element = props => {
const listStyle = css`
padding: 0;
margin: 0;
list-style: none;
`;
return (
<ul className={listStyle}>
<li>
{Object.keys(props).map((key, i) => {
return (
<li key={i}>
{key}: {props[key]}
</li>
);
})}
</li>
</ul>
);
};
interface CombinationsRowRendererProps extends Themeable {
Component: React.ComponentType<any>;
props: any;
options: any;
}
const CombinationsRowRenderer: React.FunctionComponent<CombinationsRowRendererProps> = ({
Component,
props,
theme,
}) => {
const el = React.createElement(Component, props);
const borderColor = selectThemeVariant(
{
dark: theme.colors.dark8,
light: theme.colors.gray5,
},
theme.type
);
const rowStyle = css`
display: flex;
width: 100%;
flex-direction: row;
border: 1px solid ${borderColor};
border-bottom: none;
&:last-child {
border-bottom: 1px solid ${borderColor};
}
`;
const cellStyle = css`
padding: 10px;
`;
const previewCellStyle = css`
display: flex;
align-items: center;
justify-content: center;
width: 200px;
flex-shrink: 1;
border-right: 1px solid ${borderColor};
${cellStyle};
`;
const variantsCellStyle = css`
width: 200px;
border-right: 1px solid ${borderColor};
${cellStyle};
`;
return (
<div className={rowStyle}>
<div className={previewCellStyle}>{el}</div>
<div className={variantsCellStyle}>{detailsRenderer(props)}</div>
<div className={cellStyle}>
{prettyFormat(el, {
plugins: [prettyFormat.plugins.ReactElement],
printFunctionName: true,
})}
</div>
</div>
);
};
export const ThemeableCombinationsRowRenderer = withTheme(CombinationsRowRenderer);
import React from 'react';
import { RenderFunction } from '@storybook/react';
const PaddedStory: React.FunctionComponent<{}> = ({ children }) => {
return (
<div
style={{
padding: '20px',
}}
>
{children}
</div>
);
};
export const withPaddedStory = (story: RenderFunction) => <PaddedStory>{story()}</PaddedStory>;
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment