Buttons: introduce buttonStyle prop (#33384)

* Wip

* Updates to colors to make secondary outline work

* refactor: prefer buttonStyle="text" over variant="link" for text buttons

* revert(button): put back mdx story page

* fix(button): variant link disabled as text disabled

* docs(button): remove link variant from stories

* feat(grafana-ui): introduce basic deprecation notice for button and linkbutton components

* docs(button): update usage of href with button and buttonlink

* feat(button): add grafana/ui to deprecation warning

* refactor(buttons): use a more descriptive name for prop warning function

* test(buttonrow): update snapshots

* refactor(buttons): change prop name from buttonStyle to fill

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Jack Westbrook 2021-04-27 12:03:06 +02:00 committed by GitHub
parent 58a325a4e4
commit 45fa5fdf48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 200 additions and 118 deletions

View File

@ -86,6 +86,12 @@ class DarkColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
whiteBase = '201, 209, 217';
border = {
weak: `rgba(${this.whiteBase}, 0.10)`,
medium: `rgba(${this.whiteBase}, 0.15)`,
strong: `rgba(${this.whiteBase}, 0.25)`,
};
text = {
primary: `rgb(${this.whiteBase})`,
secondary: `rgba(${this.whiteBase}, 0.65)`,
@ -103,8 +109,9 @@ class DarkColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
secondary = {
main: `rgba(${this.whiteBase}, 0.1)`,
shade: `rgba(${this.whiteBase}, 0.15)`,
text: `rgba(${this.whiteBase}, 0.13)`,
text: this.text.primary,
contrastText: `rgb(${this.whiteBase})`,
border: this.border.strong,
};
info = this.primary;
@ -130,12 +137,6 @@ class DarkColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
secondary: palette.gray15,
};
border = {
weak: `rgba(${this.whiteBase}, 0.10)`,
medium: `rgba(${this.whiteBase}, 0.15)`,
strong: `rgba(${this.whiteBase}, 0.20)`,
};
action = {
hover: `rgba(${this.whiteBase}, 0.08)`,
selected: `rgba(${this.whiteBase}, 0.12)`,
@ -167,10 +168,26 @@ class LightColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
text: palette.blueLightText,
};
text = {
primary: `rgba(${this.blackBase}, 1)`,
secondary: `rgba(${this.blackBase}, 0.75)`,
disabled: `rgba(${this.blackBase}, 0.50)`,
link: this.primary.text,
maxContrast: palette.black,
};
border = {
weak: `rgba(${this.blackBase}, 0.12)`,
medium: `rgba(${this.blackBase}, 0.30)`,
strong: `rgba(${this.blackBase}, 0.40)`,
};
secondary = {
main: `rgba(${this.blackBase}, 0.11)`,
shade: `rgba(${this.blackBase}, 0.16)`,
contrastText: `rgba(${this.blackBase}, 1)`,
text: this.text.primary,
border: this.border.strong,
};
info = {
@ -194,28 +211,12 @@ class LightColors implements ThemeColorsBase<Partial<ThemeRichColor>> {
text: palette.orangeLightText,
};
text = {
primary: `rgba(${this.blackBase}, 1)`,
secondary: `rgba(${this.blackBase}, 0.75)`,
disabled: `rgba(${this.blackBase}, 0.50)`,
link: this.primary.text,
maxContrast: palette.black,
};
background = {
canvas: palette.gray90,
primary: palette.white,
secondary: palette.gray100,
};
border = {
weak: `rgba(${this.blackBase}, 0.12)`,
medium: `rgba(${this.blackBase}, 0.30)`,
strong: `rgba(${this.blackBase}, 0.40)`,
};
divider = this.border.weak;
action = {
hover: `rgba(${this.blackBase}, 0.04)`,
selected: `rgba(${this.blackBase}, 0.08)`,

View File

@ -1,5 +1,5 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Button } from './Button';
import { Button, LinkButton } from './Button';
<Meta title="MDX|Button" component={Button} />
@ -64,22 +64,38 @@ Used for triggering a removing or deleting action. Because of its dominant color
</div>
</Preview>
## Link
Used for hyperlinks.
## Text
<Preview>
<div>
<Button href="/" variant="link" size="sm" style={{ margin: '5px' }}>
<Button fill="text" size="sm" style={{ margin: '5px' }}>
Small
</Button>
<Button href="/" variant="link" size="md" style={{ margin: '5px' }}>
<Button fill="text" size="md" style={{ margin: '5px' }}>
Medium
</Button>
<Button href="/" variant="link" size="lg" style={{ margin: '5px' }}>
<Button fill="text" size="lg" style={{ margin: '5px' }}>
Large
</Button>
</div>
</Preview>
<Props of={Button} />
## Links
To add an anchor that looks like a button use the `<LinkButton>` component and pass a href prop.
<Preview>
<div>
<LinkButton href="/" size="sm" style={{ margin: '5px' }}>
Small
</LinkButton>
<LinkButton href="/" size="md" style={{ margin: '5px' }}>
Medium
</LinkButton>
<LinkButton href="/" size="lg" style={{ margin: '5px' }}>
Large
</LinkButton>
</div>
</Preview>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Story } from '@storybook/react';
import { allButtonVariants, Button, ButtonProps } from './Button';
import { Story, Meta } from '@storybook/react';
import { allButtonVariants, allButtonFills, Button, ButtonProps } from './Button';
import { iconOptions } from '../../utils/storybook/knobs';
import mdx from './Button.mdx';
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
@ -12,11 +12,9 @@ export default {
title: 'Buttons/Button',
component: Button,
argTypes: {
variant: { control: { type: 'select', options: ['primary', 'secondary', 'destructive', 'link'] } },
size: { control: { type: 'select', options: ['sm', 'md', 'lg'] } },
variant: { control: 'select' },
size: { control: 'select' },
icon: { control: { type: 'select', options: iconOptions } },
css: { control: { disable: true } },
className: { control: { disable: true } },
},
parameters: {
docs: {
@ -25,29 +23,35 @@ export default {
knobs: {
disable: true,
},
controls: {
exclude: ['css', 'className'],
},
},
};
} as Meta;
export const Variants: Story<ButtonProps> = ({ children, ...args }) => {
const sizes: ComponentSize[] = ['lg', 'md', 'sm'];
return (
<VerticalGroup>
<HorizontalGroup spacing="lg">
{allButtonVariants.map((variant) => (
<VerticalGroup spacing="lg" key={variant}>
{sizes.map((size) => (
<Button variant={variant} size={size} key={size}>
{variant} {size}
</Button>
{allButtonFills.map((buttonFill) => (
<VerticalGroup key={buttonFill}>
<HorizontalGroup spacing="lg">
{allButtonVariants.map((variant) => (
<VerticalGroup spacing="lg" key={`${buttonFill}-${variant}`}>
{sizes.map((size) => (
<Button variant={variant} fill={buttonFill} size={size} key={size}>
{variant} {size}
</Button>
))}
<Button variant={variant} fill={buttonFill} disabled>
{variant} disabled
</Button>
</VerticalGroup>
))}
<Button variant={variant} disabled>
{variant} disabled
</Button>
</VerticalGroup>
))}
</HorizontalGroup>
<div />
</HorizontalGroup>
<div style={{ padding: '20px 0', width: '100%' }} />
</VerticalGroup>
))}
<HorizontalGroup spacing="lg">
<div>With icon and text</div>
<Button icon="cloud" size="sm">

View File

@ -9,11 +9,14 @@ import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
import { Icon } from '../Icon/Icon';
export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'link';
export const allButtonVariants: ButtonVariant[] = ['primary', 'secondary', 'destructive', 'link'];
export const allButtonVariants: ButtonVariant[] = ['primary', 'secondary', 'destructive'];
export type ButtonFill = 'solid' | 'outline' | 'text';
export const allButtonFills: ButtonFill[] = ['solid', 'outline', 'text'];
type CommonProps = {
size?: ComponentSize;
variant?: ButtonVariant;
fill?: ButtonFill;
icon?: IconName;
className?: string;
children?: React.ReactNode;
@ -23,16 +26,22 @@ type CommonProps = {
export type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', icon, fullWidth, children, className, ...otherProps }, ref) => {
({ variant = 'primary', size = 'md', fill = 'solid', icon, fullWidth, children, className, ...otherProps }, ref) => {
const theme = useTheme2();
const styles = getButtonStyles({
theme,
size,
variant,
fill,
fullWidth,
iconOnly: !children,
});
deprecatedPropWarning(
variant === 'link',
`${Button.displayName}: Prop variant="link" is deprecated. Please use fill="text".`
);
return (
<button className={cx(styles.button, className)} {...otherProps} ref={ref}>
{icon && <Icon name={icon} size={size} className={styles.icon} />}
@ -51,6 +60,7 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
{
variant = 'primary',
size = 'md',
fill = 'solid',
icon,
fullWidth,
children,
@ -68,11 +78,17 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
fullWidth,
size,
variant,
fill,
iconOnly: !children,
});
const linkButtonStyles = cx(styles.button, { [styles.disabled]: disabled }, className);
deprecatedPropWarning(
variant === 'link',
`${LinkButton.displayName}: Prop variant="link" is deprecated. Please use fill="text".`
);
return (
<a className={linkButtonStyles} {...otherProps} tabIndex={disabled ? -1 : 0} ref={ref}>
{icon && <Icon name={icon} size={size} className={styles.icon} />}
@ -87,6 +103,7 @@ LinkButton.displayName = 'LinkButton';
export interface StyleProps {
size: ComponentSize;
variant: ButtonVariant;
fill?: ButtonFill;
iconOnly?: boolean;
theme: GrafanaThemeV2;
fullWidth?: boolean;
@ -94,24 +111,10 @@ export interface StyleProps {
}
export const getButtonStyles = (props: StyleProps) => {
const { theme, variant, size, iconOnly, fullWidth } = props;
const { theme, variant, fill = 'solid', size, iconOnly, fullWidth } = props;
const { height, padding, fontSize } = getPropertiesForButtonSize(size, theme);
const variantStyles = getPropertiesForVariant(theme, variant);
const disabledStyles: CSSObject = {
cursor: 'not-allowed',
boxShadow: 'none',
background: theme.colors.action.disabledBackground,
border: `1px solid transparent`,
color: theme.colors.text.disabled,
pointerEvents: 'none',
'&:hover': {
background: theme.colors.action.disabledBackground,
color: theme.colors.text.disabled,
boxShadow: 'none',
},
};
const variantStyles = getPropertiesForVariant(theme, variant, fill);
const disabledStyles = getPropertiesForDisabled(theme, variant, fill);
const focusStyle = getFocusStyles(theme);
@ -160,7 +163,45 @@ export const getButtonStyles = (props: StyleProps) => {
};
};
function getButtonVariantStyles(theme: GrafanaThemeV2, color: ThemeRichColor): CSSObject {
function getButtonVariantStyles(theme: GrafanaThemeV2, color: ThemeRichColor, fill: ButtonFill): CSSObject {
if (fill === 'outline') {
return {
background: 'transparent',
color: color.text,
border: `1px solid ${color.border}`,
transition: theme.transitions.create(['background-color', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
}),
'&:hover': {
background: colorManipulator.alpha(color.main, theme.colors.action.hoverOpacity),
borderColor: theme.colors.emphasize(color.border, 0.25),
color: color.text,
},
};
}
if (fill === 'text') {
return {
background: 'transparent',
color: color.text,
border: '1px solid transparent',
transition: theme.transitions.create(['background-color', 'color'], {
duration: theme.transitions.duration.short,
}),
'&:focus': {
outline: 'none',
textDecoration: 'none',
},
'&:hover': {
background: colorManipulator.alpha(color.shade, theme.colors.action.hoverOpacity),
textDecoration: 'none',
},
};
}
return {
background: color.main,
color: color.contrastText,
@ -177,32 +218,57 @@ function getButtonVariantStyles(theme: GrafanaThemeV2, color: ThemeRichColor): C
};
}
export function getPropertiesForVariant(theme: GrafanaThemeV2, variant: ButtonVariant) {
switch (variant) {
function getPropertiesForDisabled(theme: GrafanaThemeV2, variant: ButtonVariant, fill: ButtonFill) {
const disabledStyles: CSSObject = {
cursor: 'not-allowed',
boxShadow: 'none',
pointerEvents: 'none',
color: theme.colors.text.disabled,
transition: 'none',
};
if (fill === 'text' || variant === 'link') {
return {
...disabledStyles,
background: 'transparent',
border: `1px solid transparent`,
};
}
if (fill === 'outline') {
return {
...disabledStyles,
background: 'transparent',
border: `1px solid ${theme.colors.action.disabledText}`,
};
}
return {
...disabledStyles,
background: theme.colors.action.disabledBackground,
border: `1px solid transparent`,
};
}
export function getPropertiesForVariant(theme: GrafanaThemeV2, variant: ButtonVariant, fill: ButtonFill) {
const buttonVariant = variant === 'link' ? 'primary' : variant;
const buttonFill = variant === 'link' ? 'text' : fill;
switch (buttonVariant) {
case 'secondary':
return getButtonVariantStyles(theme, theme.colors.secondary);
return getButtonVariantStyles(theme, theme.colors.secondary, buttonFill);
case 'destructive':
return getButtonVariantStyles(theme, theme.colors.error);
case 'link':
return {
background: 'transparent',
color: theme.colors.text.link,
border: '1px solid transparent',
'&:focus': {
outline: 'none',
textDecoration: 'underline',
},
'&:hover': {
background: colorManipulator.alpha(theme.colors.text.link, theme.colors.action.hoverOpacity),
textDecoration: 'underline',
},
};
return getButtonVariantStyles(theme, theme.colors.error, buttonFill);
case 'primary':
default:
return getButtonVariantStyles(theme, theme.colors.primary);
return getButtonVariantStyles(theme, theme.colors.primary, buttonFill);
}
}
function deprecatedPropWarning(test: boolean, message: string) {
if (process.env.NODE_ENV === 'development' && test) {
console.warn(`@grafana/ui ${message}`);
}
}

View File

@ -105,8 +105,8 @@ function renderIcon(icon: IconName | React.ReactNode) {
}
const getStyles = (theme: GrafanaThemeV2) => {
const primaryVariant = getPropertiesForVariant(theme, 'primary');
const destructiveVariant = getPropertiesForVariant(theme, 'destructive');
const primaryVariant = getPropertiesForVariant(theme, 'primary', 'solid');
const destructiveVariant = getPropertiesForVariant(theme, 'destructive', 'solid');
return {
button: css`

View File

@ -143,7 +143,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
<span className={styles.buttonContainer}>
{typeof children === 'string' ? (
<span className={buttonClass}>
<Button size={size} variant="link" onClick={onClick}>
<Button size={size} fill="text" onClick={onClick}>
{children}
</Button>
</span>
@ -153,7 +153,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
</span>
)}
<span className={confirmButtonClass}>
<Button size={size} variant="link" onClick={this.onClickCancel}>
<Button size={size} fill="text" onClick={this.onClickCancel}>
Cancel
</Button>
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm}>

View File

@ -64,7 +64,7 @@ export const FilterPopup: FC<Props> = ({ column: { preFilteredRows, filterValue,
</HorizontalGroup>
{clearFilterVisible && (
<HorizontalGroup>
<Button variant="link" size="sm" onClick={onClearFilter}>
<Button fill="text" size="sm" onClick={onClearFilter}>
Clear filter
</Button>
</HorizontalGroup>

View File

@ -53,7 +53,7 @@ export const TagsInput: FC<Props> = ({ placeholder = 'New tag (enter key to add)
onKeyUp={onKeyboardAdd}
suffix={
newTagName.length > 0 && (
<Button variant="link" className={styles.addButtonStyle} onClick={onAdd} size="md">
<Button fill="text" className={styles.addButtonStyle} onClick={onAdd} size="md">
Add
</Button>
)

View File

@ -13,7 +13,7 @@ export interface Props {
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, autoFocus }) => {
const suffix =
value !== '' ? (
<Button icon="times" variant="link" size="sm" onClick={() => onChange('')}>
<Button icon="times" fill="text" size="sm" onClick={() => onChange('')}>
Clear
</Button>
) : null;

View File

@ -50,7 +50,7 @@ export const ChangePassword: FC<Props> = ({ onSubmit, onSkip }) => {
content="If you skip you will be prompted to change password next time you log in."
placement="bottom"
>
<Button variant="link" onClick={onSkip} type="button" aria-label={selectors.pages.Login.skip}>
<Button fill="text" onClick={onSkip} type="button" aria-label={selectors.pages.Login.skip}>
Skip
</Button>
</Tooltip>

View File

@ -55,7 +55,7 @@ export const ForgottenPassword: FC = () => {
</Field>
<HorizontalGroup>
<Button>Send reset email</Button>
<LinkButton variant="link" href={loginHref}>
<LinkButton fill="text" href={loginHref}>
Back to login
</LinkButton>
</HorizontalGroup>

View File

@ -50,7 +50,7 @@ export const LoginPage: FC = () => {
<HorizontalGroup justify="flex-end">
<LinkButton
className={forgottenPasswordStyles}
variant="link"
fill="text"
href={`${config.appSubUrl}/user/password/send-reset-email`}
>
Forgot your password?

View File

@ -112,7 +112,7 @@ export const SignupPage: FC<Props> = (props) => {
<HorizontalGroup>
<Button type="submit">Submit</Button>
<LinkButton variant="link" href={getConfig().appSubUrl + '/login'}>
<LinkButton fill="text" href={getConfig().appSubUrl + '/login'}>
Back to login
</LinkButton>
</HorizontalGroup>

View File

@ -51,7 +51,7 @@ export const VerifyEmail: FC = () => {
</Field>
<HorizontalGroup>
<Button>Send verification email</Button>
<LinkButton variant="link" href={getConfig().appSubUrl + '/login'}>
<LinkButton fill="text" href={getConfig().appSubUrl + '/login'}>
Back to login
</LinkButton>
</HorizontalGroup>

View File

@ -60,12 +60,7 @@ export const NotificationChannelOptions: FC<Props> = ({
readOnly={true}
value="Configured"
suffix={
<Button
onClick={() => onResetSecureField(option.propertyName)}
variant="link"
type="button"
size="sm"
>
<Button onClick={() => onResetSecureField(option.propertyName)} fill="text" type="button" size="sm">
Clear
</Button>
}

View File

@ -63,7 +63,7 @@ export const VisualizationSelectPane: FC<Props> = ({ panel }) => {
const suffix =
searchQuery !== '' ? (
<Button icon="times" variant="link" size="sm" onClick={() => setSearchQuery('')}>
<Button icon="times" fill="text" size="sm" onClick={() => setSearchQuery('')}>
Clear
</Button>
) : null;

View File

@ -269,7 +269,7 @@ export class ShareSnapshot extends PureComponent<Props, State> {
<div className="pull-right" style={{ padding: '5px' }}>
Did you make a mistake?{' '}
<LinkButton variant="link" target="_blank" onClick={this.deleteSnapshot}>
<LinkButton fill="text" target="_blank" onClick={this.deleteSnapshot}>
Delete snapshot.
</LinkButton>
</div>

View File

@ -39,7 +39,7 @@ const ButtonRow: FC<Props> = ({ isReadOnly, onDelete, onSubmit, onTest }) => {
>
Delete
</Button>
<LinkButton variant="link" href={`${config.appSubUrl}/datasources`}>
<LinkButton fill="text" href={`${config.appSubUrl}/datasources`}>
Back
</LinkButton>
</div>

View File

@ -21,8 +21,8 @@ exports[`Render should render component 1`] = `
Delete
</Button>
<LinkButton
fill="text"
href="/datasources"
variant="link"
>
Back
</LinkButton>
@ -52,8 +52,8 @@ exports[`Render should render with buttons enabled 1`] = `
Delete
</Button>
<LinkButton
fill="text"
href="/datasources"
variant="link"
>
Back
</LinkButton>

View File

@ -415,7 +415,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
{!loading && !hasData && !scanning && (
<div className={styles.noData}>
No logs found.
<Button size="xs" variant="link" onClick={this.onClickScan}>
<Button size="xs" fill="text" onClick={this.onClickScan}>
Scan for older logs
</Button>
</div>
@ -424,7 +424,7 @@ export class UnthemedLogs extends PureComponent<Props, State> {
{scanning && (
<div className={styles.noData}>
<span>{scanText}</span>
<Button size="xs" variant="link" onClick={this.onClickStopScan}>
<Button size="xs" fill="text" onClick={this.onClickStopScan}>
Stop scan
</Button>
</div>