mirror of https://github.com/grafana/grafana
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/uipull/16250/head
parent
232b7fd698
commit
c9e4fedaa8
@ -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'; |
@ -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)(); |
||||
}); |
@ -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` |
||||
); |
@ -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); |
@ -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>; |
Loading…
Reference in new issue