Button/Link Focus: Use focus-visible for keyboard focus (#33066)

* outside react approach

* fixed ts issues and updated radio button

* css only solution

* Removed a bit

* Updated radio button design and fixed focus state on ToolbarButton

* Fixes

* Added missing fullWidth
This commit is contained in:
Torkel Ödegaard
2021-04-16 15:22:31 +02:00
committed by GitHub
parent 555da77527
commit 733bb45172
11 changed files with 152 additions and 165 deletions

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Story } from '@storybook/react'; import { Story } from '@storybook/react';
import { allButtonVariants, Button, ButtonProps } from './Button'; import { allButtonVariants, Button, ButtonProps } from './Button';
import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
import { iconOptions } from '../../utils/storybook/knobs'; import { iconOptions } from '../../utils/storybook/knobs';
import mdx from './Button.mdx'; import mdx from './Button.mdx';
import { HorizontalGroup, VerticalGroup } from '../Layout/Layout'; import { HorizontalGroup, VerticalGroup } from '../Layout/Layout';
@ -12,7 +11,6 @@ import { Card } from '../Card/Card';
export default { export default {
title: 'Buttons/Button', title: 'Buttons/Button',
component: Button, component: Button,
decorators: [withCenteredStory, withHorizontallyCenteredStory],
argTypes: { argTypes: {
variant: { control: { type: 'select', options: ['primary', 'secondary', 'destructive', 'link'] } }, variant: { control: { type: 'select', options: ['primary', 'secondary', 'destructive', 'link'] } },
size: { control: { type: 'select', options: ['sm', 'md', 'lg'] } }, size: { control: { type: 'select', options: ['sm', 'md', 'lg'] } },

View File

@ -5,7 +5,7 @@ import { IconName } from '../../types/icon';
import { getPropertiesForButtonSize } from '../Forms/commonStyles'; import { getPropertiesForButtonSize } from '../Forms/commonStyles';
import { colorManipulator, GrafanaTheme, GrafanaThemeV2, ThemePaletteColor } from '@grafana/data'; import { colorManipulator, GrafanaTheme, GrafanaThemeV2, ThemePaletteColor } from '@grafana/data';
import { ComponentSize } from '../../types/size'; import { ComponentSize } from '../../types/size';
import { getFocusStyles } from '../../themes/mixins'; import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'link'; export type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'link';
@ -34,7 +34,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
}); });
return ( return (
<button className={cx(styles.button, className)} {...otherProps} ref={ref}> <button className={cx(styles.button, className)} {...otherProps}>
{icon && <Icon name={icon} size={size} className={styles.icon} />} {icon && <Icon name={icon} size={size} className={styles.icon} />}
{children && <span className={styles.content}>{children}</span>} {children && <span className={styles.content}>{children}</span>}
</button> </button>
@ -47,7 +47,21 @@ Button.displayName = 'Button';
type ButtonLinkProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & AnchorHTMLAttributes<HTMLAnchorElement>; type ButtonLinkProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement> & AnchorHTMLAttributes<HTMLAnchorElement>;
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>( export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
({ variant = 'primary', size = 'md', icon, fullWidth, children, className, disabled, ...otherProps }, ref) => { (
{
variant = 'primary',
size = 'md',
icon,
fullWidth,
children,
className,
onBlur,
onFocus,
disabled,
...otherProps
},
ref
) => {
const theme = useTheme(); const theme = useTheme();
const styles = getButtonStyles({ const styles = getButtonStyles({
theme, theme,
@ -60,7 +74,7 @@ export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
const linkButtonStyles = cx(styles.button, { [styles.disabled]: disabled }, className); const linkButtonStyles = cx(styles.button, { [styles.disabled]: disabled }, className);
return ( return (
<a className={linkButtonStyles} {...otherProps} ref={ref} tabIndex={disabled ? -1 : 0}> <a className={linkButtonStyles} {...otherProps} tabIndex={disabled ? -1 : 0}>
{icon && <Icon name={icon} size={size} className={styles.icon} />} {icon && <Icon name={icon} size={size} className={styles.icon} />}
{children && <span className={styles.content}>{children}</span>} {children && <span className={styles.content}>{children}</span>}
</a> </a>
@ -99,6 +113,8 @@ export const getButtonStyles = (props: StyleProps) => {
}, },
}; };
const focusStyle = getFocusStyles(theme.v2);
return { return {
button: css({ button: css({
label: 'button', label: 'button',
@ -114,6 +130,9 @@ export const getButtonStyles = (props: StyleProps) => {
verticalAlign: 'middle', verticalAlign: 'middle',
cursor: 'pointer', cursor: 'pointer',
borderRadius: theme.v2.shape.borderRadius(1), borderRadius: theme.v2.shape.borderRadius(1),
'&:focus': focusStyle,
'&:focus-visible': focusStyle,
'&:focus:not(:focus-visible)': getMouseFocusStyles(theme.v2),
...(fullWidth && { ...(fullWidth && {
flexGrow: 1, flexGrow: 1,
justifyContent: 'center', justifyContent: 'center',
@ -155,10 +174,6 @@ function getButtonVariantStyles(theme: GrafanaThemeV2, color: ThemePaletteColor)
color: color.contrastText, color: color.contrastText,
boxShadow: theme.shadows.z2, boxShadow: theme.shadows.z2,
}, },
'&:focus': {
...getFocusStyles(theme),
},
}; };
} }

View File

@ -8,6 +8,7 @@ import { Icon } from '../Icon/Icon';
import { getPropertiesForVariant } from './Button'; import { getPropertiesForVariant } from './Button';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { focusCss, getMouseFocusStyles } from '../../themes/mixins';
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Icon name */ /** Icon name */
@ -117,14 +118,20 @@ const getStyles = (theme: GrafanaTheme) => {
border-radius: ${theme.v2.shape.borderRadius()}; border-radius: ${theme.v2.shape.borderRadius()};
line-height: ${theme.v2.components.height.md * theme.v2.spacing.gridSize - 2}px; line-height: ${theme.v2.components.height.md * theme.v2.spacing.gridSize - 2}px;
font-weight: ${theme.v2.typography.fontWeightMedium}; font-weight: ${theme.v2.typography.fontWeightMedium};
border: 1px solid ${theme.v2.palette.border1}; border: 1px solid ${theme.v2.palette.border1};
white-space: nowrap; white-space: nowrap;
transition: ${theme.v2.transitions.create(['background', 'box-shadow', 'border-color', 'color'], { transition: ${theme.v2.transitions.create(['background', 'box-shadow', 'border-color', 'color'], {
duration: theme.v2.transitions.duration.short, duration: theme.v2.transitions.duration.short,
})}, })};
&:focus { &:focus,
outline: none; &:focus-visible {
${focusCss(theme)}
z-index: 1;
}
&:focus:not(:focus-visible) {
${getMouseFocusStyles(theme.v2)}
} }
&:hover { &:hover {
@ -143,7 +150,7 @@ const getStyles = (theme: GrafanaTheme) => {
background: ${theme.v2.palette.action.disabledBackground}; background: ${theme.v2.palette.action.disabledBackground};
box-shadow: none; box-shadow: none;
} }
} }
`, `,
default: css` default: css`
color: ${theme.v2.palette.text.secondary}; color: ${theme.v2.palette.text.secondary};

View File

@ -3,7 +3,7 @@ import { useTheme, stylesFactory } from '../../../themes';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { getPropertiesForButtonSize } from '../commonStyles'; import { getPropertiesForButtonSize } from '../commonStyles';
import { focusCss } from '../../../themes/mixins'; import { getFocusStyles, getMouseFocusStyles } from '../../../themes/mixins';
export type RadioButtonSize = 'sm' | 'md'; export type RadioButtonSize = 'sm' | 'md';
@ -18,73 +18,6 @@ export interface RadioButtonProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme, size: RadioButtonSize, fullWidth?: boolean) => {
const { fontSize, height, padding } = getPropertiesForButtonSize(size, theme.v2);
const textColor = theme.v2.palette.text.secondary;
const textColorHover = theme.v2.palette.text.primary;
const textColorActive = theme.v2.palette.primary.text;
const borderColor = theme.v2.components.form.border;
const borderColorHover = theme.v2.components.form.borderHover;
const borderColorActive = theme.v2.components.form.border;
const bg = theme.colors.bodyBg;
const bgActive = theme.v2.palette.layer2;
const border = `1px solid ${borderColor}`;
const borderActive = `1px solid ${borderColorActive}`;
const borderHover = `1px solid ${borderColorHover}`;
return {
radio: css`
position: absolute;
opacity: 0;
z-index: -1000;
&:checked + label {
border: ${borderActive};
color: ${textColorActive};
background: ${bgActive};
z-index: 3;
}
&:focus + label {
${focusCss(theme)};
z-index: 3;
}
&:disabled + label {
cursor: default;
color: ${theme.v2.palette.text.disabled};
cursor: not-allowed;
}
`,
radioLabel: css`
display: inline-block;
position: relative;
font-size: ${fontSize};
height: ${theme.v2.spacing(height)};
// Deduct border from line-height for perfect vertical centering on windows and linux
line-height: ${theme.v2.spacing.gridSize * height - 2}px;
color: ${textColor};
padding: ${theme.v2.spacing(0, padding)};
margin-left: -1px;
border-radius: ${theme.border.radius.sm};
border: ${border};
background: ${bg};
cursor: pointer;
z-index: 1;
flex: ${fullWidth ? `1 0 0` : 'none'};
text-align: center;
user-select: none;
&:hover {
color: ${textColorHover};
border: ${borderHover};
z-index: 2;
}
`,
};
});
export const RadioButton: React.FC<RadioButtonProps> = ({ export const RadioButton: React.FC<RadioButtonProps> = ({
children, children,
active = false, active = false,
@ -118,3 +51,62 @@ export const RadioButton: React.FC<RadioButtonProps> = ({
}; };
RadioButton.displayName = 'RadioButton'; RadioButton.displayName = 'RadioButton';
const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme, size: RadioButtonSize, fullWidth?: boolean) => {
const { fontSize, height, padding } = getPropertiesForButtonSize(size, theme.v2);
const textColor = theme.v2.palette.text.secondary;
const textColorHover = theme.v2.palette.text.primary;
const bg = theme.colors.bodyBg;
return {
radio: css`
position: absolute;
opacity: 0;
z-index: -1000;
&:checked + label {
color: ${theme.v2.palette.text.primary};
font-weight: ${theme.v2.typography.fontWeightMedium};
background: ${theme.v2.palette.action.selected};
z-index: 3;
}
&:focus + label,
&:focus-visible + label {
${getFocusStyles(theme.v2)};
}
&:focus:not(:focus-visible) + label {
${getMouseFocusStyles(theme.v2)}
}
&:disabled + label {
cursor: default;
color: ${theme.v2.palette.text.disabled};
cursor: not-allowed;
}
`,
radioLabel: css`
display: inline-block;
position: relative;
font-size: ${fontSize};
height: ${theme.v2.spacing(height)};
// Deduct border from line-height for perfect vertical centering on windows and linux
line-height: ${theme.v2.spacing.gridSize * height - 2}px;
color: ${textColor};
padding: ${theme.v2.spacing(0, padding)};
border-radius: ${theme.v2.shape.borderRadius()};
background: ${bg};
cursor: pointer;
z-index: 1;
flex: ${fullWidth ? `1 0 0` : 'none'};
text-align: center;
user-select: none;
&:hover {
color: ${textColorHover};
}
`,
};
});

View File

@ -1,41 +1,11 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useRef } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import uniqueId from 'lodash/uniqueId'; import uniqueId from 'lodash/uniqueId';
import { SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { RadioButtonSize, RadioButton } from './RadioButton'; import { RadioButtonSize, RadioButton } from './RadioButton';
import { Icon } from '../../Icon/Icon'; import { Icon } from '../../Icon/Icon';
import { IconName } from '../../../types/icon'; import { IconName } from '../../../types/icon';
import { useStyles } from '../../../themes';
const getRadioButtonGroupStyles = () => {
return {
wrapper: css`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
position: relative;
`,
radioGroup: css`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
label {
border-radius: 0px;
&:first-of-type {
border-radius: 2px 0px 0px 2px;
}
&:last-of-type {
border-radius: 0px 2px 2px 0px;
}
}
`,
icon: css`
margin-right: 6px;
`,
};
};
export interface RadioButtonGroupProps<T> { export interface RadioButtonGroupProps<T> {
value?: T; value?: T;
@ -70,10 +40,10 @@ export function RadioButtonGroup<T>({
); );
const id = uniqueId('radiogroup-'); const id = uniqueId('radiogroup-');
const groupName = useRef(id); const groupName = useRef(id);
const styles = getRadioButtonGroupStyles(); const styles = useStyles(getStyles);
return ( return (
<div className={cx(styles.radioGroup, className)}> <div className={cx(styles.radioGroup, fullWidth && styles.fullWidth, className)}>
{options.map((o, i) => { {options.map((o, i) => {
const isItemDisabled = disabledOptions && o.value && disabledOptions.includes(o.value); const isItemDisabled = disabledOptions && o.value && disabledOptions.includes(o.value);
return ( return (
@ -85,8 +55,8 @@ export function RadioButtonGroup<T>({
onChange={handleOnChange(o)} onChange={handleOnChange(o)}
id={`option-${o.value}-${id}`} id={`option-${o.value}-${id}`}
name={groupName.current} name={groupName.current}
fullWidth={fullWidth}
description={o.description} description={o.description}
fullWidth={fullWidth}
> >
{o.icon && <Icon name={o.icon as IconName} className={styles.icon} />} {o.icon && <Icon name={o.icon as IconName} className={styles.icon} />}
{o.label} {o.label}
@ -98,3 +68,22 @@ export function RadioButtonGroup<T>({
} }
RadioButtonGroup.displayName = 'RadioButtonGroup'; RadioButtonGroup.displayName = 'RadioButtonGroup';
const getStyles = (theme: GrafanaTheme) => {
return {
radioGroup: css({
display: 'inline-flex',
flexDirection: 'row',
flexWrap: 'nowrap',
border: `1px solid ${theme.v2.components.form.border}`,
borderRadius: theme.v2.shape.borderRadius(),
padding: '2px',
}),
fullWidth: css({
display: 'flex',
}),
icon: css`
margin-right: 6px;
`,
};
};

View File

@ -7,7 +7,7 @@ import { useTheme } from '../../themes/ThemeContext';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { Tooltip } from '../Tooltip/Tooltip'; import { Tooltip } from '../Tooltip/Tooltip';
import { TooltipPlacement } from '../Tooltip/PopoverController'; import { TooltipPlacement } from '../Tooltip/PopoverController';
import { focusCss } from '../../themes/mixins'; import { focusCss, getMouseFocusStyles } from '../../themes/mixins';
export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Name of the icon **/ /** Name of the icon **/
@ -69,6 +69,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme, size: IconSize) => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
border-radius: ${theme.v2.shape.borderRadius()};
z-index: 0; z-index: 0;
margin-right: ${theme.spacing.xs}; margin-right: ${theme.spacing.xs};
@ -98,10 +99,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme, size: IconSize) => {
transition-property: transform, opacity; transition-property: transform, opacity;
} }
&:focus { &:focus,
&:focus-visible {
${focusCss(theme)} ${focusCss(theme)}
} }
&:focus:not(:focus-visible) {
${getMouseFocusStyles(theme.v2)}
}
&:hover { &:hover {
color: ${theme.colors.linkHover}; color: ${theme.colors.linkHover};

View File

@ -46,11 +46,19 @@ export const focusCss = (theme: GrafanaTheme) => `
transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1); transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1);
`; `;
export function getMouseFocusStyles(theme: GrafanaThemeV2): CSSObject {
return {
outline: 'none',
boxShadow: `${theme.shadows.z1}`,
transition: theme.transitions.create('box-shadow'),
};
}
export function getFocusStyles(theme: GrafanaThemeV2): CSSObject { export function getFocusStyles(theme: GrafanaThemeV2): CSSObject {
return { return {
outline: '2px dotted transparent', outline: '2px dotted transparent',
outlineOffset: '2px', outlineOffset: '2px',
boxShadow: `0 0 0 2px ${theme.palette.layer0}, 0 0 0px 4px ${theme.palette.primary.border}`, boxShadow: `0 0 0 2px ${theme.palette.layer0}, 0 0 0px 4px ${theme.palette.primary.main}`,
transition: `all 0.2s cubic-bezier(0.19, 1, 0.22, 1)`, transition: `all 0.2s cubic-bezier(0.19, 1, 0.22, 1)`,
}; };
} }

View File

@ -11,6 +11,7 @@ import {
} from '../../copy/appNotification'; } from '../../copy/appNotification';
import { AppEvents } from '@grafana/data'; import { AppEvents } from '@grafana/data';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { VerticalGroup } from '@grafana/ui';
export interface OwnProps {} export interface OwnProps {}
@ -45,15 +46,17 @@ export class AppNotificationListUnConnected extends PureComponent<Props> {
return ( return (
<div className="page-alert-list"> <div className="page-alert-list">
{appNotifications.map((appNotification, index) => { <VerticalGroup>
return ( {appNotifications.map((appNotification, index) => {
<AppNotificationItem return (
key={`${appNotification.id}-${index}`} <AppNotificationItem
appNotification={appNotification} key={`${appNotification.id}-${index}`}
onClearNotification={(id) => this.onClearAppNotification(id)} appNotification={appNotification}
/> onClearNotification={(id) => this.onClearAppNotification(id)}
); />
})} );
})}
</VerticalGroup>
</div> </div>
); );
} }

View File

@ -130,6 +130,10 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
white-space: nowrap; white-space: nowrap;
&:focus {
outline: none;
}
`, `,
dragIcon: css` dragIcon: css`
cursor: drag; cursor: drag;

View File

@ -11,7 +11,6 @@ import './jquery_extended';
import './partials'; import './partials';
import './components/jsontree/jsontree'; import './components/jsontree/jsontree';
import './components/code_editor/code_editor'; import './components/code_editor/code_editor';
import './utils/outline';
import './components/colorpicker/spectrum_picker'; import './components/colorpicker/spectrum_picker';
import './services/search_srv'; import './services/search_srv';
import './services/ng_react'; import './services/ng_react';

View File

@ -1,34 +0,0 @@
// based on http://www.paciellogroup.com/blog/2012/04/how-to-remove-css-outlines-in-an-accessible-manner/
function outlineFixer() {
const d: any = document;
const styleElement = d.createElement('STYLE');
const domEvents = 'addEventListener' in d;
const addEventListener = (type: string, callback: { (): void; (): void }) => {
// Basic cross-browser event handling
if (domEvents) {
d.addEventListener(type, callback);
} else {
d.attachEvent('on' + type, callback);
}
};
const setCss = (cssText: string) => {
// Handle setting of <style> element contents in IE8
!!styleElement.styleSheet ? (styleElement.styleSheet.cssText = cssText) : (styleElement.innerHTML = cssText);
};
d.getElementsByTagName('HEAD')[0].appendChild(styleElement);
// Using mousedown instead of mouseover, so that previously focused elements don't lose focus ring on mouse move
addEventListener('mousedown', () => {
setCss(':focus{outline:0 !important}::-moz-focus-inner{border:0;}');
});
addEventListener('keydown', () => {
setCss('');
});
}
outlineFixer();