NestedFolderPicker: Implement keyboard handling (#71842)

* first attempt at keyboard handling

* rename some things, handle escape

* better

* almost working

* cleaner

* remove aria-label

* add some extra unit tests

* remove onMouseUp

* fix typo

* use a switch instead of if/else

* ensure lsit items are prefixed with an id unique to the picker

* extract keyboard interactions out into custom hook

* wrap handleCloseOverlay in useCallback

* use redux state instead of filtering items
This commit is contained in:
Ashley Harrison 2023-07-19 15:32:55 +01:00 committed by GitHub
parent 6d6cd2011e
commit b164fa37e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 307 additions and 107 deletions

View File

@ -1,4 +1,4 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React, { useCallback, useId, useMemo, useRef } from 'react';
import Skeleton from 'react-loading-skeleton';
import { FixedSizeList as List } from 'react-window';
@ -10,31 +10,38 @@ import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { Trans } from 'app/core/internationalization';
import { Indent } from 'app/features/browse-dashboards/components/Indent';
import { childrenByParentUIDSelector, rootItemsSelector } from 'app/features/browse-dashboards/state';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
import { useSelector } from 'app/types';
import { FolderUID } from './types';
const ROW_HEIGHT = 40;
const CHEVRON_SIZE = 'md';
export const getDOMId = (idPrefix: string, id: string) => `${idPrefix}-${id || 'root'}`;
interface NestedFolderListProps {
items: DashboardsTreeItem[];
focusedItemIndex: number;
foldersAreOpenable: boolean;
idPrefix: string;
selectedFolder: FolderUID | undefined;
onFolderClick: (uid: string, newOpenState: boolean) => void;
onSelectionChange: (event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => void;
onFolderExpand: (uid: string, newOpenState: boolean) => void;
onFolderSelect: (item: DashboardViewItem) => void;
isItemLoaded: (itemIndex: number) => boolean;
requestLoadMore: (folderUid: string | undefined) => void;
}
export function NestedFolderList({
items,
focusedItemIndex,
foldersAreOpenable,
idPrefix,
selectedFolder,
onFolderClick,
onSelectionChange,
onFolderExpand,
onFolderSelect,
isItemLoaded,
requestLoadMore,
}: NestedFolderListProps) {
@ -42,8 +49,16 @@ export function NestedFolderList({
const styles = useStyles2(getStyles);
const virtualData = useMemo(
(): VirtualData => ({ items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange }),
[items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange]
(): VirtualData => ({
items,
focusedItemIndex,
foldersAreOpenable,
selectedFolder,
onFolderExpand,
onFolderSelect,
idPrefix,
}),
[items, focusedItemIndex, foldersAreOpenable, selectedFolder, onFolderExpand, onFolderSelect, idPrefix]
);
const handleIsItemLoaded = useCallback(
@ -62,8 +77,8 @@ export function NestedFolderList({
);
return (
<div className={styles.table}>
{items.length ? (
<div className={styles.table} role="tree">
{items.length > 0 ? (
<InfiniteLoader
ref={infiniteLoaderRef}
itemCount={items.length}
@ -104,39 +119,38 @@ interface RowProps {
const SKELETON_WIDTHS = [100, 200, 130, 160, 150];
function Row({ index, style: virtualStyles, data }: RowProps) {
const { items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange } = data;
const { item, isOpen, level } = items[index];
const { items, focusedItemIndex, foldersAreOpenable, selectedFolder, onFolderExpand, onFolderSelect, idPrefix } =
data;
const { item, isOpen, level, parentUID } = items[index];
const rowRef = useRef<HTMLDivElement>(null);
const labelId = useId();
const rootCollection = useSelector(rootItemsSelector);
const childrenCollections = useSelector(childrenByParentUIDSelector);
const children = (item.uid ? childrenCollections[item.uid] : rootCollection)?.items ?? [];
let siblings: DashboardViewItem[] = [];
// only look for siblings if we're not at the root
if (item.uid) {
siblings = (parentUID ? childrenCollections[parentUID] : rootCollection)?.items ?? [];
}
const id = useId() + `-uid-${item.uid}`;
const styles = useStyles2(getStyles);
const handleClick = useCallback(
const handleExpand = useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
onFolderClick(item.uid, !isOpen);
},
[item.uid, isOpen, onFolderClick]
);
const handleRadioChange = useCallback(
(ev: React.FormEvent<HTMLInputElement>) => {
if (item.kind === 'folder') {
onSelectionChange(ev, item);
ev.stopPropagation();
if (item.uid) {
onFolderExpand(item.uid, !isOpen);
}
},
[item, onSelectionChange]
[item.uid, isOpen, onFolderExpand]
);
const handleKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
// Expand/collapse folder on arrow keys
if (foldersAreOpenable && (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft')) {
ev.preventDefault();
onFolderClick(item.uid, ev.key === 'ArrowRight');
}
},
[item.uid, foldersAreOpenable, onFolderClick]
);
const handleSelect = useCallback(() => {
if (item.kind === 'folder') {
onFolderSelect(item);
}
}, [item, onFolderSelect]);
if (item.kind === 'ui' && item.uiKind === 'pagination-placeholder') {
return (
@ -156,25 +170,36 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
}
return (
<div style={virtualStyles} className={styles.row}>
<input
className={styles.radio}
type="radio"
value={id}
id={id}
name="folder"
checked={item.uid === selectedFolder}
onChange={handleRadioChange}
onKeyDown={handleKeyDown}
/>
// don't need a key handler here, it's handled at the input level in NestedFolderPicker
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
ref={rowRef}
style={virtualStyles}
className={cx(styles.row, {
[styles.rowFocused]: index === focusedItemIndex,
[styles.rowSelected]: item.uid === selectedFolder,
})}
tabIndex={-1}
onClick={handleSelect}
aria-expanded={isOpen}
aria-selected={item.uid === selectedFolder}
aria-labelledby={labelId}
aria-level={level + 1} // aria-level is 1-indexed
role="treeitem"
aria-owns={children.length > 0 ? children.map((child) => getDOMId(idPrefix, child.uid)).join(' ') : undefined}
aria-setsize={children.length}
aria-posinset={siblings.findIndex((i) => i.uid === item.uid) + 1}
id={getDOMId(idPrefix, item.uid)}
>
<div className={styles.rowBody}>
<Indent level={level} />
{foldersAreOpenable ? (
<IconButton
size={CHEVRON_SIZE}
onClick={handleClick}
// tabIndex not needed here because we handle keyboard navigation at the radio button level
// by using onMouseDown here instead of onClick we can stop focus moving
// to the button when the user clicks it (via preventDefault + stopPropagation)
onMouseDown={handleExpand}
// tabIndex not needed here because we handle keyboard navigation at the input level
tabIndex={-1}
aria-label={isOpen ? `Collapse folder ${item.title}` : `Expand folder ${item.title}`}
name={isOpen ? 'angle-down' : 'angle-right'}
@ -183,7 +208,7 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
<span className={styles.folderButtonSpacer} />
)}
<label className={styles.label} htmlFor={id}>
<label className={styles.label} id={labelId}>
<Text as="span" truncate>
{item.title}
</Text>
@ -230,28 +255,21 @@ const getStyles = (theme: GrafanaTheme2) => {
},
}),
radio: css({
position: 'absolute',
left: '-1000rem',
rowFocused: css({
backgroundColor: theme.colors.background.secondary,
}),
'&:checked': {
border: '1px solid green',
},
[`&:checked + .${rowBody}`]: {
backgroundColor: theme.colors.background.secondary,
'&::before': {
display: 'block',
content: '""',
position: 'absolute',
left: 0,
bottom: 0,
top: 0,
width: 4,
borderRadius: theme.shape.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
rowSelected: css({
'&::before': {
display: 'block',
content: '""',
position: 'absolute',
left: 0,
bottom: 0,
top: 0,
width: 4,
borderRadius: theme.shape.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
}),

View File

@ -1,5 +1,5 @@
import 'whatwg-fetch'; // fetch polyfill
import { render as rtlRender, screen } from '@testing-library/react';
import { fireEvent, render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { SetupServer, setupServer } from 'msw/node';
@ -40,9 +40,11 @@ function render(...[ui, options]: Parameters<typeof rtlRender>) {
describe('NestedFolderPicker', () => {
const mockOnChange = jest.fn();
const originalScrollIntoView = window.HTMLElement.prototype.scrollIntoView;
let server: SetupServer;
beforeAll(() => {
window.HTMLElement.prototype.scrollIntoView = function () {};
server = setupServer(
rest.get('/api/folders/:uid', (_, res, ctx) => {
return res(
@ -59,9 +61,11 @@ describe('NestedFolderPicker', () => {
afterAll(() => {
server.close();
window.HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
});
afterEach(() => {
jest.resetAllMocks();
server.resetHandlers();
});
@ -70,10 +74,11 @@ describe('NestedFolderPicker', () => {
expect(await screen.findByRole('button', { name: 'Select folder' })).toBeInTheDocument();
});
it('renders a button with the folder name instead when a folder is selected', async () => {
it('renders a button with the correct label when a folder is selected', async () => {
render(<NestedFolderPicker onChange={mockOnChange} value="folderA" />);
expect(await screen.findByRole('button', { name: folderA.item.title })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Select folder' })).not.toBeInTheDocument();
expect(
await screen.findByRole('button', { name: `Select folder: ${folderA.item.title} currently selected` })
).toBeInTheDocument();
});
it('clicking the button opens the folder picker', async () => {
@ -110,6 +115,19 @@ describe('NestedFolderPicker', () => {
});
});
it('can select a folder from the picker with the keyboard', async () => {
render(<NestedFolderPicker onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(mockOnChange).toHaveBeenCalledWith({
uid: folderA.item.uid,
title: folderA.item.title,
});
});
it('can expand and collapse a folder to show its children', async () => {
render(<NestedFolderPicker onChange={mockOnChange} />);
@ -119,15 +137,57 @@ describe('NestedFolderPicker', () => {
await screen.findByLabelText(folderA.item.title);
// Expand Folder A
await userEvent.click(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
// Folder A's children are visible
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
// Collapse Folder A
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` }));
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
// Expand Folder A again
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
// Select the first child
await userEvent.click(screen.getByLabelText(folderA_folderA.item.title));
expect(mockOnChange).toHaveBeenCalledWith({
uid: folderA_folderA.item.uid,
title: folderA_folderA.item.title,
});
});
it('can expand and collapse a folder to show its children with the keyboard', async () => {
render(<NestedFolderPicker onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button);
// Expand Folder A
await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
// Folder A's children are visible
expect(screen.getByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
expect(screen.getByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
// Collapse Folder A
await userEvent.click(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` }));
await userEvent.keyboard('{ArrowLeft}');
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
// Expand Folder A again
await userEvent.keyboard('{ArrowRight}');
// Select the first child
await userEvent.keyboard('{ArrowDown}{Enter}');
expect(mockOnChange).toHaveBeenCalledWith({
uid: folderA_folderA.item.uid,
title: folderA_folderA.item.title,
});
});
});

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useId, useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { usePopperTooltip } from 'react-popper-tooltip';
import { useAsync } from 'react-use';
@ -25,7 +25,8 @@ import { queryResultToViewItem } from 'app/features/search/service/utils';
import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types/store';
import { NestedFolderList } from './NestedFolderList';
import { getDOMId, NestedFolderList } from './NestedFolderList';
import { useTreeInteractions } from './hooks';
import { FolderChange, FolderUID } from './types';
interface NestedFolderPickerProps {
@ -45,8 +46,10 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
const rootStatus = useBrowseLoadingStatus(undefined);
const [search, setSearch] = useState('');
const [autoFocusButton, setAutoFocusButton] = useState(false);
const [overlayOpen, setOverlayOpen] = useState(false);
const [folderOpenState, setFolderOpenState] = useState<Record<string, boolean>>({});
const overlayId = useId();
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
const searchState = useAsync(async () => {
@ -65,22 +68,38 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
return { ...queryResponse, items };
}, [search]);
const handleFolderClick = useCallback(
async (uid: string, newOpenState: boolean) => {
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
if (newOpenState) {
dispatch(fetchNextChildrenPage({ parentUID: uid, pageSize: PAGE_SIZE, excludeKinds: EXCLUDED_KINDS }));
}
},
[dispatch]
);
const rootCollection = useSelector(rootItemsSelector);
const childrenCollections = useSelector(childrenByParentUIDSelector);
const handleSelectionChange = useCallback(
(event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible, triggerRef } = usePopperTooltip({
visible: overlayOpen,
placement: 'bottom',
interactive: true,
offset: [0, 0],
trigger: 'click',
onVisibleChange: (value: boolean) => {
// ensure state is clean on opening the overlay
if (value) {
setSearch('');
setAutoFocusButton(true);
}
setOverlayOpen(value);
},
});
const handleFolderExpand = useCallback(
async (uid: string, newOpenState: boolean) => {
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
if (newOpenState && !folderOpenState[uid]) {
dispatch(fetchNextChildrenPage({ parentUID: uid, pageSize: PAGE_SIZE, excludeKinds: EXCLUDED_KINDS }));
}
},
[dispatch, folderOpenState]
);
const handleFolderSelect = useCallback(
(item: DashboardViewItem) => {
if (onChange) {
onChange({
uid: item.uid,
@ -92,20 +111,7 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
[onChange]
);
const { getTooltipProps, setTooltipRef, setTriggerRef, visible, triggerRef } = usePopperTooltip({
visible: overlayOpen,
placement: 'bottom',
interactive: true,
offset: [0, 0],
trigger: 'click',
onVisibleChange: (value: boolean) => {
// ensure search state is clean on opening the overlay
if (value) {
setSearch('');
}
setOverlayOpen(value);
},
});
const handleCloseOverlay = useCallback(() => setOverlayOpen(false), [setOverlayOpen]);
const baseHandleLoadMore = useLoadNextChildrenPage(EXCLUDED_KINDS);
const handleLoadMore = useCallback(
@ -175,6 +181,16 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
const isLoading = rootStatus === 'pending' || searchState.loading;
const { focusedItemIndex, handleKeyDown } = useTreeInteractions({
tree: flatTree,
handleCloseOverlay,
handleFolderSelect,
handleFolderExpand,
idPrefix: overlayId,
search,
visible,
});
let label = selectedFolder.data?.title;
if (value === '') {
label = 'Dashboards';
@ -183,10 +199,12 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
if (!visible) {
return (
<Button
autoFocus={autoFocusButton}
className={styles.button}
variant="secondary"
icon={value !== undefined ? 'folder' : undefined}
ref={setTriggerRef}
aria-label={label ? `Select folder: ${label} currently selected` : undefined}
>
{selectedFolder.isLoading ? (
<Skeleton width={100} />
@ -207,12 +225,20 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')}
value={search}
className={styles.search}
onKeyDown={handleKeyDown}
onChange={(e) => setSearch(e.currentTarget.value)}
aria-autocomplete="list"
aria-expanded
aria-haspopup
aria-controls={overlayId}
aria-owns={overlayId}
aria-activedescendant={getDOMId(overlayId, flatTree[focusedItemIndex]?.item.uid)}
role="combobox"
suffix={<Icon name="search" />}
/>
<fieldset
ref={setTooltipRef}
id={overlayId}
{...getTooltipProps({
className: styles.tableWrapper,
style: {
@ -239,8 +265,10 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
<NestedFolderList
items={flatTree}
selectedFolder={value}
onFolderClick={handleFolderClick}
onSelectionChange={handleSelectionChange}
focusedItemIndex={focusedItemIndex}
onFolderExpand={handleFolderExpand}
onFolderSelect={handleFolderSelect}
idPrefix={overlayId}
foldersAreOpenable={!(search && searchState.value)}
isItemLoaded={isItemLoaded}
requestLoadMore={handleLoadMore}

View File

@ -0,0 +1,94 @@
import React, { useCallback, useEffect, useState } from 'react';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
import { getDOMId } from './NestedFolderList';
interface TreeInteractionProps {
tree: DashboardsTreeItem[];
handleCloseOverlay: () => void;
handleFolderSelect: (item: DashboardViewItem) => void;
handleFolderExpand: (uid: string, newOpenState: boolean) => Promise<void>;
idPrefix: string;
search: string;
visible: boolean;
}
export function useTreeInteractions({
tree,
handleCloseOverlay,
handleFolderSelect,
handleFolderExpand,
idPrefix,
search,
visible,
}: TreeInteractionProps) {
const [focusedItemIndex, setFocusedItemIndex] = useState(-1);
useEffect(() => {
if (visible) {
setFocusedItemIndex(-1);
}
}, [visible]);
useEffect(() => {
setFocusedItemIndex(0);
}, [search]);
useEffect(() => {
document
.getElementById(getDOMId(idPrefix, tree[focusedItemIndex]?.item.uid))
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [focusedItemIndex, idPrefix, tree]);
const handleKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
const foldersAreOpenable = !search;
switch (ev.key) {
// Expand/collapse folder on right/left arrow keys
case 'ArrowRight':
case 'ArrowLeft':
if (foldersAreOpenable) {
ev.preventDefault();
handleFolderExpand(tree[focusedItemIndex].item.uid, ev.key === 'ArrowRight');
}
break;
case 'ArrowUp':
if (focusedItemIndex > 0) {
ev.preventDefault();
setFocusedItemIndex(focusedItemIndex - 1);
}
break;
case 'ArrowDown':
if (focusedItemIndex < tree.length - 1) {
ev.preventDefault();
setFocusedItemIndex(focusedItemIndex + 1);
}
break;
case 'Enter':
ev.preventDefault();
const item = tree[focusedItemIndex].item;
if (item.kind === 'folder') {
handleFolderSelect(item);
}
break;
case 'Tab':
ev.stopPropagation();
handleCloseOverlay();
break;
case 'Escape':
ev.stopPropagation();
ev.preventDefault();
handleCloseOverlay();
break;
}
},
[focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, search, tree]
);
return {
focusedItemIndex,
handleKeyDown,
};
}