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 = (
)}
+
+ {description}
+
);
@@ -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}
/>
+
({
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",