GrafanaUI: Define tooltip or aria-label as required for IconButton (#69699)

* refactor: modify interfaces to make tooltip or aria-label required

* refactor: change functionality around aria-label and tooltip

* refactor: change and add information in storybook documentation

* refactor: remove default from tooltip

* refactor: IconButton to make tooltip or aria-label required

* refactor: Fix tests

* refactor: Fix tests

* refactor: Fix tests

* refactor: Fix tests

* feat: add migration guide for breaking change

* feat: add latest requirements to storybook docs

* refactor: separate iconbutton story with and without tooltip

* refactor: remove exported baseArgs

* refactor: clean up and restructure original story

* refactor: adjust styling

* refactor: enable control for tooltip

* refactor: clean up

* refactor: enable control for aria-label

* refactor: fix theme getting the wrong theme

* refactor: fix tests

* refactor: adjust story

* refactor: remove confusing story

* refactor: adjust controls for stories
This commit is contained in:
Laura Benz
2023-06-23 17:10:37 +02:00
committed by GitHub
parent 8dc9fcf88b
commit d64b6264ff
22 changed files with 185 additions and 117 deletions
@@ -1,17 +1,23 @@
import { Meta, ArgTypes } from '@storybook/blocks';
import { IconButton } from './IconButton';
import { Icon } from '../Icon/Icon';
import { Alert } from '../Alert/Alert';
<Meta title="MDX|IconButton" component={IconButton} />
# IconButton
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.
This component looks just like an icon but behaves like a button. It fulfils an action when you click it and has a hover as well a focus states. You can choose which icon size you would like to use.
`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.
When using `IconButton` right next to a text element consider wrapping both in a flex container and use `align-items: center;` to make them align properly.
Always keep in mind to add text for a tooltip and an aria label.
There are two options to use the IconButton:
- with `Tooltip`: This is the preferred option since we don't want to rely on assumptions when it comes to the meaning an `Icon` has. Add a text for the `Tooltip`. It will be used for the `aria-label` as well.
- without `Tooltip`: This is an option for use cases where the `Icon` is unambiguous e.g <Icon name="angle-down" /> for expanding a folder. Add a text for the `aria-label` and there will **not** be a `Tooltip`.
The IconButton used to have a property called `ariaLabel` which got deprecated. You can now use the regular property `aria-label` instead.
<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
@@ -7,13 +7,16 @@ import { IconSize, IconName } from '../../types';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
import { IconButton, IconButtonVariant, Props as IconButtonProps } from './IconButton';
import { BasePropsWithTooltip, IconButton, IconButtonVariant, Props as IconButtonProps } from './IconButton';
import mdx from './IconButton.mdx';
interface ScenarioProps {
background: 'canvas' | 'primary' | 'secondary';
}
const defaultExcludes = ['ariaLabel', 'aria-label'];
const additionalExcludes = ['size', 'name', 'variant', 'iconType'];
const meta: Meta<typeof IconButton> = {
title: 'Buttons/IconButton',
component: IconButton,
@@ -22,6 +25,7 @@ const meta: Meta<typeof IconButton> = {
docs: {
page: mdx,
},
controls: { exclude: defaultExcludes },
},
args: {
name: 'apps',
@@ -30,7 +34,8 @@ const meta: Meta<typeof IconButton> = {
tooltip: 'sample tooltip message',
tooltipPlacement: 'top',
variant: 'secondary',
ariaLabel: 'sample aria-label content',
ariaLabel: 'this property is deprecated',
['aria-label']: 'sample aria-label content',
},
argTypes: {
tooltip: {
@@ -43,7 +48,7 @@ export const Basic: StoryFn<typeof IconButton> = (args: IconButtonProps) => {
return <IconButton {...args} />;
};
export const ExamplesSizes = () => {
export const ExamplesSizes = (args: BasePropsWithTooltip) => {
const theme = useTheme2();
const sizes: IconSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const icons: IconName[] = ['search', 'trash-alt', 'arrow-left', 'times'];
@@ -56,17 +61,22 @@ export const ExamplesSizes = () => {
`;
return (
<HorizontalGroup spacing="md">
<HorizontalGroup justify="center">
{variants.map((variant) => {
return (
<div key={variant}>
<div
key={variant}
className={css`
margin: auto ${theme.spacing(1)};
`}
>
<p>{variant}</p>
{icons.map((icon) => {
return (
<div className={rowStyle} key={icon}>
{sizes.map((size) => (
<span key={icon + size}>
<IconButton name={icon} size={size} variant={variant} tooltip="Tooltip example" />
<IconButton name={icon} size={size} variant={variant} tooltip={args.tooltip} />
</span>
))}
</div>
@@ -81,7 +91,7 @@ export const ExamplesSizes = () => {
<div className={rowStyle} key={icon}>
{sizes.map((size) => (
<span key={icon + size}>
<IconButton name={icon} size={size} tooltip="Tooltip example" disabled />
<IconButton name={icon} size={size} tooltip={args.tooltip} disabled />
</span>
))}
</div>
@@ -91,7 +101,42 @@ export const ExamplesSizes = () => {
);
};
export const ExamplesBackground = () => {
ExamplesSizes.parameters = {
controls: {
exclude: [...defaultExcludes, ...additionalExcludes],
},
};
export const ExamplesBackground = (args: BasePropsWithTooltip) => {
const RenderBackgroundScenario = ({ background }: ScenarioProps) => {
const theme = useTheme2();
const variants: IconButtonVariant[] = ['primary', 'secondary', 'destructive'];
return (
<div
className={css`
padding: 30px;
background: ${theme.colors.background[background]};
`}
>
<VerticalGroup spacing="md">
<div>{background}</div>
<div
className={css`
display: flex;
gap: ${theme.spacing(2)};
`}
>
{variants.map((variant) => {
return <IconButton name="times" size="xl" variant={variant} key={variant} tooltip={args.tooltip} />;
})}
<IconButton name="times" size="xl" tooltip={args.tooltip} disabled />
</div>
</VerticalGroup>
</div>
);
};
return (
<div>
<RenderBackgroundScenario background="canvas" />
@@ -101,33 +146,10 @@ export const ExamplesBackground = () => {
);
};
const RenderBackgroundScenario = ({ background }: ScenarioProps) => {
const theme = useTheme2();
const variants: IconButtonVariant[] = ['primary', 'secondary', 'destructive'];
return (
<div
className={css`
padding: 30px;
background: ${theme.colors.background[background]};
`}
>
<VerticalGroup spacing="md">
<div>{background}</div>
<div
className={css`
display: flex;
gap: ${theme.spacing(2)};
`}
>
{variants.map((variant) => {
return <IconButton name="times" size="xl" variant={variant} key={variant} tooltip="Tooltip example" />;
})}
<IconButton name="times" size="xl" tooltip="Tooltip example" disabled />
</div>
</VerticalGroup>
</div>
);
ExamplesBackground.parameters = {
controls: {
exclude: [...defaultExcludes, ...additionalExcludes],
},
};
export default meta;
@@ -15,76 +15,92 @@ export type IconButtonVariant = 'primary' | 'secondary' | 'destructive';
type LimitedIconSize = ComponentSize | 'xl';
export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
interface BaseProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label'> {
/** Name of the icon **/
name: IconName;
/** Icon size - sizes xxl and xxxl are deprecated and when used being decreased to xl*/
size?: IconSize;
/** Type of the icon - mono or default */
iconType?: IconType;
/** Tooltip content to display on hover */
tooltip?: PopoverContent;
/** Position of the tooltip */
tooltipPlacement?: TooltipPlacement;
/** Variant to change the color of the Icon */
variant?: IconButtonVariant;
/** Text available only for screen readers. Will use tooltip text as fallback. */
ariaLabel?: string;
}
export const IconButton = React.forwardRef<HTMLButtonElement, Props>(
(
{
name,
size = 'md',
iconType,
tooltip,
tooltipPlacement,
ariaLabel,
className,
variant = 'secondary',
...restProps
},
ref
) => {
const theme = useTheme2();
let limitedIconSize: LimitedIconSize;
export interface BasePropsWithTooltip extends BaseProps {
/** Tooltip content to display on hover and as the aria-label */
tooltip: PopoverContent;
/** Position of the tooltip */
tooltipPlacement?: TooltipPlacement;
}
// 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;
}
interface BasePropsWithAriaLabel extends BaseProps {
/** @deprecated use aria-label instead*/
ariaLabel?: string;
/** Text available only for screen readers. No tooltip will be set in this case. */
['aria-label']: string;
}
const styles = getStyles(theme, limitedIconSize, variant);
const tooltipString = typeof tooltip === 'string' ? tooltip : '';
export type Props = BasePropsWithTooltip | BasePropsWithAriaLabel;
// When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632
const button = (
export const IconButton = React.forwardRef<HTMLButtonElement, Props>((props, ref) => {
const { size = 'md', variant = 'secondary' } = props;
const theme = useTheme2();
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);
let ariaLabel: string | undefined;
let buttonRef: typeof ref | undefined;
if ('tooltip' in props) {
const { tooltip } = props;
ariaLabel = typeof tooltip === 'string' ? tooltip : undefined;
} else if ('ariaLabel' in props || 'aria-label' in props) {
const { ariaLabel: deprecatedAriaLabel, ['aria-label']: ariaLabelProp } = props;
ariaLabel = ariaLabelProp || deprecatedAriaLabel;
buttonRef = ref;
}
// When using tooltip, ref is forwarded to Tooltip component instead for https://github.com/grafana/grafana/issues/65632
if ('tooltip' in props) {
const { name, iconType, className, tooltip, tooltipPlacement, ...restProps } = props;
return (
<Tooltip ref={ref} content={tooltip} placement={tooltipPlacement}>
<button
{...restProps}
ref={buttonRef}
aria-label={ariaLabel}
className={cx(styles.button, className)}
type="button"
>
<Icon name={name} size={limitedIconSize} className={styles.icon} type={iconType} />
</button>
</Tooltip>
);
} else {
const { name, iconType, className, ...restProps } = props;
return (
<button
ref={tooltip ? undefined : ref}
aria-label={ariaLabel || tooltipString}
{...restProps}
ref={buttonRef}
aria-label={ariaLabel}
className={cx(styles.button, className)}
type="button"
>
<Icon name={name} size={limitedIconSize} className={styles.icon} type={iconType} />
</button>
);
if (tooltip) {
return (
<Tooltip ref={ref} content={tooltip} placement={tooltipPlacement}>
{button}
</Tooltip>
);
}
return button;
}
);
});
IconButton.displayName = 'IconButton';
@@ -84,7 +84,7 @@ export function Modal(props: PropsWithChildren<Props>) {
typeof title !== 'string' && title
}
<div className={styles.modalHeaderClose}>
<IconButton aria-label="Close dialog" name="times" size="xl" onClick={onDismiss} tooltip="Close" />
<IconButton name="times" size="xl" onClick={onDismiss} tooltip="Close" />
</div>
</div>
<div className={cx(styles.modalContent, contentClassName)}>{children}</div>
@@ -233,7 +233,7 @@ describe('SelectBase', () => {
expect(screen.getByLabelText('My select')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Remove Option 1'));
await userEvent.click(screen.getAllByLabelText('Remove')[0]);
expect(onChangeHandler).toHaveBeenCalledWith([], {
action: 'remove-value',
name: undefined,
@@ -28,8 +28,7 @@ export const TagItem = ({ name, disabled, onRemove }: Props) => {
name="times"
size="lg"
disabled={disabled}
ariaLabel={`Remove "${name}" tag`}
tooltip="Remove tag"
tooltip={`Remove "${name}" tag`}
onClick={() => onRemove(name)}
className={styles.buttonStyles}
/>