浏览代码

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
Dominik Prokop 6 年之前
父节点
当前提交
c9e4fedaa8

+ 1 - 0
package.json

@@ -16,6 +16,7 @@
     "@babel/preset-env": "^7.3.4",
     "@babel/preset-react": "^7.0.0",
     "@babel/preset-typescript": "^7.3.3",
+    "@emotion/core": "^10.0.10",
     "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1",
     "@types/angular": "^1.6.6",
     "@types/chalk": "^2.2.0",

+ 2 - 0
packages/grafana-ui/.storybook/config.ts

@@ -1,6 +1,7 @@
 import { configure, addDecorator } from '@storybook/react';
 import { withKnobs } from '@storybook/addon-knobs';
 import { withTheme } from '../src/utils/storybook/withTheme';
+import { withPaddedStory } from '../src/utils/storybook/withPaddedStory';
 
 // @ts-ignore
 import lightTheme from '../../../public/sass/grafana.light.scss';
@@ -20,6 +21,7 @@ const handleThemeChange = (theme: string) => {
 const req = require.context('../src/components', true, /.story.tsx$/);
 
 addDecorator(withKnobs);
+addDecorator(withPaddedStory);
 addDecorator(withTheme(handleThemeChange));
 
 function loadStories() {

+ 4 - 5
packages/grafana-ui/.storybook/webpack.config.js

@@ -1,11 +1,14 @@
 const path = require('path');
 
-module.exports = (baseConfig, env, config) => {
+module.exports = ({config, mode}) => {
   config.module.rules.push({
     test: /\.(ts|tsx)$/,
     use: [
       {
         loader: require.resolve('awesome-typescript-loader'),
+        options: {
+          configFileName: path.resolve(__dirname+'/../tsconfig.json')
+        }
       },
     ],
   });
@@ -56,9 +59,5 @@ module.exports = (baseConfig, env, config) => {
   });
 
   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;
 };

+ 9 - 5
packages/grafana-ui/package.json

@@ -32,6 +32,7 @@
     "react-dom": "^16.8.4",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
+    "react-storybook-addon-props-combinations": "^1.1.0",
     "react-transition-group": "^2.2.1",
     "react-virtualized": "^9.21.0",
     "tether": "^1.4.0",
@@ -39,10 +40,11 @@
     "tinycolor2": "^1.4.1"
   },
   "devDependencies": {
-    "@storybook/addon-actions": "^4.1.7",
-    "@storybook/addon-info": "^4.1.6",
-    "@storybook/addon-knobs": "^4.1.7",
-    "@storybook/react": "^4.1.4",
+    "@storybook/addon-actions": "^5.0.5",
+    "@storybook/addon-info": "^5.0.5",
+    "@storybook/addon-knobs": "^5.0.5",
+    "@storybook/react": "^5.0.5",
+    "@storybook/theming": "^5.0.5",
     "@types/classnames": "^2.2.6",
     "@types/d3": "^5.7.0",
     "@types/jest": "^23.3.2",
@@ -50,17 +52,19 @@
     "@types/lodash": "^4.14.119",
     "@types/node": "^10.12.18",
     "@types/papaparse": "^4.5.9",
+    "@types/pretty-format": "^20.0.1",
     "@types/react": "^16.8.8",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-test-renderer": "^16.0.3",
     "@types/react-transition-group": "^2.0.15",
     "@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__react": "^4.0.0",
     "@types/tether-drop": "^1.4.8",
     "@types/tinycolor2": "^1.4.1",
     "awesome-typescript-loader": "^5.2.1",
+    "pretty-format": "^24.5.0",
     "react-docgen-typescript-loader": "^3.0.0",
     "react-docgen-typescript-webpack-plugin": "^1.1.0",
     "react-test-renderer": "^16.7.0",

+ 212 - 0
packages/grafana-ui/src/components/Button/AbstractButton.tsx

@@ -0,0 +1,212 @@
+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';

+ 56 - 0
packages/grafana-ui/src/components/Button/Button.story.tsx

@@ -0,0 +1,56 @@
+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)();
+});

+ 86 - 0
packages/grafana-ui/src/components/Button/Button.tsx

@@ -0,0 +1,86 @@
+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`
+);

+ 2 - 0
packages/grafana-ui/src/components/index.ts

@@ -5,6 +5,8 @@ export { Popper } from './Tooltip/Popper';
 export { Portal } from './Portal/Portal';
 export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
 
+export * from './Button/Button';
+
 // Select
 export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
 export { IndicatorsContainer } from './Select/IndicatorsContainer';

+ 94 - 0
packages/grafana-ui/src/utils/storybook/CombinationsRowRenderer.tsx

@@ -0,0 +1,94 @@
+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);

+ 16 - 0
packages/grafana-ui/src/utils/storybook/withPaddedStory.tsx

@@ -0,0 +1,16 @@
+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>;

文件差异内容过多而无法显示
+ 455 - 326
yarn.lock


部分文件因为文件数量过多而无法显示