TimeRangePicker: Allow users to navigate quick ranges with arrows instead of Tab (#88138)
This commit is contained in:
+10
-1
@@ -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];
|
||||
};
|
||||
Reference in New Issue
Block a user