From 755fd9fae323525f91ac2d00626483b5f766d1ad Mon Sep 17 00:00:00 2001 From: KodudulaAshishUiPath Date: Fri, 23 Oct 2026 12:07:36 +0530 Subject: [PATCH] fix(apollo-react): announce Toolbox row details and submenus [MST-14409] [MST-14937] --- .../components/Toolbox/ListView.test.tsx | 15 ++++++- .../canvas/components/Toolbox/ListView.tsx | 41 ++++++++++++++++--- .../components/Toolbox/Toolbox.test.tsx | 28 ++++++++++++- .../src/canvas/components/Toolbox/Toolbox.tsx | 20 +++++++++ .../apollo-react/src/canvas/locales/en.json | 2 + 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/apollo-react/src/canvas/components/Toolbox/ListView.test.tsx b/packages/apollo-react/src/canvas/components/Toolbox/ListView.test.tsx index 8f94f33fc..4fbfe9907 100644 --- a/packages/apollo-react/src/canvas/components/Toolbox/ListView.test.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbox/ListView.test.tsx @@ -57,6 +57,7 @@ describe('ListView', () => { const defaultProps = { items: [] as ListItem[], onItemClick: mockOnItemClick, + keyboardNav: false, }; describe('Empty states', () => { @@ -297,7 +298,7 @@ describe('ListView', () => { render(); expect(screen.getByText('Test Item')).toBeInTheDocument(); - expect(screen.queryByText('Hover-only explanation')).not.toBeInTheDocument(); + expect(screen.getByText('Hover-only explanation')).not.toBeVisible(); }); it('should render a badge tag when the item has one', () => { @@ -466,6 +467,18 @@ describe('ListView', () => { }); }); + it('describes a row by its detail and submenu', () => { + const items: ListItem[] = [ + { id: 'agent', name: 'Agent', data: {}, detail: 'Autonomous agents', children: [] }, + ]; + + render(); + + expect(screen.getByRole('option', { name: 'Agent' })).toHaveAccessibleDescription( + 'Autonomous agents. Has submenu' + ); + }); + describe('Row styling (contentColor / trailingIcon)', () => { it('should apply contentColor to the name and leading icon and render the trailing icon', () => { const items: ListItem[] = [ diff --git a/packages/apollo-react/src/canvas/components/Toolbox/ListView.tsx b/packages/apollo-react/src/canvas/components/Toolbox/ListView.tsx index 02afc5bdb..35505ce71 100644 --- a/packages/apollo-react/src/canvas/components/Toolbox/ListView.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbox/ListView.tsx @@ -2,8 +2,9 @@ import { Column } from '@uipath/apollo-react/canvas/layouts'; import { CanvasIcon, cx, partition } from '@uipath/apollo-react/canvas/utils'; import { badgeVariants, cn, Skeleton } from '@uipath/apollo-wind'; import { motion } from 'motion/react'; -import { forwardRef, memo, useCallback, useImperativeHandle, useMemo } from 'react'; +import { forwardRef, memo, useCallback, useId, useImperativeHandle, useMemo } from 'react'; import { List, type ListImperativeAPI, type RowComponentProps } from 'react-window'; +import { useSafeLingui } from '../../../i18n'; import { useCanvasTheme } from '../BaseCanvas/CanvasThemeContext'; import { CanvasTooltip } from '../CanvasTooltip'; import { InitialsBadge } from '../shared/InitialsBadge'; @@ -58,9 +59,10 @@ export type ListItem = { */ description?: string; /** - * Extra text shown only in the row's hover tooltip, never inline. Use for - * content that would make the row too tall to show inline (e.g. a full - * explanation) while a shorter {@link ListItem.description} stays on the row. + * Extra text shown only in the row's tooltip, never inline, and exposed as the + * row's accessible description. Use for content that would make the row too + * tall to show inline (e.g. a full explanation) while a shorter + * {@link ListItem.description} stays on the row. */ detail?: string; /** @@ -237,6 +239,7 @@ const LIST_SCROLLBAR_CLASS = export interface ListViewRowProps { renderedItems: RenderItem[]; activeIndex: number; + keyboardNav: boolean; isLoading?: boolean; isDarkMode?: boolean; onItemClick: (item: T, index: number) => void; @@ -250,12 +253,15 @@ const ListViewRow = memo( ariaAttributes, renderedItems, activeIndex, + keyboardNav, isLoading, isDarkMode, onItemClick, onItemHover, }: RowComponentProps>) => { const renderItem = renderedItems[index]!; + const { _ } = useSafeLingui(); + const descriptionId = useId(); // `style` from react-window already carries the row height (rowHeight), so // we only layer on the padding that insets content from the focus ring. @@ -319,6 +325,13 @@ const ListViewRow = memo( const isActive = index === activeIndex; + const description = [ + item.detail, + item.children ? _({ id: 'toolbox.item.submenu', message: 'Has submenu' }) : undefined, + ] + .filter(Boolean) + .join('. '); + const button = ( )} + ); @@ -399,6 +416,8 @@ const ListViewRow = memo( } placement="right" delay + isOpen={(isActive && keyboardNav) || undefined} + hide={(keyboardNav && !isActive) || undefined} contentClassName="max-w-64" disableSkipDelay > @@ -437,6 +456,7 @@ interface ListViewProps { * when `items` is empty, skeleton rows stand in until the answer lands. */ searchPending?: boolean; + keyboardNav: boolean; enableSections?: boolean; /** * Render skeleton placeholder rows when `items` is empty, independent of @@ -478,6 +498,7 @@ const ListViewInner = forwardRef(function ListView( renderEmptyState, isLoading = false, searchPending = false, + keyboardNav, enableSections = true, loadingSkeleton, }: ListViewProps, @@ -501,8 +522,16 @@ const ListViewInner = forwardRef(function ListView( useImperativeHandle(ref, () => ({ renderedItems }), [renderedItems]); const rowProps = useMemo( - () => ({ renderedItems, activeIndex, isLoading, isDarkMode, onItemClick, onItemHover }), - [renderedItems, activeIndex, isLoading, isDarkMode, onItemClick, onItemHover] + () => ({ + renderedItems, + activeIndex, + keyboardNav, + isLoading, + isDarkMode, + onItemClick, + onItemHover, + }), + [renderedItems, activeIndex, keyboardNav, isLoading, isDarkMode, onItemClick, onItemHover] ); const getRowHeight = useCallback( diff --git a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.test.tsx b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.test.tsx index 843f1fbda..4cc637b2a 100644 --- a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.test.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.test.tsx @@ -1,8 +1,21 @@ +import type { PropsWithChildren } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { act, render, screen, type UserEvent, userEvent } from '../../utils/testing'; +import { act, fireEvent, render, screen, type UserEvent, userEvent } from '../../utils/testing'; import type { ListItem } from './ListView'; import { Toolbox } from './Toolbox'; +vi.mock('@uipath/apollo-wind/components/ui/tooltip', () => ({ + Tooltip: ({ children, open }: PropsWithChildren<{ open?: boolean }>) => ( +
+ {children} +
+ ), + TooltipContent: () => null, + TooltipPortal: ({ children }: PropsWithChildren) => <>{children}, + TooltipProvider: ({ children }: PropsWithChildren) => <>{children}, + TooltipTrigger: ({ children }: PropsWithChildren) => <>{children}, +})); + describe('Toolbox', () => { const mockItems: ListItem[] = [ { @@ -648,6 +661,17 @@ describe('Toolbox', () => { const getActiveItem = () => document.querySelector('[aria-selected="true"]'); + it('opens the active row tooltip on keyboard navigation until the pointer moves', async () => { + render(); + + await user.keyboard('{ArrowDown}'); + const [first] = screen.getAllByTestId('tooltip'); + expect(first).toHaveAttribute('data-open', 'true'); + + fireEvent.pointerMove(screen.getByTestId('toolbox-container')); + expect(first).toHaveAttribute('data-open', 'false'); + }); + it('should move focus from search to first item on ArrowDown', async () => { render(); @@ -842,6 +866,7 @@ describe('Toolbox', () => { await user.keyboard('{ArrowRight}'); expect(screen.getByText('Child 1')).toBeInTheDocument(); expect(screen.queryByText('Item 1')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Item 2, 1 item'); }); it('should not navigate with ArrowRight on leaf item', async () => { @@ -1782,6 +1807,7 @@ describe('Toolbox', () => { expect(screen.getAllByTestId('list-item-skeleton')).toHaveLength(3); expect(screen.queryByText('No nodes found')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); }); it('renders preemptive section headers when childrenLoading provides sections', async () => { diff --git a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx index bcf5e1fa3..a7cd012e6 100644 --- a/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx +++ b/packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx @@ -225,6 +225,7 @@ export function Toolbox({ }>(); const [activeIndex, setActiveIndex] = useState(SEARCH_BAR_INDEX); + const [keyboardNav, setKeyboardNav] = useState(false); const containerRef = useRef(null); const transitionTimeoutRef = useRef | undefined>(undefined); @@ -263,6 +264,19 @@ export function Toolbox({ [isSearching, isSearchingInitialItems, searchedItems, items] ); + const categoryAnnouncement = + currentParentItem && + !isSearching && + !awaitingChildren && + !loading && + !currentParentItem.childrenLoading + ? _({ + id: 'toolbox.category.announcement', + message: '{name}, {count, plural, one {# item} other {# items}}', + values: { name: currentParentItem.name, count: displayedItems.length }, + }) + : ''; + const startTransition = useCallback((direction: AnimationDirection) => { if (transitionTimeoutRef.current) { clearTimeout(transitionTimeoutRef.current); @@ -539,6 +553,7 @@ export function Toolbox({ const handleNavigationKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isTransitioning) return; + setKeyboardNav(true); const renderedItems = listViewRef.current?.renderedItems ?? []; @@ -716,6 +731,7 @@ export function Toolbox({ ref={containerRef} data-testid="toolbox-container" style={{ maxHeight: '100%', overflow: 'hidden' }} + onPointerMove={() => setKeyboardNav(false)} > ({ onBack={handleBackTransition} showBackButton={navigationStack.canGoBack} /> + + {categoryAnnouncement} + ({ ref={listViewRef} isLoading={awaitingChildren || loading} searchPending={searchLoading} + keyboardNav={keyboardNav} items={displayedItems} activeIndex={activeIndex} listRef={listRef} diff --git a/packages/apollo-react/src/canvas/locales/en.json b/packages/apollo-react/src/canvas/locales/en.json index d8fd0564b..39d8019a9 100644 --- a/packages/apollo-react/src/canvas/locales/en.json +++ b/packages/apollo-react/src/canvas/locales/en.json @@ -83,6 +83,8 @@ "add-node-panel.search-nodes": "Search nodes", "toolbox.search": "Search", "toolbox.search.clear": "Clear search", + "toolbox.item.submenu": "Has submenu", + "toolbox.category.announcement": "{name}, {count, plural, one {# item} other {# items}}", "toolbox.search.pending": "Searching", "loop-node.add-node": "Add node to loop", "loop-node.mode.parallel": "Parallel",