RefreshPicker: make widget accessible (#40570)

* adds better aria-label for run and interval buttons

* enable refreshPicker to be keyboard navigable

* adds support for closing menu using esc key

* Fix: weird behaviour when navigating menu items

* adds focus trapping to refresh picker

* WIP: sanitize time interval values for screen readers to pronounce correctly

* WIP: improve sanitizeLabel function to work for all use cases

* Chore: move label sanitization to refreshPicker component instead

* Chore: add fallback label when ariaLabel prop is not set

* Chore: fix some type errors

* code cleanup

* update tests

* rename function to be more descriptive

* remove unnecessary type casting

* WIP: use cleaner solution

* WIP: use parseDuration util instead

* use more descriptive aria label

* fix: modify parseDuration util to output correct interval unit format

* fix: move interval unit format logic to refreshPicker

* Chore: add back old refreshPicker e2e selectors for backward compatibility

* Fix: improve refresh picker to voice out selected interval option

* Fix: use appropriate aria roles and states to aid screen reader a11y

* Fix: support dropdown expansion using down arrow key

* Chore: use better type construct

* Fix: add support for tab to close menu

* add more context to the deprecation warning message

* Chore: use formatDuration util instead to format interval labels

* Chore: small syntax fix

* chore: syntax fix

* syntax fix

* Chore: add back lockfile
This commit is contained in:
Uchechukwu Obasi 2021-11-10 11:01:06 +01:00 committed by GitHub
parent 2139a3dfa8
commit e7fd41d779
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 146 additions and 76 deletions

View File

@ -9,7 +9,7 @@ e2e.scenario({
scenario: () => {
e2e.pages.Explore.visit();
e2e.pages.Explore.General.container().should('have.length', 1);
e2e.components.RefreshPicker.runButton().should('have.length', 1);
e2e.components.RefreshPicker.runButtonV2().should('have.length', 1);
e2e.components.DataSource.TestData.QueryTab.scenarioSelectContainer()
.should('be.visible')

View File

@ -19,7 +19,7 @@ describe('Trace view', () => {
e2e().wait(500);
e2e.components.RefreshPicker.runButton().should('be.visible').click();
e2e.components.RefreshPicker.runButtonV2().should('be.visible').click();
e2e().wait('@longTrace');

View File

@ -137,8 +137,16 @@ export const Components = {
active: () => '[class*="-activeTabStyle"]',
},
RefreshPicker: {
/**
* @deprecated use runButtonV2 from Grafana 8.3 instead
*/
runButton: 'RefreshPicker run button',
/**
* @deprecated use intervalButtonV2 from Grafana 8.3 instead
*/
intervalButton: 'RefreshPicker interval button',
runButtonV2: 'data-testid RefreshPicker run button',
intervalButtonV2: 'data-testid RefreshPicker interval button',
},
QueryTab: {
content: 'Query editor tab content',

View File

@ -48,6 +48,7 @@
"clipboard": "2.0.4",
"core-js": "3.10.0",
"d3": "5.15.0",
"date-fns": "2.25.0",
"emotion": "11.0.0",
"hoist-non-react-statics": "3.3.2",
"immutable": "3.8.2",

View File

@ -7,6 +7,7 @@ import { css } from '@emotion/css';
import { useStyles2 } from '../../themes/ThemeContext';
import { Menu } from '../Menu/Menu';
import { MenuItem } from '../Menu/MenuItem';
import { FocusScope } from '@react-aria/focus';
export interface Props<T> extends HTMLAttributes<HTMLButtonElement> {
className?: string;
@ -37,6 +38,13 @@ const ButtonSelectComponent = <T,>(props: Props<T>) => {
setIsOpen(!isOpen);
};
const onArrowKeyDown = (event: React.KeyboardEvent) => {
event.stopPropagation();
if (event.key === 'ArrowDown' || event.key === 'Enter') {
setIsOpen(!isOpen);
}
};
const onChangeInternal = (item: SelectableValue<T>) => {
onChange(item);
setIsOpen(false);
@ -48,6 +56,7 @@ const ButtonSelectComponent = <T,>(props: Props<T>) => {
className={className}
isOpen={isOpen}
onClick={onToggle}
onKeyDown={onArrowKeyDown}
narrow={narrow}
variant={variant}
{...restProps}
@ -56,17 +65,22 @@ const ButtonSelectComponent = <T,>(props: Props<T>) => {
</ToolbarButton>
{isOpen && (
<div className={styles.menuWrapper}>
<ClickOutsideWrapper onClick={onCloseMenu} parent={document}>
<Menu>
<ClickOutsideWrapper onClick={onCloseMenu} parent={document} includeButtonPress={false}>
<FocusScope contain autoFocus restoreFocus>
<Menu onClose={onCloseMenu}>
{options.map((item) => (
<MenuItem
key={`${item.value}`}
label={(item.label || item.value) as string}
onClick={() => onChangeInternal(item)}
active={item.value === value?.value}
ariaChecked={item.value === value?.value}
ariaLabel={item.ariaLabel || item.label}
role="menuitemradio"
/>
))}
</Menu>
</FocusScope>
</ClickOutsideWrapper>
</div>
)}

View File

@ -11,6 +11,7 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
ariaLabel?: string;
onOpen?: (focusOnItem: (itemId: number) => void) => void;
onClose?: () => void;
onKeyDown?: React.KeyboardEventHandler;
}
@ -20,7 +21,7 @@ type MenuItemElement = HTMLAnchorElement & HTMLButtonElement;
/** @internal */
export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
({ header, children, ariaLabel, onOpen, onKeyDown, ...otherProps }, forwardedRef) => {
({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => {
const styles = useStyles2(getStyles);
const [focusedItem, setFocusedItem] = useState(UNFOCUSED);
@ -39,7 +40,7 @@ export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
useEffectOnce(() => {
const firstMenuItem = localRef?.current?.querySelector(`[data-role="menuitem"]`) as MenuItemElement | null;
if (firstMenuItem) {
firstMenuItem.tabIndex = 0;
setFocusedItem(0);
}
onOpen?.(setFocusedItem);
});
@ -68,6 +69,14 @@ export const Menu = React.forwardRef<HTMLDivElement, MenuProps>(
event.stopPropagation();
setFocusedItem(menuItemsCount - 1);
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
onClose?.();
break;
case 'Tab':
onClose?.();
break;
default:
break;
}

View File

@ -11,10 +11,14 @@ export interface MenuItemProps<T = any> {
label: string;
/** Aria label for accessibility support */
ariaLabel?: string;
/** Aria checked for accessibility support */
ariaChecked?: boolean;
/** Target of the menu item (i.e. new window) */
target?: LinkTarget;
/** Icon of the menu item */
icon?: IconName;
/** Role of the menu item */
role?: string;
/** Url of the menu item */
url?: string;
/** Handler for the click behaviour */
@ -29,8 +33,20 @@ export interface MenuItemProps<T = any> {
/** @internal */
export const MenuItem = React.memo(
React.forwardRef<HTMLAnchorElement & HTMLButtonElement, MenuItemProps>(
({ url, icon, label, ariaLabel, target, onClick, className, active, tabIndex = -1 }, ref) => {
React.forwardRef<HTMLAnchorElement & HTMLButtonElement, MenuItemProps>((props, ref) => {
const {
url,
icon,
label,
ariaLabel,
ariaChecked,
target,
onClick,
className,
active,
role = 'menuitem',
tabIndex = -1,
} = props;
const styles = useStyles2(getStyles);
const itemStyle = cx(
{
@ -57,17 +73,17 @@ export const MenuItem = React.memo(
}
: undefined
}
role={url === undefined ? 'menuitem' : undefined}
role={url === undefined ? role : undefined}
data-role="menuitem" // used to identify menuitem in Menu.tsx
ref={ref}
aria-label={ariaLabel}
aria-checked={ariaChecked}
tabIndex={tabIndex}
>
{icon && <Icon name={icon} className={styles.icon} aria-hidden />} {label}
</Wrapper>
);
}
)
})
);
MenuItem.displayName = 'MenuItem';

View File

@ -7,17 +7,17 @@ describe('RefreshPicker', () => {
const result = intervalsToOptions();
expect(result).toEqual([
{ value: '', label: 'Off' },
{ value: '5s', label: '5s' },
{ value: '10s', label: '10s' },
{ value: '30s', label: '30s' },
{ value: '1m', label: '1m' },
{ value: '5m', label: '5m' },
{ value: '15m', label: '15m' },
{ value: '30m', label: '30m' },
{ value: '1h', label: '1h' },
{ value: '2h', label: '2h' },
{ value: '1d', label: '1d' },
{ value: '', label: 'Off', ariaLabel: 'Turn off auto refresh' },
{ value: '5s', label: '5s', ariaLabel: '5 seconds' },
{ value: '10s', label: '10s', ariaLabel: '10 seconds' },
{ value: '30s', label: '30s', ariaLabel: '30 seconds' },
{ value: '1m', label: '1m', ariaLabel: '1 minute' },
{ value: '5m', label: '5m', ariaLabel: '5 minutes' },
{ value: '15m', label: '15m', ariaLabel: '15 minutes' },
{ value: '30m', label: '30m', ariaLabel: '30 minutes' },
{ value: '1h', label: '1h', ariaLabel: '1 hour' },
{ value: '2h', label: '2h', ariaLabel: '2 hours' },
{ value: '1d', label: '1d', ariaLabel: '1 day' },
]);
});
});
@ -29,9 +29,9 @@ describe('RefreshPicker', () => {
const result = intervalsToOptions({ intervals });
expect(result).toEqual([
{ value: '', label: 'Off' },
{ value: '5s', label: '5s' },
{ value: '10s', label: '10s' },
{ value: '', label: 'Off', ariaLabel: 'Turn off auto refresh' },
{ value: '5s', label: '5s', ariaLabel: '5 seconds' },
{ value: '10s', label: '10s', ariaLabel: '10 seconds' },
]);
});
});

View File

@ -1,11 +1,13 @@
import React, { PureComponent } from 'react';
import { SelectableValue } from '@grafana/data';
import formatDuration from 'date-fns/formatDuration';
import { SelectableValue, parseDuration } from '@grafana/data';
import { ButtonSelect } from '../Dropdown/ButtonSelect';
import { ButtonGroup, ToolbarButton, ToolbarButtonVariant } from '../Button';
import { selectors } from '@grafana/e2e-selectors';
// Default intervals used in the refresh picker component
export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'];
const offLabel = 'Auto refresh turned off. Choose refresh time interval';
export interface Props {
intervals?: string[];
@ -22,8 +24,8 @@ export interface Props {
}
export class RefreshPicker extends PureComponent<Props> {
static offOption = { label: 'Off', value: '' };
static liveOption = { label: 'Live', value: 'LIVE' };
static offOption = { label: 'Off', value: '', ariaLabel: 'Turn off auto refresh' };
static liveOption = { label: 'Live', value: 'LIVE', ariaLabel: 'Turn on live streaming' };
static isLive = (refreshInterval?: string): boolean => refreshInterval === RefreshPicker.liveOption.value;
constructor(props: Props) {
@ -71,7 +73,7 @@ export class RefreshPicker extends PureComponent<Props> {
onClick={onRefresh}
variant={variant}
icon={isLoading ? 'fa fa-spinner' : 'sync'}
aria-label={selectors.components.RefreshPicker.runButton}
data-testid={selectors.components.RefreshPicker.runButtonV2}
>
{text}
</ToolbarButton>
@ -81,7 +83,12 @@ export class RefreshPicker extends PureComponent<Props> {
options={options}
onChange={this.onChangeSelect as any}
variant={variant}
aria-label={selectors.components.RefreshPicker.intervalButton}
data-testid={selectors.components.RefreshPicker.intervalButtonV2}
aria-label={
selectedValue.value === ''
? offLabel
: `Choose refresh time interval with current interval ${selectedValue.ariaLabel} selected`
}
/>
)}
</ButtonGroup>
@ -93,7 +100,21 @@ export function intervalsToOptions({ intervals = defaultIntervals }: { intervals
SelectableValue<string>
> {
const intervalsOrDefault = intervals || defaultIntervals;
const options = intervalsOrDefault.map((interval) => ({ label: interval, value: interval }));
const options = intervalsOrDefault.map((interval) => {
const duration: { [key: string]: string | number } = parseDuration(interval);
const key = Object.keys(duration)[0];
const value = duration[key];
duration[key] = Number(value);
const ariaLabel = formatDuration(duration);
return {
label: interval,
value: interval,
ariaLabel: ariaLabel,
};
});
options.unshift(RefreshPicker.offOption);
return options;

View File

@ -34,14 +34,14 @@ describe('DashNavTimeControls', () => {
const container = render(
<DashNavTimeControls dashboard={dashboardModel} onChangeTimeZone={jest.fn()} key="time-controls" />
);
expect(container.queryByLabelText(/RefreshPicker run button/i)).toBeInTheDocument();
expect(container.queryByLabelText(/Refresh dashboard/i)).toBeInTheDocument();
});
it('renders RefreshPicker with interval button in panel view', () => {
const container = render(
<DashNavTimeControls dashboard={dashboardModel} onChangeTimeZone={jest.fn()} key="time-controls" />
);
expect(container.queryByLabelText(/RefreshPicker interval button/i)).toBeInTheDocument();
expect(container.queryByLabelText(/Choose refresh time interval/i)).toBeInTheDocument();
});
it('should not render RefreshPicker interval button in panel edit', () => {
@ -51,7 +51,7 @@ describe('DashNavTimeControls', () => {
const container = render(
<DashNavTimeControls dashboard={dashboardModel} onChangeTimeZone={jest.fn()} key="time-controls" />
);
expect(container.queryByLabelText(/RefreshPicker interval button/i)).not.toBeInTheDocument();
expect(container.queryByLabelText(/Choose refresh time interval/i)).not.toBeInTheDocument();
});
it('should render RefreshPicker run button in panel edit', () => {
@ -61,6 +61,6 @@ describe('DashNavTimeControls', () => {
const container = render(
<DashNavTimeControls dashboard={dashboardModel} onChangeTimeZone={jest.fn()} key="time-controls" />
);
expect(container.queryByLabelText(/RefreshPicker run button/i)).toBeInTheDocument();
expect(container.queryByLabelText(/Refresh dashboard/i)).toBeInTheDocument();
});
});

View File

@ -2766,6 +2766,7 @@ __metadata:
css-minimizer-webpack-plugin: ^3.1.1
csstype: 3.0.9
d3: 5.15.0
date-fns: 2.25.0
emotion: 11.0.0
enzyme: 3.11.0
expose-loader: 3.0.0