mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
6d6cd2011e
commit
b164fa37e6
@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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}
|
||||
|
94
public/app/core/components/NestedFolderPicker/hooks.ts
Normal file
94
public/app/core/components/NestedFolderPicker/hooks.ts
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user