+
{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"