diff --git a/e2e/suite1/specs/explore.spec.ts b/e2e/suite1/specs/explore.spec.ts index 91872ff59cd..4e089a27b79 100644 --- a/e2e/suite1/specs/explore.spec.ts +++ b/e2e/suite1/specs/explore.spec.ts @@ -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') diff --git a/e2e/suite1/specs/trace-view-scrolling.spec.ts b/e2e/suite1/specs/trace-view-scrolling.spec.ts index b7436dc6e6c..44ca2892ec0 100644 --- a/e2e/suite1/specs/trace-view-scrolling.spec.ts +++ b/e2e/suite1/specs/trace-view-scrolling.spec.ts @@ -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'); diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 36c3d3c6e7b..b64e006d3f5 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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', diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 024d1131dc9..4f6d7f78c91 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx index 30648016ea4..45f2ce981eb 100644 --- a/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.tsx @@ -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 extends HTMLAttributes { className?: string; @@ -37,6 +38,13 @@ const ButtonSelectComponent = (props: Props) => { setIsOpen(!isOpen); }; + const onArrowKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === 'ArrowDown' || event.key === 'Enter') { + setIsOpen(!isOpen); + } + }; + const onChangeInternal = (item: SelectableValue) => { onChange(item); setIsOpen(false); @@ -48,6 +56,7 @@ const ButtonSelectComponent = (props: Props) => { className={className} isOpen={isOpen} onClick={onToggle} + onKeyDown={onArrowKeyDown} narrow={narrow} variant={variant} {...restProps} @@ -56,17 +65,22 @@ const ButtonSelectComponent = (props: Props) => { {isOpen && (
- - - {options.map((item) => ( - onChangeInternal(item)} - active={item.value === value?.value} - /> - ))} - + + + + {options.map((item) => ( + onChangeInternal(item)} + active={item.value === value?.value} + ariaChecked={item.value === value?.value} + ariaLabel={item.ariaLabel || item.label} + role="menuitemradio" + /> + ))} + +
)} diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index 62ad58a2ce8..8f93dda38dc 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -11,6 +11,7 @@ export interface MenuProps extends React.HTMLAttributes { 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( - ({ 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( 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( event.stopPropagation(); setFocusedItem(menuItemsCount - 1); break; + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + onClose?.(); + break; + case 'Tab': + onClose?.(); + break; default: break; } diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index a1cdb6a72b5..4dc72fae3bc 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -11,10 +11,14 @@ export interface MenuItemProps { 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 { /** @internal */ export const MenuItem = React.memo( - React.forwardRef( - ({ 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((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 ( - { - if (!(event.ctrlKey || event.metaKey || event.shiftKey) && onClick) { - event.preventDefault(); - onClick(event); - } + const Wrapper = url === undefined ? 'button' : 'a'; + return ( + { + 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 && } {label} - - ); - } - ) + } + : 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 && } {label} + + ); + }) ); MenuItem.displayName = 'MenuItem'; diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.test.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.test.tsx index 12cb111e31a..011c0acbbeb 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.test.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.test.tsx @@ -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' }, ]); }); }); diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 9e26505912f..1c215b76689 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -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 { - 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 { onClick={onRefresh} variant={variant} icon={isLoading ? 'fa fa-spinner' : 'sync'} - aria-label={selectors.components.RefreshPicker.runButton} + data-testid={selectors.components.RefreshPicker.runButtonV2} > {text} @@ -81,7 +83,12 @@ export class RefreshPicker extends PureComponent { 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` + } /> )} @@ -93,7 +100,21 @@ export function intervalsToOptions({ intervals = defaultIntervals }: { intervals SelectableValue > { 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; diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.test.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.test.tsx index 9a8c1f87510..29593cac102 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.test.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.test.tsx @@ -34,14 +34,14 @@ describe('DashNavTimeControls', () => { const container = render( ); - 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( ); - 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( ); - 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( ); - expect(container.queryByLabelText(/RefreshPicker run button/i)).toBeInTheDocument(); + expect(container.queryByLabelText(/Refresh dashboard/i)).toBeInTheDocument(); }); }); diff --git a/yarn.lock b/yarn.lock index e9ca85d928e..e0bfd363765 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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