Design System: Refactor IconButton and update documentation (#66774)

* refactor: remove unnecessary styling and adjust to button styling

* refactor: improve story

* refactor: use new default styling for border radius

* refactor: add missing pseudo classes and clean up

* refactor: repair disabled styling and add to story

* refactor: clean up and apply styles defined in figma

* refactor: set hover background in a pseudo-element

* refactor: unify large sizes

* refactor: add information for further use

* refactor: add comment

* refactor: comments and clean up import

* refactor: add changes after code review

* refactor: replace some bad example code in documentation

* refactor: update comment

Co-authored-by: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com>

* refactor: add changes requested in review

* refactor: move deprecation warning

* refactor: replace padding

* refactor: remove local styling

* refactor: create separate stories for different examples

* refactor: change style of story

* refactor: replace absolute value by variable

* Update toggles_gen.go

---------

Co-authored-by: Joao Silva <100691367+JoaoSilvaGrafana@users.noreply.github.com>
This commit is contained in:
Laura Benz 2023-05-15 13:57:18 +02:00 committed by GitHub
parent 9441692fe9
commit e7dc3575d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 176 additions and 110 deletions

View File

@ -1,10 +1,17 @@
import { Meta, Preview, ArgTypes } from '@storybook/blocks'; import { Meta, Preview, ArgTypes } from '@storybook/blocks';
import { Button, LinkButton } from './Button'; import { Button, LinkButton } from './Button';
import { Alert } from '../Alert/Alert';
<Meta title="MDX|Button" component={Button} /> <Meta title="MDX|Button" component={Button} />
# Button # Button
<Alert severity="warning" title={'Please note:'}>
After reviewing this component we are asking you to use the IconButton when you require a button with only an icon.
</Alert>
When using a button please always follow a11y rules (e.g. W3C Recommendation [3.3.2 Labels or instructions](https://www.w3.org/TR/WCAG21/#labels-or-instructions)) and make sure the context in which the button is located is also communicated by screen readers.
## Primary ## Primary
Used for "call to action", i.e. triggering the main action. There should never be more than one on a page. If you need multiple buttons for different actions, decide which action is the most important and make that the primary `Button`. All other `Button` components should be secondary. Used for "call to action", i.e. triggering the main action. There should never be more than one on a page. If you need multiple buttons for different actions, decide which action is the most important and make that the primary `Button`. All other `Button` components should be secondary.

View File

@ -1,7 +1,7 @@
import { StoryFn } from '@storybook/react'; import { StoryFn } from '@storybook/react';
import React from 'react'; import React from 'react';
import { ComponentSize } from '../../types/size'; import { ComponentSize } from '../../types';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { Card } from '../Card/Card'; import { Card } from '../Card/Card';
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout'; import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
@ -69,12 +69,6 @@ export const Examples: StoryFn<typeof Button> = () => {
</Button> </Button>
</HorizontalGroup> </HorizontalGroup>
<div /> <div />
<HorizontalGroup spacing="lg">
<div>With icon only</div>
<Button icon="cloud" size="sm" />
<Button icon="cloud" size="md" />
<Button icon="cloud" size="lg" />
</HorizontalGroup>
<div /> <div />
<Button icon="plus" fullWidth> <Button icon="plus" fullWidth>
Button with fullWidth Button with fullWidth

View File

@ -5,8 +5,8 @@ import { colorManipulator, GrafanaTheme2, ThemeRichColor } from '@grafana/data';
import { useTheme2 } from '../../themes'; import { useTheme2 } from '../../themes';
import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
import { ComponentSize } from '../../types';
import { IconName } from '../../types/icon'; import { IconName } from '../../types/icon';
import { ComponentSize } from '../../types/size';
import { getPropertiesForButtonSize } from '../Forms/commonStyles'; import { getPropertiesForButtonSize } from '../Forms/commonStyles';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { PopoverContent, Tooltip, TooltipPlacement } from '../Tooltip'; import { PopoverContent, Tooltip, TooltipPlacement } from '../Tooltip';
@ -60,6 +60,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
iconOnly: !children, iconOnly: !children,
}); });
// In order to standardise Button please always consider using IconButton when you need a button with an icon only
const button = ( const button = (
<button className={cx(styles.button, className)} type={type} {...otherProps} ref={ref}> <button className={cx(styles.button, className)} type={type} {...otherProps} ref={ref}>
{icon && <Icon name={icon} size={size} className={styles.icon} />} {icon && <Icon name={icon} size={size} className={styles.icon} />}
@ -175,7 +176,7 @@ export const getButtonStyles = (props: StyleProps) => {
lineHeight: `${theme.spacing.gridSize * height - 2}px`, lineHeight: `${theme.spacing.gridSize * height - 2}px`,
verticalAlign: 'middle', verticalAlign: 'middle',
cursor: 'pointer', cursor: 'pointer',
borderRadius: theme.shape.borderRadius(1), borderRadius: theme.shape.radius.default,
'&:focus': focusStyle, '&:focus': focusStyle,
'&:focus-visible': focusStyle, '&:focus-visible': focusStyle,
'&:focus:not(:focus-visible)': getMouseFocusStyles(theme), '&:focus:not(:focus-visible)': getMouseFocusStyles(theme),

View File

@ -1,12 +1,20 @@
import { Meta, Story, Preview, ArgTypes } from '@storybook/blocks'; import { Meta, ArgTypes } from '@storybook/blocks';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { Alert } from '../Alert/Alert';
<Meta title="MDX|IconButton" component={IconButton} /> <Meta title="MDX|IconButton" component={IconButton} />
# IconButton # IconButton
This component looks like just an icon but behaves like a button. It fulfils an action when you click it and has hover and focus states. You can choose which icon size you would like to use. This component looks just like an icon but behaves like a button. It fulfils an action when you click it and has hover and focus states. You can choose which icon size you would like to use.
`IconButton` is best used when an actual button would look out of place, for example when you want to place a solitary clickable icon next to text. An example where an `IconButton` is used in Grafana is the top left back arrow in the panel edit mode. `IconButton` is best used when you only want an icon instead of a button with text, for example when you want to place a solitary clickable icon next to text. An example where an `IconButton` is used in Grafana is the hamburger icon at the top left which opens the new navigation.
Always keep in mind to add text for a tooltip and an aria label.
<Alert severity="warning" title={'Please note:'}>
After reviewing this component we would like you to know that there are only 5 sizes available (sizes xs to xl). Sizes
xxl and xxxl are now shown in size xl as well and will be deprecated in the future.
</Alert>
<ArgTypes of={IconButton} /> <ArgTypes of={IconButton} />

View File

@ -5,11 +5,15 @@ import React from 'react';
import { useTheme2 } from '../../themes'; import { useTheme2 } from '../../themes';
import { IconSize, IconName } from '../../types'; import { IconSize, IconName } from '../../types';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { VerticalGroup } from '../Layout/Layout'; import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
import { IconButton, IconButtonVariant, Props as IconButtonProps } from './IconButton'; import { IconButton, IconButtonVariant, Props as IconButtonProps } from './IconButton';
import mdx from './IconButton.mdx'; import mdx from './IconButton.mdx';
interface ScenarioProps {
background: 'canvas' | 'primary' | 'secondary';
}
const meta: Meta<typeof IconButton> = { const meta: Meta<typeof IconButton> = {
title: 'Buttons/IconButton', title: 'Buttons/IconButton',
component: IconButton, component: IconButton,
@ -35,29 +39,84 @@ const meta: Meta<typeof IconButton> = {
}, },
}; };
export const Examples = () => {
return (
<div>
<RenderScenario background="canvas" />
<RenderScenario background="primary" />
<RenderScenario background="secondary" />
</div>
);
};
export const Basic: StoryFn<typeof IconButton> = (args: IconButtonProps) => { export const Basic: StoryFn<typeof IconButton> = (args: IconButtonProps) => {
return <IconButton {...args} />; return <IconButton {...args} />;
}; };
interface ScenarioProps { export const ExamplesSizes = () => {
background: 'canvas' | 'primary' | 'secondary'; const sizes: IconSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
}
const RenderScenario = ({ background }: ScenarioProps) => {
const theme = useTheme2();
const sizes: IconSize[] = ['sm', 'md', 'lg', 'xl', 'xxl'];
const icons: IconName[] = ['search', 'trash-alt', 'arrow-left', 'times']; const icons: IconName[] = ['search', 'trash-alt', 'arrow-left', 'times'];
const variants: IconButtonVariant[] = ['secondary', 'primary', 'destructive']; const variants: IconButtonVariant[] = ['primary', 'secondary', 'destructive'];
return (
<div
className={css`
button {
margin-right: 8px;
margin-left: 8px;
margin-bottom: 20px;
}
`}
>
<HorizontalGroup spacing="md">
{variants.map((variant) => {
return (
<div key={variant}>
<p>{variant}</p>
{icons.map((icon) => {
return (
<div
className={css`
display: flex;
`}
key={icon}
>
{sizes.map((size) => (
<span key={icon + size}>
<IconButton name={icon} size={size} variant={variant} />
</span>
))}
</div>
);
})}
</div>
);
})}
<div>
<p>disabled</p>
{icons.map((icon) => (
<div
className={css`
display: flex;
`}
key={icon}
>
{sizes.map((size) => (
<span key={icon + size}>
<IconButton name={icon} size={size} disabled />
</span>
))}
</div>
))}
</div>
</HorizontalGroup>
</div>
);
};
export const ExamplesBackground = () => {
return (
<div>
<RenderBackgroundScenario background="canvas" />
<RenderBackgroundScenario background="primary" />
<RenderBackgroundScenario background="secondary" />
</div>
);
};
const RenderBackgroundScenario = ({ background }: ScenarioProps) => {
const theme = useTheme2();
const variants: IconButtonVariant[] = ['primary', 'secondary', 'destructive'];
return ( return (
<div <div
@ -67,25 +126,21 @@ const RenderScenario = ({ background }: ScenarioProps) => {
button { button {
margin-right: 8px; margin-right: 8px;
margin-left: 8px; margin-left: 8px;
margin-bottom: 8px;
} }
`} `}
> >
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
<div>{background}</div> <div>{background}</div>
{variants.map((variant) => { <div
return ( className={css`
<div key={variant}> display: flex;
{icons.map((icon) => { `}
return sizes.map((size) => ( >
<span key={icon + size}> {variants.map((variant) => {
<IconButton name={icon} size={size} variant={variant} /> return <IconButton name="times" size="xl" variant={variant} key={variant} />;
</span> })}
)); <IconButton name="times" size="xl" disabled />
})} </div>
</div>
);
})}
</VerticalGroup> </VerticalGroup>
</div> </div>
); );

View File

@ -1,11 +1,11 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React from 'react'; import React from 'react';
import { GrafanaTheme2, colorManipulator } from '@grafana/data'; import { GrafanaTheme2, colorManipulator, deprecationWarning } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext'; import { useTheme2, stylesFactory } from '../../themes';
import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
import { stylesFactory } from '../../themes/stylesFactory'; import { ComponentSize } from '../../types';
import { IconName, IconSize, IconType } from '../../types/icon'; import { IconName, IconSize, IconType } from '../../types/icon';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { getSvgSize } from '../Icon/utils'; import { getSvgSize } from '../Icon/utils';
@ -13,10 +13,12 @@ import { TooltipPlacement, PopoverContent, Tooltip } from '../Tooltip';
export type IconButtonVariant = 'primary' | 'secondary' | 'destructive'; export type IconButtonVariant = 'primary' | 'secondary' | 'destructive';
type LimitedIconSize = ComponentSize | 'xl';
export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Name of the icon **/ /** Name of the icon **/
name: IconName; name: IconName;
/** Icon size */ /** Icon size - sizes xxl and xxxl are deprecated and when used being decreased to xl*/
size?: IconSize; size?: IconSize;
/** Type of the icon - mono or default */ /** Type of the icon - mono or default */
iconType?: IconType; iconType?: IconType;
@ -46,12 +48,22 @@ export const IconButton = React.forwardRef<HTMLButtonElement, Props>(
ref ref
) => { ) => {
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, size, variant); let limitedIconSize: LimitedIconSize;
// very large icons (xl to xxxl) are unified to size xl
if (size === 'xxl' || size === 'xxxl') {
deprecationWarning('IconButton', 'size="xxl" and size="xxxl"', 'size="xl"');
limitedIconSize = 'xl';
} else {
limitedIconSize = size;
}
const styles = getStyles(theme, limitedIconSize, variant);
const tooltipString = typeof tooltip === 'string' ? tooltip : ''; const tooltipString = typeof tooltip === 'string' ? tooltip : '';
const button = ( const button = (
<button ref={ref} aria-label={ariaLabel || tooltipString} {...restProps} className={cx(styles.button, className)}> <button ref={ref} aria-label={ariaLabel || tooltipString} {...restProps} className={cx(styles.button, className)}>
<Icon name={name} size={size} className={styles.icon} type={iconType} /> <Icon name={name} size={limitedIconSize} className={styles.icon} type={iconType} />
</button> </button>
); );
@ -69,9 +81,11 @@ export const IconButton = React.forwardRef<HTMLButtonElement, Props>(
IconButton.displayName = 'IconButton'; IconButton.displayName = 'IconButton';
const getStyles = stylesFactory((theme: GrafanaTheme2, size: IconSize, variant: IconButtonVariant) => { const getStyles = stylesFactory((theme: GrafanaTheme2, size, variant: IconButtonVariant) => {
const pixelSize = getSvgSize(size); // overall size of the IconButton on hover
const hoverSize = Math.max(pixelSize / 3, 8); // theme.spacing.gridSize originates from 2*4px for padding and letting the IconSize generally decide on the hoverSize
const hoverSize = getSvgSize(size) + theme.spacing.gridSize;
let iconColor = theme.colors.text.primary; let iconColor = theme.colors.text.primary;
if (variant === 'primary') { if (variant === 'primary') {
@ -82,47 +96,34 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, size: IconSize, variant:
return { return {
button: css` button: css`
width: ${pixelSize}px;
height: ${pixelSize}px;
background: transparent;
border: none;
color: ${iconColor};
padding: 0;
margin: 0;
outline: none;
box-shadow: none;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: ${theme.shape.borderRadius()};
z-index: 0; z-index: 0;
margin-right: ${theme.spacing(0.5)}; position: relative;
margin: 0 ${theme.spacing(0.5)} 0 0;
box-shadow: none;
border: none;
display: inline-flex;
background: transparent;
justify-content: center;
align-items: center;
padding: 0;
color: ${iconColor};
&[disabled], &[disabled],
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
color: ${theme.colors.action.disabledText}; color: ${theme.colors.action.disabledText};
opacity: 0.65; opacity: 0.65;
box-shadow: none;
} }
&:before { &:before {
content: ''; z-index: -1;
display: block;
opacity: 1;
position: absolute; position: absolute;
width: ${hoverSize}px;
height: ${hoverSize}px;
border-radius: ${theme.shape.radius.default};
content: '';
transition-duration: 0.2s; transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
z-index: -1;
bottom: -${hoverSize}px;
left: -${hoverSize}px;
right: -${hoverSize}px;
top: -${hoverSize}px;
background: none;
border-radius: 50%;
box-sizing: border-box;
transform: scale(0);
transition-property: transform, opacity; transition-property: transform, opacity;
} }
@ -136,22 +137,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, size: IconSize, variant:
} }
&:hover { &:hover {
color: ${iconColor};
&:before { &:before {
background-color: ${variant === 'secondary' background-color: ${variant === 'secondary'
? theme.colors.action.hover ? theme.colors.action.hover
: colorManipulator.alpha(iconColor, 0.12)}; : colorManipulator.alpha(iconColor, 0.12)};
border: none;
box-shadow: none;
opacity: 1;
transform: scale(0.8);
} }
} }
`, `,
icon: css` icon: css`
vertical-align: baseline; vertical-align: baseline;
display: flex;
`, `,
}; };
}); });

View File

@ -1,13 +1,13 @@
import { Meta, Preview, ArgTypes } from '@storybook/blocks'; import { css } from '@emotion/css';
import { Meta } from '@storybook/addon-docs/blocks';
import { PanelChrome } from './PanelChrome'; import { PanelChrome } from './PanelChrome';
import { LoadingIndicator } from './LoadingIndicator';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
import { HorizontalGroup } from '../Layout/Layout'; import { HorizontalGroup } from '../Layout/Layout';
import { LoadingState } from '@grafana/data'; import { LoadingState } from '@grafana/data';
import { Button } from '../Button/Button.tsx'; import { Button } from '../Button';
import { Menu } from '../Menu/Menu'; import { Menu } from '../Menu/Menu';
import { IconButton } from '../IconButton/IconButton';
<Meta title="MDX|PanelChrome" component={PanelChrome} /> <Meta title="MDX|PanelChrome" component={PanelChrome} />
@ -306,10 +306,17 @@ Component used for rendering content wrapped in the same style as grafana panels
<PanelChrome <PanelChrome
title="My awesome panel title" title="My awesome panel title"
titleItems={ titleItems={
<div> <>
<Button fill="text" icon="github" variant="secondary" tooltip="extra content to render" /> <IconButton
<Button fill="text" icon="sliders-v-alt" variant="secondary" tooltip="extra content2 to render" /> className={css`
</div> margin-right: 10px;
`}
name="github"
variant="secondary"
tooltip="extra content to render"
/>
<IconButton name="sliders-v-alt" variant="secondary" tooltip="extra content2 to render" />
</>
} }
actions={ actions={
<Button size="sm" variant="secondary" key="A"> <Button size="sm" variant="secondary" key="A">
@ -342,10 +349,17 @@ Component used for rendering content wrapped in the same style as grafana panels
<PanelChrome <PanelChrome
title="My awesome panel title" title="My awesome panel title"
titleItems={ titleItems={
<div> <>
<Button fill="text" icon="github" variant="secondary" tooltip="extra content to render" /> <IconButton
<Button fill="text" icon="sliders-v-alt" variant="secondary" tooltip="extra content2 to render" /> className={css`
</div> margin-right: 10px;
`}
name="github"
variant="secondary"
tooltip="extra content to render"
/>
<IconButton name="sliders-v-alt" variant="secondary" tooltip="extra content2 to render" />
</>
} }
actions={ actions={
<Button size="sm" variant="secondary" key="A"> <Button size="sm" variant="secondary" key="A">

View File

@ -71,9 +71,7 @@ return (
closeButton={true} closeButton={true}
onClose={onClose} onClose={onClose}
> >
<Button type="button"> <IconButton name="question-circle" tooltip="IconButton containing a Toggletip" />
<Icon name="question-circle" />
</Button>
</Toogletip> </Toogletip>
); );
``` ```

View File

@ -125,9 +125,8 @@ export const DynamicTable = <T extends object>({
<div className={cx(styles.cell, styles.expandCell)}> <div className={cx(styles.cell, styles.expandCell)}>
<IconButton <IconButton
aria-label={`${isItemExpanded ? 'Collapse' : 'Expand'} row`} aria-label={`${isItemExpanded ? 'Collapse' : 'Expand'} row`}
size="lg" size="md"
data-testid="collapse-toggle" data-testid="collapse-toggle"
className={styles.expandButton}
name={isItemExpanded ? 'angle-down' : 'angle-right'} name={isItemExpanded ? 'angle-down' : 'angle-right'}
onClick={() => toggleExpanded(item)} onClick={() => toggleExpanded(item)}
type="button" type="button"
@ -278,9 +277,5 @@ const getStyles = <T extends unknown>(
padding: ${theme.spacing(1)} 0 0 0; padding: ${theme.spacing(1)} 0 0 0;
} }
`, `,
expandButton: css`
margin-right: 0;
display: block;
`,
}); });
}; };