mirror of https://github.com/grafana/grafana.git
Scopes: Arrow key selection support (#110155)
* Add basic arrow key navigation support * Add shortcut for applying scopes * Support expanding nodes with arrow keys * Make useEffect non-conditional * Add a11y and error boundary * Fix preventDefault * Add test for keyboardinteractions * Add tests and expanded status to treeitem * Reset highlight when disabled and change styles * Fix tests * Update i18n * Remove unused var * Reset enterprise imports from main * Move failing test to correct quite * Remove test outside fo context * Remove unused import * Use highlitghted ID instead of index * Extract all highlighing functionality into its own hook * Remove unused imports
This commit is contained in:
parent
4de9ec7310
commit
dac6d04e24
|
@ -1,12 +1,14 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { useScopes } from '@grafana/runtime';
|
||||
import { Button, Drawer, IconButton, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Drawer, ErrorBoundary, ErrorWithStack, IconButton, Spinner, Text, useStyles2 } from '@grafana/ui';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { getModKey } from 'app/core/utils/browser';
|
||||
|
||||
import { useScopesServices } from '../ScopesContextProvider';
|
||||
|
||||
|
@ -28,6 +30,21 @@ export const ScopesSelector = () => {
|
|||
services?.scopesSelectorService.state
|
||||
);
|
||||
|
||||
// Keyboard shortcut for closing and applying
|
||||
useEffect(() => {
|
||||
if (!services?.scopesSelectorService) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// ctrl/cmd + enter. Do a check up here to prevent conditional useEffect
|
||||
if (event.key === 'Enter' && event.metaKey) {
|
||||
services.scopesSelectorService.closeAndApply();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [services?.scopesSelectorService]);
|
||||
|
||||
if (!services || !scopes || !scopes.state.enabled || !selectorServiceState) {
|
||||
return null;
|
||||
}
|
||||
|
@ -90,39 +107,55 @@ export const ScopesSelector = () => {
|
|||
|
||||
{opened && (
|
||||
<Drawer title={t('scopes.selector.title', 'Select scopes')} size="sm" onClose={closeAndReset}>
|
||||
<div className={styles.drawerContainer}>
|
||||
<div className={styles.treeContainer}>
|
||||
{loading || !tree ? (
|
||||
<Spinner data-testid="scopes-selector-loading" />
|
||||
) : (
|
||||
<>
|
||||
<ScopesTree
|
||||
tree={tree}
|
||||
loadingNodeName={loadingNodeName}
|
||||
onNodeUpdate={updateNode}
|
||||
recentScopes={recentScopes}
|
||||
selectedScopes={selectedScopes}
|
||||
scopeNodes={nodes}
|
||||
selectScope={selectScope}
|
||||
deselectScope={deselectScope}
|
||||
onRecentScopesSelect={(scopeIds: string[], parentNodeId?: string) => {
|
||||
scopesSelectorService.changeScopes(scopeIds, parentNodeId);
|
||||
scopesSelectorService.closeAndReset();
|
||||
}}
|
||||
<ErrorBoundary>
|
||||
{({ error, errorInfo }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorWithStack
|
||||
error={error}
|
||||
title={t('scopes.selector.error-title', 'An unexpected error happened')}
|
||||
errorInfo={errorInfo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.drawerContainer}>
|
||||
<div className={styles.treeContainer}>
|
||||
{loading || !tree ? (
|
||||
<Spinner data-testid="scopes-selector-loading" />
|
||||
) : (
|
||||
<>
|
||||
<ScopesTree
|
||||
tree={tree}
|
||||
loadingNodeName={loadingNodeName}
|
||||
onNodeUpdate={updateNode}
|
||||
recentScopes={recentScopes}
|
||||
selectedScopes={selectedScopes}
|
||||
scopeNodes={nodes}
|
||||
selectScope={selectScope}
|
||||
deselectScope={deselectScope}
|
||||
onRecentScopesSelect={(scopeIds: string[], parentNodeId?: string) => {
|
||||
scopesSelectorService.changeScopes(scopeIds, parentNodeId);
|
||||
scopesSelectorService.closeAndReset();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Button variant="primary" data-testid="scopes-selector-apply" onClick={closeAndApply}>
|
||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" data-testid="scopes-selector-cancel" onClick={closeAndReset}>
|
||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Button variant="primary" data-testid="scopes-selector-apply" onClick={closeAndApply}>
|
||||
<Trans i18nKey="scopes.selector.apply">Apply</Trans>
|
||||
<Text variant="bodySmall">{`${getModKey()}+↵`}</Text>
|
||||
</Button>
|
||||
<Button variant="secondary" data-testid="scopes-selector-cancel" onClick={closeAndReset}>
|
||||
<Trans i18nKey="scopes.selector.cancel">Cancel</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ErrorBoundary>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { css } from '@emotion/css';
|
||||
import { useId } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2, Scope } from '@grafana/data';
|
||||
|
@ -9,6 +10,7 @@ import { ScopesTreeHeadline } from './ScopesTreeHeadline';
|
|||
import { ScopesTreeItemList } from './ScopesTreeItemList';
|
||||
import { ScopesTreeSearch } from './ScopesTreeSearch';
|
||||
import { NodesMap, SelectedScope, TreeNode } from './types';
|
||||
import { useScopesHighlighting } from './useScopesHighlighting';
|
||||
|
||||
export interface ScopesTreeProps {
|
||||
tree: TreeNode;
|
||||
|
@ -39,6 +41,10 @@ export function ScopesTree({
|
|||
}: ScopesTreeProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Used for a11y reference
|
||||
const selectedNodesToShowId = useId();
|
||||
const childrenArrayId = useId();
|
||||
|
||||
const nodeLoading = loadingNodeName === tree.scopeNodeId;
|
||||
|
||||
const children = tree.children;
|
||||
|
@ -63,6 +69,17 @@ export function ScopesTree({
|
|||
}
|
||||
}
|
||||
|
||||
const { highlightedId, ariaActiveDescendant, enableHighlighting, disableHighlighting } = useScopesHighlighting({
|
||||
selectedNodes: selectedNodesToShow,
|
||||
resultNodes: childrenArray,
|
||||
treeQuery: tree.query,
|
||||
scopeNodes,
|
||||
selectedScopes,
|
||||
onNodeUpdate,
|
||||
selectScope,
|
||||
deselectScope,
|
||||
});
|
||||
|
||||
// Used as a label and placeholder for search field
|
||||
const nodeTitle = scopeNodes[tree.scopeNodeId]?.spec?.title || '';
|
||||
const searchArea = tree.scopeNodeId === '' ? '' : nodeTitle;
|
||||
|
@ -76,6 +93,10 @@ export function ScopesTree({
|
|||
searchArea={searchArea}
|
||||
onNodeUpdate={onNodeUpdate}
|
||||
treeNode={tree}
|
||||
aria-controls={`${selectedNodesToShowId} ${childrenArrayId}`}
|
||||
aria-activedescendant={ariaActiveDescendant}
|
||||
onFocus={enableHighlighting}
|
||||
onBlur={disableHighlighting}
|
||||
/>
|
||||
{tree.scopeNodeId === '' &&
|
||||
!anyChildExpanded &&
|
||||
|
@ -99,6 +120,8 @@ export function ScopesTree({
|
|||
selectScope={selectScope}
|
||||
deselectScope={deselectScope}
|
||||
maxHeight={`${Math.min(5, selectedNodesToShow.length) * 30}px`}
|
||||
highlightedId={highlightedId}
|
||||
id={selectedNodesToShowId}
|
||||
/>
|
||||
|
||||
<ScopesTreeHeadline
|
||||
|
@ -119,6 +142,8 @@ export function ScopesTree({
|
|||
selectScope={selectScope}
|
||||
deselectScope={deselectScope}
|
||||
maxHeight={'100%'}
|
||||
highlightedId={highlightedId}
|
||||
id={childrenArrayId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -15,6 +15,7 @@ export interface ScopesTreeItemProps {
|
|||
scopeNodes: NodesMap;
|
||||
selected: boolean;
|
||||
selectedScopes: SelectedScope[];
|
||||
highlighted: boolean;
|
||||
|
||||
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
|
||||
selectScope: (scopeNodeId: string) => void;
|
||||
|
@ -31,6 +32,7 @@ export function ScopesTreeItem({
|
|||
selectedScopes,
|
||||
selectScope,
|
||||
deselectScope,
|
||||
highlighted,
|
||||
}: ScopesTreeItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
|
@ -52,11 +54,20 @@ export function ScopesTreeItem({
|
|||
return (
|
||||
<div
|
||||
key={treeNode.scopeNodeId}
|
||||
id={getTreeItemElementId(treeNode.scopeNodeId)}
|
||||
role="treeitem"
|
||||
aria-selected={treeNode.expanded}
|
||||
// aria-selected refers to the highlighted item in the tree, not the selected checkbox/radio button
|
||||
aria-selected={highlighted}
|
||||
aria-expanded={isExpandable ? treeNode.expanded : undefined}
|
||||
className={anyChildExpanded ? styles.expandedContainer : undefined}
|
||||
>
|
||||
<div className={cx(styles.title, isSelectable && !treeNode.expanded && styles.titlePadding)}>
|
||||
<div
|
||||
className={cx(
|
||||
styles.title,
|
||||
isSelectable && !treeNode.expanded && styles.titlePadding,
|
||||
highlighted && styles.highlighted
|
||||
)}
|
||||
>
|
||||
{isSelectable && !treeNode.expanded ? (
|
||||
disableMultiSelect ? (
|
||||
<RadioButtonDot
|
||||
|
@ -71,6 +82,7 @@ export function ScopesTreeItem({
|
|||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
id={treeNode.scopeNodeId}
|
||||
checked={selected}
|
||||
data-testid={`scopes-tree-${treeNode.scopeNodeId}-checkbox`}
|
||||
label={isExpandable ? '' : scopeNode.spec.title}
|
||||
|
@ -114,8 +126,16 @@ export function ScopesTreeItem({
|
|||
);
|
||||
}
|
||||
|
||||
export const getTreeItemElementId = (scopeNodeId?: string) => {
|
||||
return scopeNodeId ? `scopes-tree-item-${scopeNodeId}` : undefined;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
highlighted: css({
|
||||
background: theme.colors.action.focus,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
}),
|
||||
expandedContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
|
@ -18,6 +18,8 @@ type Props = {
|
|||
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
|
||||
selectScope: (scopeNodeId: string) => void;
|
||||
deselectScope: (scopeNodeId: string) => void;
|
||||
highlightedId: string | undefined;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function ScopesTreeItemList({
|
||||
|
@ -31,6 +33,8 @@ export function ScopesTreeItemList({
|
|||
onNodeUpdate,
|
||||
selectScope,
|
||||
deselectScope,
|
||||
highlightedId,
|
||||
id,
|
||||
}: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
|
@ -39,7 +43,7 @@ export function ScopesTreeItemList({
|
|||
}
|
||||
|
||||
const children = (
|
||||
<div role="tree" className={anyChildExpanded ? styles.expandedContainer : undefined}>
|
||||
<div role="tree" id={id} className={anyChildExpanded ? styles.expandedContainer : undefined}>
|
||||
{items.map((childNode) => {
|
||||
const selected =
|
||||
isNodeSelectable(scopeNodes[childNode.scopeNodeId]) &&
|
||||
|
@ -64,6 +68,7 @@ export function ScopesTreeItemList({
|
|||
onNodeUpdate={onNodeUpdate}
|
||||
selectScope={selectScope}
|
||||
deselectScope={deselectScope}
|
||||
highlighted={childNode.scopeNodeId === highlightedId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -13,9 +13,22 @@ export interface ScopesTreeSearchProps {
|
|||
searchArea: string;
|
||||
treeNode: TreeNode;
|
||||
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
'aria-controls': string;
|
||||
'aria-activedescendant'?: string;
|
||||
}
|
||||
|
||||
export function ScopesTreeSearch({ anyChildExpanded, treeNode, onNodeUpdate, searchArea }: ScopesTreeSearchProps) {
|
||||
export function ScopesTreeSearch({
|
||||
anyChildExpanded,
|
||||
treeNode,
|
||||
onNodeUpdate,
|
||||
searchArea,
|
||||
onFocus,
|
||||
onBlur,
|
||||
'aria-controls': ariaControls,
|
||||
'aria-activedescendant': ariaActivedescendant,
|
||||
}: ScopesTreeSearchProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [inputState, setInputState] = useState<{ value: string; dirty: boolean }>({
|
||||
|
@ -52,6 +65,11 @@ export function ScopesTreeSearch({ anyChildExpanded, treeNode, onNodeUpdate, sea
|
|||
placeholder={searchLabel}
|
||||
// Don't do autofocus for root node
|
||||
autoFocus={treeNode.scopeNodeId !== ''}
|
||||
role="combobox"
|
||||
aria-expanded={true}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={ariaControls}
|
||||
aria-activedescendant={ariaActivedescendant}
|
||||
aria-label={searchLabel}
|
||||
value={inputState.value}
|
||||
className={styles.input}
|
||||
|
@ -60,6 +78,8 @@ export function ScopesTreeSearch({ anyChildExpanded, treeNode, onNodeUpdate, sea
|
|||
onChange={(value) => {
|
||||
setInputState({ value, dirty: true });
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,281 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { TreeNode } from './types';
|
||||
import { KeyboardAction, useKeyboardInteraction } from './useKeyboardInteractions';
|
||||
|
||||
// Mock data for testing
|
||||
const createMockTreeNode = (id: string, hasChildren = false): TreeNode => ({
|
||||
scopeNodeId: id,
|
||||
expanded: false,
|
||||
query: '',
|
||||
children: hasChildren ? { child1: createMockTreeNode('child1') } : undefined,
|
||||
});
|
||||
|
||||
const mockItems: TreeNode[] = [
|
||||
createMockTreeNode('item1'),
|
||||
createMockTreeNode('item2', true), // expandable
|
||||
createMockTreeNode('item3'),
|
||||
];
|
||||
|
||||
describe('useKeyboardInteraction', () => {
|
||||
let mockOnSelect: jest.Mock;
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSelect = jest.fn();
|
||||
user = userEvent.setup();
|
||||
|
||||
// Create a real input element for keyboard events
|
||||
inputElement = document.createElement('input');
|
||||
document.body.appendChild(inputElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(inputElement);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with no highlightedId', () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
expect(result.current.highlightedId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add and remove event listeners correctly', () => {
|
||||
const { unmount } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
// Verify event listener is added (we can't easily test removal without mocking)
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('when disabled', () => {
|
||||
it('should not handle keyboard events', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(false, mockItems, '', mockOnSelect));
|
||||
|
||||
// Focus the input to enable keyboard events
|
||||
await user.click(inputElement);
|
||||
|
||||
// Try to navigate with arrow keys
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
expect(result.current.highlightedId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when no items', () => {
|
||||
it('should not handle keyboard events', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, [], '', mockOnSelect));
|
||||
|
||||
// Focus the input to enable keyboard events
|
||||
await user.click(inputElement);
|
||||
|
||||
// Try to navigate with arrow keys
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
expect(result.current.highlightedId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArrowDown key', () => {
|
||||
it('should move highlight to first item', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
});
|
||||
|
||||
it('should wrap around to first when reaching the end', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item3');
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArrowUp key', () => {
|
||||
it('should decrement highlighted item', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item2');
|
||||
|
||||
await user.keyboard('{ArrowUp}');
|
||||
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
});
|
||||
|
||||
it('should wrap around to last item when going above first', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
|
||||
await user.keyboard('{ArrowUp}');
|
||||
|
||||
expect(result.current.highlightedId).toBe('item3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enter key', () => {
|
||||
it('should call onSelect with SELECT action when item is highlighted', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('item1', KeyboardAction.SELECT);
|
||||
});
|
||||
|
||||
it('should not call onSelect when no item is highlighted', async () => {
|
||||
renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArrowRight key', () => {
|
||||
it('should call onSelect with EXPAND action for expandable items', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item2');
|
||||
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('item2', KeyboardAction.EXPAND);
|
||||
});
|
||||
|
||||
it('should not call onSelect when no item is highlighted', async () => {
|
||||
renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escape key', () => {
|
||||
it('should reset highlighted id to undefined', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(result.current.highlightedId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('other keys', () => {
|
||||
it('should not affect highlight for non-handled keys', async () => {
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, mockItems, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
|
||||
await user.keyboard('{Tab}');
|
||||
|
||||
expect(result.current.highlightedId).toBe('item1');
|
||||
expect(mockOnSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useEffect behaviors', () => {
|
||||
it('should reset highlighted id when items length changes to 0', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ items, enabled, searchQuery, onSelect }) => useKeyboardInteraction(enabled, items, searchQuery, onSelect),
|
||||
{
|
||||
initialProps: {
|
||||
items: mockItems,
|
||||
enabled: true,
|
||||
searchQuery: '',
|
||||
onSelect: mockOnSelect,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Rerender with empty items
|
||||
rerender({
|
||||
items: [],
|
||||
enabled: true,
|
||||
searchQuery: '',
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
|
||||
expect(result.current.highlightedId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reset highlighted id when search query changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ items, enabled, searchQuery, onSelect }) => useKeyboardInteraction(enabled, items, searchQuery, onSelect),
|
||||
{
|
||||
initialProps: {
|
||||
items: mockItems,
|
||||
enabled: true,
|
||||
searchQuery: '',
|
||||
onSelect: mockOnSelect,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Rerender with new search query
|
||||
rerender({
|
||||
items: mockItems,
|
||||
enabled: true,
|
||||
searchQuery: 'new query',
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
|
||||
expect(result.current.highlightedId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle single item correctly', async () => {
|
||||
const singleItem = [createMockTreeNode('single')];
|
||||
const { result } = renderHook(() => useKeyboardInteraction(true, singleItem, '', mockOnSelect));
|
||||
|
||||
await user.click(inputElement);
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('single');
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(result.current.highlightedId).toBe('single');
|
||||
|
||||
await user.keyboard('{ArrowUp}');
|
||||
expect(result.current.highlightedId).toBe('single');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,95 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { TreeNode } from './types';
|
||||
|
||||
// Uses enum to enable extension in the future
|
||||
export enum KeyboardAction {
|
||||
SELECT = 'select',
|
||||
EXPAND = 'expand',
|
||||
}
|
||||
|
||||
// Handles keyboard interactions for the scopes tree
|
||||
// onSelect is the function to call when an option is selected
|
||||
// Returns the highlighted node id
|
||||
export function useKeyboardInteraction(
|
||||
enabled: boolean,
|
||||
items: TreeNode[],
|
||||
searchQuery: string,
|
||||
onSelect: (nodeId: string | undefined, action: KeyboardAction) => void
|
||||
) {
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): void => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no options, do nothing. Also to prevent dividing by 0
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
// Change highlighted index
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
|
||||
setHighlightedIndex((prev) => (prev + 1) % items.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
|
||||
setHighlightedIndex((prev) => (prev - 1 + items.length) % items.length);
|
||||
break;
|
||||
// Handle Select action
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedIndex !== -1) {
|
||||
onSelect(items[highlightedIndex]?.scopeNodeId, KeyboardAction.SELECT);
|
||||
}
|
||||
break;
|
||||
// Handle Expand action
|
||||
case 'ArrowRight':
|
||||
// Let checking if an item actually is expandable be handled in onSelect
|
||||
if (highlightedIndex !== -1) {
|
||||
// Send an expand action here and let onSelect determine if the node actually is expandable
|
||||
event.preventDefault();
|
||||
onSelect(items[highlightedIndex]?.scopeNodeId, KeyboardAction.EXPAND);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Escape':
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[items, onSelect, highlightedIndex, enabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Reset highlighted index when items length changes to 0
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset when doing a new query
|
||||
setHighlightedIndex(-1);
|
||||
}, [searchQuery, enabled]);
|
||||
|
||||
const highlightedId = highlightedIndex === -1 ? undefined : items[highlightedIndex]?.scopeNodeId;
|
||||
|
||||
return { highlightedId };
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { getTreeItemElementId } from './ScopesTreeItem';
|
||||
import { isNodeExpandable, isNodeSelectable } from './scopesTreeUtils';
|
||||
import { NodesMap, SelectedScope, TreeNode } from './types';
|
||||
import { KeyboardAction, useKeyboardInteraction } from './useKeyboardInteractions';
|
||||
|
||||
interface UseScopesHighlightingParams {
|
||||
selectedNodes: TreeNode[];
|
||||
resultNodes: TreeNode[];
|
||||
treeQuery: string;
|
||||
scopeNodes: NodesMap;
|
||||
selectedScopes: SelectedScope[];
|
||||
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void;
|
||||
selectScope: (scopeNodeId: string) => void;
|
||||
deselectScope: (scopeNodeId: string) => void;
|
||||
}
|
||||
|
||||
export function useScopesHighlighting({
|
||||
selectedNodes,
|
||||
resultNodes,
|
||||
treeQuery,
|
||||
scopeNodes,
|
||||
selectedScopes,
|
||||
onNodeUpdate,
|
||||
selectScope,
|
||||
deselectScope,
|
||||
}: UseScopesHighlightingParams) {
|
||||
// Enable keyboard highlighting when the search field is focused
|
||||
const [highlightEnabled, setHighlightEnabled] = useState(false);
|
||||
|
||||
const items = [...selectedNodes, ...resultNodes];
|
||||
|
||||
const { highlightedId } = useKeyboardInteraction(
|
||||
highlightEnabled,
|
||||
items,
|
||||
highlightEnabled ? treeQuery : '',
|
||||
(nodeId: string | undefined, action: KeyboardAction) => {
|
||||
if (!nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExpanding = action === KeyboardAction.EXPAND && isNodeExpandable(scopeNodes[nodeId]);
|
||||
const isSelectingAndExpandable =
|
||||
action === KeyboardAction.SELECT &&
|
||||
!isNodeSelectable(scopeNodes[nodeId]) &&
|
||||
isNodeExpandable(scopeNodes[nodeId]);
|
||||
|
||||
if (isExpanding || isSelectingAndExpandable) {
|
||||
onNodeUpdate(nodeId, true, treeQuery);
|
||||
setHighlightEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle selection
|
||||
if (selectedScopes.some((s) => s.scopeNodeId === nodeId)) {
|
||||
deselectScope(nodeId);
|
||||
} else {
|
||||
selectScope(nodeId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const ariaActiveDescendant = getTreeItemElementId(highlightedId);
|
||||
|
||||
return {
|
||||
highlightedId,
|
||||
ariaActiveDescendant,
|
||||
enableHighlighting: () => setHighlightEnabled(true),
|
||||
disableHighlighting: () => setHighlightEnabled(false),
|
||||
};
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
|
||||
|
@ -55,6 +56,7 @@ describe('Tree', () => {
|
|||
let fetchNodesSpy: jest.SpyInstance;
|
||||
let fetchScopeSpy: jest.SpyInstance;
|
||||
let scopesService: ScopesService;
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = true;
|
||||
|
@ -66,6 +68,7 @@ describe('Tree', () => {
|
|||
scopesService = result.scopesService;
|
||||
fetchNodesSpy = jest.spyOn(result.client, 'fetchNodes');
|
||||
fetchScopeSpy = jest.spyOn(result.client, 'fetchScope');
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
@ -75,10 +78,10 @@ describe('Tree', () => {
|
|||
|
||||
it('Gives autofocus to search field when node is expanded', async () => {
|
||||
await openSelector();
|
||||
expect(screen.getByRole('textbox', { name: 'Search' })).not.toHaveFocus();
|
||||
expect(screen.getByRole('combobox', { name: 'Search' })).not.toHaveFocus();
|
||||
|
||||
await expandResultApplications();
|
||||
expect(screen.getByRole('textbox', { name: 'Search Applications' })).toHaveFocus();
|
||||
expect(screen.getByRole('combobox', { name: 'Search Applications' })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('Fetches scope details on select', async () => {
|
||||
|
@ -263,4 +266,261 @@ describe('Tree', () => {
|
|||
await expandResultApplicationsCloud();
|
||||
expectScopesHeadline('Recommended');
|
||||
});
|
||||
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should navigate through items with arrow keys when search is focused', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveFocus();
|
||||
|
||||
// Navigate down through items
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Get all tree items and find the one that's selected
|
||||
const selectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(selectedItem).toBeTruthy();
|
||||
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Find the new selected item
|
||||
const newSelectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(newSelectedItem).toBeTruthy();
|
||||
expect(newSelectedItem).not.toBe(selectedItem);
|
||||
|
||||
// Navigate up
|
||||
await user.keyboard('{ArrowUp}');
|
||||
|
||||
// Should be back to the first selected item
|
||||
const finalSelectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(finalSelectedItem).toBe(selectedItem);
|
||||
});
|
||||
|
||||
it('should wrap around when navigating past boundaries', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveFocus();
|
||||
|
||||
// Navigate to last item (just a few steps to avoid getting stuck)
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Verify we can navigate and items have proper state
|
||||
const treeItems = screen.getAllByRole('treeitem');
|
||||
expect(treeItems.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that at least one item is selected
|
||||
const selectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(selectedItem).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should select items with Enter key', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveFocus();
|
||||
|
||||
// Navigate to Grafana and select it
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expectResultApplicationsGrafanaSelected();
|
||||
});
|
||||
|
||||
it('should expand items with ArrowRight key', async () => {
|
||||
await openSelector();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search' });
|
||||
searchInput.focus();
|
||||
|
||||
// Navigate to Applications (which is expandable) - need to ensure we reach it
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Verify we can navigate and items have proper state
|
||||
const treeItems = screen.getAllByRole('treeitem');
|
||||
expect(treeItems.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that at least one item is selected
|
||||
const selectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(selectedItem).toBeTruthy();
|
||||
|
||||
// Verify we're on an expandable item (should have aria-expanded attribute)
|
||||
expect(selectedItem).toHaveAttribute('aria-expanded');
|
||||
|
||||
// Try to expand with ArrowRight
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
// Should now show the expanded Applications section with its search input
|
||||
expect(screen.getByRole('combobox', { name: 'Search Applications' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset highlight with Escape key', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveFocus();
|
||||
|
||||
// Navigate to an item
|
||||
await user.keyboard('{ArrowDown}');
|
||||
const selectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(selectedItem).toBeTruthy();
|
||||
|
||||
// Reset with Escape
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('treeitem', { selected: true })).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not handle keyboard events when search is not focused', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
// Click outside search to lose focus
|
||||
const outsideElement = screen.getByText('Select scopes');
|
||||
await user.click(outsideElement);
|
||||
|
||||
// Try to navigate with arrow keys
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// No items should be selected
|
||||
const items = screen.getAllByRole('treeitem');
|
||||
const nonSelectedItems = screen.queryAllByRole('treeitem', { selected: false });
|
||||
expect(nonSelectedItems.length).toBe(items.length);
|
||||
});
|
||||
|
||||
it('should handle keyboard navigation with search results', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
await searchScopes('Cloud');
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveFocus();
|
||||
|
||||
// Navigate through search results
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Get all Cloud items and verify at least one is selected
|
||||
const cloudItems = screen.getAllByRole('treeitem', { name: /Cloud/ });
|
||||
expect(cloudItems.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that at least one item is selected
|
||||
const selectedItems = cloudItems.filter((item) => item.getAttribute('aria-selected') === 'true');
|
||||
expect(selectedItems.length).toBeGreaterThan(0);
|
||||
|
||||
// Select the first selected item
|
||||
await user.keyboard('{Enter}');
|
||||
expectResultApplicationsCloudPresent();
|
||||
});
|
||||
|
||||
it('should not expand non-expandable items with ArrowRight key', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveFocus();
|
||||
|
||||
// Navigate to a non-expandable item (like Grafana, Mimir, or Cloud)
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Verify we're on a non-expandable item (should not have aria-expanded attribute)
|
||||
const selectedItem = screen.getByRole('treeitem', { selected: true });
|
||||
expect(selectedItem).not.toHaveAttribute('aria-expanded');
|
||||
|
||||
// Try to expand with ArrowRight - should do nothing
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(selectedItem).not.toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility Markup', () => {
|
||||
it('should have proper ARIA roles and attributes on search input', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
expect(searchInput).toHaveAttribute('role', 'combobox');
|
||||
expect(searchInput).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(searchInput).toHaveAttribute('aria-autocomplete', 'list');
|
||||
expect(searchInput).toHaveAttribute('aria-controls');
|
||||
// aria-activedescendant may not be set initially, which is fine
|
||||
});
|
||||
|
||||
it('should have proper ARIA roles on tree structure', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
// Get all trees and verify at least one exists
|
||||
const trees = screen.getAllByRole('tree');
|
||||
expect(trees.length).toBeGreaterThan(0);
|
||||
|
||||
// Tree items
|
||||
const treeItems = screen.getAllByRole('treeitem');
|
||||
expect(treeItems.length).toBeGreaterThan(0);
|
||||
|
||||
treeItems.forEach((item) => {
|
||||
expect(item).toHaveAttribute('aria-selected');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper ARIA activedescendant relationship', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
|
||||
// Navigate to highlight an item
|
||||
await user.keyboard('{ArrowDown}');
|
||||
|
||||
// Should now have an active descendant
|
||||
const ariaActiveDescendant = searchInput.getAttribute('aria-activedescendant');
|
||||
expect(ariaActiveDescendant).toBeTruthy();
|
||||
|
||||
const selectedElement = screen.getByRole('treeitem', { selected: true });
|
||||
expect(selectedElement.id).toBe(ariaActiveDescendant);
|
||||
});
|
||||
|
||||
it('should have proper tree item IDs', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const treeItems = screen.getAllByRole('treeitem');
|
||||
|
||||
treeItems.forEach((item) => {
|
||||
const id = item.getAttribute('id');
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
// ID should be unique
|
||||
const elementsWithSameId = document.querySelectorAll(`#${id}`);
|
||||
expect(elementsWithSameId).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain accessibility state during interactions', async () => {
|
||||
await openSelector();
|
||||
await expandResultApplications();
|
||||
|
||||
const searchInput = screen.getByRole('combobox', { name: 'Search Applications' });
|
||||
|
||||
// Navigate and select an item
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
// Accessibility attributes should still be present
|
||||
expect(searchInput).toHaveAttribute('role', 'combobox');
|
||||
expect(searchInput).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(searchInput).toHaveAttribute('aria-autocomplete', 'list');
|
||||
|
||||
// Tree items should maintain their roles
|
||||
const treeItems = screen.getAllByRole('treeitem');
|
||||
treeItems.forEach((item) => {
|
||||
expect(item).toHaveAttribute('aria-selected');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12085,6 +12085,7 @@
|
|||
"selector": {
|
||||
"apply": "Apply",
|
||||
"cancel": "Cancel",
|
||||
"error-title": "An unexpected error happened",
|
||||
"input": {
|
||||
"placeholder": "Select scopes...",
|
||||
"removeAll": "Remove all scopes"
|
||||
|
|
Loading…
Reference in New Issue