TimeRangePicker: Allow users to navigate quick ranges with arrows instead of Tab (#88138)

This commit is contained in:
Joao Silva
2024-05-27 10:33:53 +01:00
committed by GitHub
parent 3014103f41
commit 45faefa8b3
4 changed files with 205 additions and 1 deletions
@@ -8,6 +8,7 @@ import { t } from '../../../utils/i18n';
import { TimePickerTitle } from './TimePickerTitle';
import { TimeRangeOption } from './TimeRangeOption';
import { useListFocus } from './hooks';
interface Props {
title?: string;
@@ -44,9 +45,17 @@ export const TimeRangeList = (props: Props) => {
const Options = ({ options, value, onChange, title }: Props) => {
const styles = useStyles2(getOptionsStyles);
const localRef = React.useRef<HTMLUListElement>(null);
const [handleKeys] = useListFocus({ localRef, options });
return (
<>
<ul aria-roledescription={t('time-picker.time-range.aria-role', 'Time range selection')}>
<ul
role="presentation"
onKeyDown={handleKeys}
ref={localRef}
aria-roledescription={t('time-picker.time-range.aria-role', 'Time range selection')}
>
{options.map((option, index) => (
<TimeRangeOption
key={keyForOption(option, index)}
@@ -60,6 +60,8 @@ export const TimeRangeOption = memo<Props>(({ value, onSelect, selected = false,
checked={selected}
name={name}
type="checkbox"
data-role="item"
tabIndex={-1}
id={id}
onChange={() => onSelect(value)}
/>
@@ -0,0 +1,118 @@
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
import React, { createRef, KeyboardEvent, RefObject } from 'react';
import { useListFocus } from './hooks';
describe('useListFocus', () => {
const testid = 'test';
const getListElement = (
ref: RefObject<HTMLUListElement>,
handleKeys?: (event: KeyboardEvent) => void,
onClick?: () => void
) => (
<ul data-testid={testid} ref={ref} tabIndex={0} onKeyDown={handleKeys}>
<li data-role="item" onClick={onClick}>
Last 1 hour
</li>
<li data-role="item">Last 6 hours</li>
<li data-role="item">Last 24 hours</li>
<li data-role="item">Last 7 days</li>
</ul>
);
const options = [
{ from: 'now-1h', to: 'now', display: 'Last 1 hour' },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours' },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours' },
{ from: 'now-7d', to: 'now', display: 'Last 7 days' },
];
it('sets correct focused item on keydown', () => {
const ref = createRef<HTMLUListElement>();
const { rerender } = render(getListElement(ref));
const { result } = renderHook(() => useListFocus({ localRef: ref, options }));
const [handleKeys] = result.current;
rerender(getListElement(ref, handleKeys));
expect(screen.getByText('Last 1 hour').tabIndex).toBe(0);
expect(screen.getByText('Last 6 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 24 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 7 days').tabIndex).toBe(-1);
act(() => {
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
});
const [handleKeys2] = result.current;
rerender(getListElement(ref, handleKeys2));
expect(screen.getByText('Last 1 hour').tabIndex).toBe(-1);
expect(screen.getByText('Last 6 hours').tabIndex).toBe(0);
expect(screen.getByText('Last 24 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 7 days').tabIndex).toBe(-1);
act(() => {
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowDown' });
});
const [handleKeys3] = result.current;
rerender(getListElement(ref, handleKeys3));
expect(screen.getByText('Last 1 hour').tabIndex).toBe(-1);
expect(screen.getByText('Last 6 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 24 hours').tabIndex).toBe(0);
expect(screen.getByText('Last 7 days').tabIndex).toBe(-1);
act(() => {
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' });
});
const [handleKeys4] = result.current;
rerender(getListElement(ref, handleKeys4));
expect(screen.getByText('Last 1 hour').tabIndex).toBe(-1);
expect(screen.getByText('Last 6 hours').tabIndex).toBe(0);
expect(screen.getByText('Last 24 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 7 days').tabIndex).toBe(-1);
act(() => {
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' });
});
const [handleKeys5] = result.current;
rerender(getListElement(ref, handleKeys5));
expect(screen.getByText('Last 1 hour').tabIndex).toBe(0);
expect(screen.getByText('Last 6 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 24 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 7 days').tabIndex).toBe(-1);
act(() => {
fireEvent.keyDown(screen.getByTestId(testid), { key: 'ArrowUp' });
});
const [handleKeys6] = result.current;
rerender(getListElement(ref, handleKeys6));
expect(screen.getByText('Last 1 hour').tabIndex).toBe(-1);
expect(screen.getByText('Last 6 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 24 hours').tabIndex).toBe(-1);
expect(screen.getByText('Last 7 days').tabIndex).toBe(0);
});
it('clicks focused item when Enter key is pressed', () => {
const ref = createRef<HTMLUListElement>();
const onClick = jest.fn();
const { rerender } = render(getListElement(ref));
const { result } = renderHook(() => useListFocus({ localRef: ref, options }));
const [handleKeys] = result.current;
rerender(getListElement(ref, handleKeys, onClick));
act(() => {
fireEvent.keyDown(screen.getByTestId(testid), { key: 'Enter' });
});
expect(onClick).toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import { RefObject, useEffect, useState } from 'react';
import { TimeOption } from '@grafana/data';
const modulo = (a: number, n: number) => ((a % n) + n) % n;
const CAUGHT_KEYS = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Tab'];
/** @internal */
export interface UseListFocusProps {
localRef: RefObject<HTMLUListElement>;
options: TimeOption[];
}
/** @internal */
export type UseListFocusReturn = [(event: React.KeyboardEvent) => void];
/** @internal */
export const useListFocus = ({ localRef, options }: UseListFocusProps): UseListFocusReturn => {
const [focusedItem, setFocusedItem] = useState(0);
useEffect(() => {
const items = localRef.current?.querySelectorAll<HTMLInputElement>('[data-role="item"]') || [];
const checkedIndex = Array.from(items).findIndex((item) => item.checked);
const newFocusedIndex = checkedIndex >= 0 ? checkedIndex : 0;
items.forEach((item, i) => {
item.tabIndex = i === newFocusedIndex ? 0 : -1;
});
// Reset focused item when options have changed
setFocusedItem(newFocusedIndex);
}, [localRef, options]);
const handleKeys = (event: React.KeyboardEvent) => {
const items = localRef?.current?.querySelectorAll<HTMLInputElement>('[data-role="item"]');
const itemsCount = items?.length ?? 0;
if (CAUGHT_KEYS.indexOf(event.key) > -1) {
event.preventDefault();
if (event.key !== 'Tab') {
event.stopPropagation();
}
}
let newFocusedIndex = null;
switch (event.key) {
case 'ArrowUp':
newFocusedIndex = modulo(focusedItem - 1, itemsCount);
break;
case 'ArrowDown':
newFocusedIndex = modulo(focusedItem + 1, itemsCount);
break;
case 'Home':
newFocusedIndex = 0;
break;
case 'End':
newFocusedIndex = itemsCount - 1;
break;
case 'Enter':
items?.[focusedItem]?.click();
break;
default:
break;
}
if (newFocusedIndex !== null) {
setFocusedItem(newFocusedIndex);
items?.[newFocusedIndex]?.focus();
items?.forEach((item, i) => {
item.tabIndex = i === newFocusedIndex ? 0 : -1;
});
}
};
return [handleKeys];
};