Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe('ListView', () => {
const defaultProps = {
items: [] as ListItem[],
onItemClick: mockOnItemClick,
keyboardNav: false,
};

describe('Empty states', () => {
Expand Down Expand Up @@ -297,7 +298,7 @@ describe('ListView', () => {
render(<ListView {...defaultProps} items={items} />);

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', () => {
Expand Down Expand Up @@ -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(<ListView {...defaultProps} items={items} />);

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[] = [
Expand Down
41 changes: 35 additions & 6 deletions packages/apollo-react/src/canvas/components/Toolbox/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,9 +59,10 @@ export type ListItem<T = any> = {
*/
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;
/**
Expand Down Expand Up @@ -237,6 +239,7 @@ const LIST_SCROLLBAR_CLASS =
export interface ListViewRowProps<T extends ListItem> {
renderedItems: RenderItem<T>[];
activeIndex: number;
keyboardNav: boolean;
isLoading?: boolean;
isDarkMode?: boolean;
onItemClick: (item: T, index: number) => void;
Expand All @@ -250,12 +253,15 @@ const ListViewRow = memo(
ariaAttributes,
renderedItems,
activeIndex,
keyboardNav,
isLoading,
isDarkMode,
onItemClick,
onItemHover,
}: RowComponentProps<ListViewRowProps<T>>) => {
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.
Expand Down Expand Up @@ -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 = (
<motion.button
{...ariaAttributes}
Expand All @@ -327,6 +340,7 @@ const ListViewRow = memo(
id={`toolbox-item-${item.id}`}
role="option"
aria-selected={isActive}
aria-describedby={descriptionId}
style={buttonStyle}
onClick={handleButtonClick}
onHoverStart={handleButtonHover}
Expand Down Expand Up @@ -382,6 +396,9 @@ const ListViewRow = memo(
{!!item.children && (
<CanvasIcon icon="chevron-right" size={16} color="var(--canvas-foreground-de-emp)" />
)}
<span id={descriptionId} hidden>
{description}
</span>
</motion.button>
);

Expand All @@ -399,6 +416,8 @@ const ListViewRow = memo(
}
placement="right"
delay
isOpen={(isActive && keyboardNav) || undefined}
hide={(keyboardNav && !isActive) || undefined}
contentClassName="max-w-64"
disableSkipDelay
>
Expand Down Expand Up @@ -437,6 +456,7 @@ interface ListViewProps<T extends ListItem> {
* 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
Expand Down Expand Up @@ -478,6 +498,7 @@ const ListViewInner = forwardRef(function ListView<T extends ListItem>(
renderEmptyState,
isLoading = false,
searchPending = false,
keyboardNav,
enableSections = true,
loadingSkeleton,
}: ListViewProps<T>,
Expand All @@ -501,8 +522,16 @@ const ListViewInner = forwardRef(function ListView<T extends ListItem>(
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }>) => (
<div data-testid="tooltip" data-open={String(!!open)}>
{children}
</div>
),
TooltipContent: () => null,
TooltipPortal: ({ children }: PropsWithChildren) => <>{children}</>,
TooltipProvider: ({ children }: PropsWithChildren) => <>{children}</>,
TooltipTrigger: ({ children }: PropsWithChildren) => <>{children}</>,
}));

describe('Toolbox', () => {
const mockItems: ListItem[] = [
{
Expand Down Expand Up @@ -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(<Toolbox {...defaultProps} />);

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(<Toolbox {...defaultProps} />);

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export function Toolbox<T>({
}>();

const [activeIndex, setActiveIndex] = useState(SEARCH_BAR_INDEX);
const [keyboardNav, setKeyboardNav] = useState(false);

const containerRef = useRef<HTMLDivElement>(null);
const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
Expand Down Expand Up @@ -263,6 +264,19 @@ export function Toolbox<T>({
[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);
Expand Down Expand Up @@ -539,6 +553,7 @@ export function Toolbox<T>({
const handleNavigationKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isTransitioning) return;
setKeyboardNav(true);

const renderedItems = listViewRef.current?.renderedItems ?? [];

Expand Down Expand Up @@ -716,6 +731,7 @@ export function Toolbox<T>({
ref={containerRef}
data-testid="toolbox-container"
style={{ maxHeight: '100%', overflow: 'hidden' }}
onPointerMove={() => setKeyboardNav(false)}
>
<Column
px={TOOLBOX_PADDING_X}
Expand All @@ -731,6 +747,9 @@ export function Toolbox<T>({
onBack={handleBackTransition}
showBackButton={navigationStack.canGoBack}
/>
<output aria-live="polite" className="sr-only">
{categoryAnnouncement}
</output>

<SearchBox
value={search}
Expand Down Expand Up @@ -758,6 +777,7 @@ export function Toolbox<T>({
ref={listViewRef}
isLoading={awaitingChildren || loading}
searchPending={searchLoading}
keyboardNav={keyboardNav}
items={displayedItems}
activeIndex={activeIndex}
listRef={listRef}
Expand Down
2 changes: 2 additions & 0 deletions packages/apollo-react/src/canvas/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading