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:
Tobias Skarhed 2025-09-01 16:59:50 +02:00 committed by GitHub
parent 4de9ec7310
commit dac6d04e24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 850 additions and 38 deletions

View File

@ -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,6 +107,18 @@ export const ScopesSelector = () => {
{opened && (
<Drawer title={t('scopes.selector.title', 'Select scopes')} size="sm" onClose={closeAndReset}>
<ErrorBoundary>
{({ error, errorInfo }) => {
if (error) {
return (
<ErrorWithStack
error={error}
title={t('scopes.selector.error-title', 'An unexpected error happened')}
errorInfo={errorInfo}
/>
);
}
return (
<div className={styles.drawerContainer}>
<div className={styles.treeContainer}>
{loading || !tree ? (
@ -116,13 +145,17 @@ export const ScopesSelector = () => {
<div className={styles.buttonsContainer}>
<Button variant="primary" data-testid="scopes-selector-apply" onClick={closeAndApply}>
<Trans i18nKey="scopes.selector.apply">Apply</Trans>
<Trans i18nKey="scopes.selector.apply">Apply</Trans>&nbsp;
<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>

View File

@ -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}
/>
</>
)}

View File

@ -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',

View File

@ -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}
/>
);
})}

View File

@ -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}
/>
);
}

View File

@ -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');
});
});
});

View File

@ -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 };
}

View File

@ -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),
};
}

View File

@ -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');
});
});
});
});

View File

@ -12085,6 +12085,7 @@
"selector": {
"apply": "Apply",
"cancel": "Cancel",
"error-title": "An unexpected error happened",
"input": {
"placeholder": "Select scopes...",
"removeAll": "Remove all scopes"