mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
2139a3dfa8
commit
e7fd41d779
@ -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')
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
{options.map((item) => (
|
||||
<MenuItem
|
||||
key={`${item.value}`}
|
||||
label={(item.label || item.value) as string}
|
||||
onClick={() => onChangeInternal(item)}
|
||||
active={item.value === value?.value}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,45 +33,57 @@ 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) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const itemStyle = cx(
|
||||
{
|
||||
[styles.item]: true,
|
||||
[styles.activeItem]: active,
|
||||
},
|
||||
className
|
||||
);
|
||||
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(
|
||||
{
|
||||
[styles.item]: true,
|
||||
[styles.activeItem]: active,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const Wrapper = url === undefined ? 'button' : 'a';
|
||||
return (
|
||||
<Wrapper
|
||||
target={target}
|
||||
className={itemStyle}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
href={url}
|
||||
onClick={
|
||||
onClick
|
||||
? (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) {
|
||||
event.preventDefault();
|
||||
onClick(event);
|
||||
}
|
||||
const Wrapper = url === undefined ? 'button' : 'a';
|
||||
return (
|
||||
<Wrapper
|
||||
target={target}
|
||||
className={itemStyle}
|
||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||
href={url}
|
||||
onClick={
|
||||
onClick
|
||||
? (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) {
|
||||
event.preventDefault();
|
||||
onClick(event);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={url === undefined ? 'menuitem' : undefined}
|
||||
data-role="menuitem" // used to identify menuitem in Menu.tsx
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{icon && <Icon name={icon} className={styles.icon} aria-hidden />} {label}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
: 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';
|
||||
|
||||
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user