diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index 5a40106070e..1e06753bcd7 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -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 && ( -
-
- {loading || !tree ? ( - - ) : ( - <> - { - scopesSelectorService.changeScopes(scopeIds, parentNodeId); - scopesSelectorService.closeAndReset(); - }} + + {({ error, errorInfo }) => { + if (error) { + return ( + - - )} -
+ ); + } + return ( +
+
+ {loading || !tree ? ( + + ) : ( + <> + { + scopesSelectorService.changeScopes(scopeIds, parentNodeId); + scopesSelectorService.closeAndReset(); + }} + /> + + )} +
-
- - -
-
+
+ + +
+
+ ); + }} +
)} diff --git a/public/app/features/scopes/selector/ScopesTree.tsx b/public/app/features/scopes/selector/ScopesTree.tsx index e21ec1bc808..a38bc60f13a 100644 --- a/public/app/features/scopes/selector/ScopesTree.tsx +++ b/public/app/features/scopes/selector/ScopesTree.tsx @@ -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} /> )} diff --git a/public/app/features/scopes/selector/ScopesTreeItem.tsx b/public/app/features/scopes/selector/ScopesTreeItem.tsx index 9640c4f7e85..12e1d8dc3f2 100644 --- a/public/app/features/scopes/selector/ScopesTreeItem.tsx +++ b/public/app/features/scopes/selector/ScopesTreeItem.tsx @@ -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 (
-
+
{isSelectable && !treeNode.expanded ? ( disableMultiSelect ? ( ) : ( { + 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', diff --git a/public/app/features/scopes/selector/ScopesTreeItemList.tsx b/public/app/features/scopes/selector/ScopesTreeItemList.tsx index 8f663b0db0a..5560ad61803 100644 --- a/public/app/features/scopes/selector/ScopesTreeItemList.tsx +++ b/public/app/features/scopes/selector/ScopesTreeItemList.tsx @@ -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 = ( -
+
{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} /> ); })} diff --git a/public/app/features/scopes/selector/ScopesTreeSearch.tsx b/public/app/features/scopes/selector/ScopesTreeSearch.tsx index e84407aa00f..755d605c783 100644 --- a/public/app/features/scopes/selector/ScopesTreeSearch.tsx +++ b/public/app/features/scopes/selector/ScopesTreeSearch.tsx @@ -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} /> ); } diff --git a/public/app/features/scopes/selector/useKeyboardInteractions.test.tsx b/public/app/features/scopes/selector/useKeyboardInteractions.test.tsx new file mode 100644 index 00000000000..648cdba9a6c --- /dev/null +++ b/public/app/features/scopes/selector/useKeyboardInteractions.test.tsx @@ -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; + 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'); + }); + }); +}); diff --git a/public/app/features/scopes/selector/useKeyboardInteractions.tsx b/public/app/features/scopes/selector/useKeyboardInteractions.tsx new file mode 100644 index 00000000000..07917030b2c --- /dev/null +++ b/public/app/features/scopes/selector/useKeyboardInteractions.tsx @@ -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(-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 }; +} diff --git a/public/app/features/scopes/selector/useScopesHighlighting.tsx b/public/app/features/scopes/selector/useScopesHighlighting.tsx new file mode 100644 index 00000000000..2cf727ade60 --- /dev/null +++ b/public/app/features/scopes/selector/useScopesHighlighting.tsx @@ -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), + }; +} diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts index a89c2e78910..398f28a95f8 100644 --- a/public/app/features/scopes/tests/tree.test.ts +++ b/public/app/features/scopes/tests/tree.test.ts @@ -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; 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'); + }); + }); + }); }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f5d9f8fae5b..0051df1d60e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12085,6 +12085,7 @@ "selector": { "apply": "Apply", "cancel": "Cancel", + "error-title": "An unexpected error happened", "input": { "placeholder": "Select scopes...", "removeAll": "Remove all scopes"