Skip to content
Open
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 @@ -65,4 +65,43 @@ describe('StageHeaderChips', () => {

expect(screen.getByTestId('stage-ends-case-badge-case')).toHaveTextContent('Ends case');
});

it('makes a status pill with a tooltip a tab stop described by its tooltip', async () => {
render(
<StageHeaderChips
id="case"
chips={[
{ type: StageHeaderChipType.Optional, tooltip: 'Not required for case completion' },
{ type: StageHeaderChipType.EndsCase },
]}
/>
);

await userEvent.tab();
const optional = screen.getByRole('img', { name: 'Optional' });
expect(optional).toHaveFocus();
expect(optional).toHaveAccessibleDescription('Not required for case completion');

await userEvent.tab();
expect(document.body).toHaveFocus();
});

it('describes a clickable status pill by its tooltip', () => {
render(
<StageHeaderChips
id="case"
chips={[
{
type: StageHeaderChipType.EndsCase,
tooltip: 'Entering this stage ends the case',
onClick: vi.fn(),
},
]}
/>
);

expect(screen.getByRole('button', { name: 'Ends case' })).toHaveAccessibleDescription(
'Entering this stage ends the case'
);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Icon } from '@uipath/apollo-core';
import { cn } from '@uipath/apollo-wind';
import { Flag } from 'lucide-react';
import { memo } from 'react';
import { memo, useId } from 'react';
import { ChecklistIcon } from '../../../icons';
import { EntryConditionIcon, ExitConditionIcon, ReturnToOriginIcon } from '../../icons';
import { CanvasTooltip } from '../CanvasTooltip';
Expand Down Expand Up @@ -66,7 +66,8 @@ const STATUS_BADGE_BASE_CLASS =
/**
* Status pill (e.g. "Optional", "Ends case"). Interactive when an `onClick` is supplied —
* it then renders a focusable button with hover that navigates the consumer to the related control;
* otherwise it renders a plain, non-interactive label.
* otherwise it renders a plain label, focusable only when it has a tooltip to open.
* The tooltip text also follows the pill in the DOM as its description, so screen readers get it while the tooltip is closed.
*/
const StageStatusBadge = ({
label,
Expand All @@ -83,32 +84,60 @@ const StageStatusBadge = ({
testId: string;
onClick?: () => void;
}) => {
const badge = onClick ? (
<button
type="button"
data-testid={testId}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`${STATUS_BADGE_BASE_CLASS} ${className} ${hoverClassName} cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-offset-2`}
>
{label}
</button>
) : (
<span data-testid={testId} className={`${STATUS_BADGE_BASE_CLASS} ${className}`}>
{label}
</span>
);
const descriptionId = useId();
const describedBy = tooltip ? descriptionId : undefined;

let badge: React.ReactElement;
if (onClick) {
badge = (
<button
type="button"
data-testid={testId}
aria-describedby={describedBy}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`${STATUS_BADGE_BASE_CLASS} ${className} ${hoverClassName} cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-offset-2`}
>
{label}
</button>
);
} else if (tooltip) {
badge = (
<span
data-testid={testId}
role="img"
aria-label={label}
aria-describedby={describedBy}
// biome-ignore lint/a11y/noNoninteractiveTabindex: Focusable so keyboard users can open the tooltip. It has no action, so it stays an image rather than a button.
tabIndex={0}
className={`${STATUS_BADGE_BASE_CLASS} ${className} focus-visible:outline-2 focus-visible:outline-offset-2`}
>
{label}
</span>
);
} else {
badge = (
<span data-testid={testId} className={`${STATUS_BADGE_BASE_CLASS} ${className}`}>
{label}
</span>
);
}

if (tooltip) {
return (
if (!tooltip) {
return badge;
}
return (
<>
<CanvasTooltip placement="bottom" content={tooltip}>
{badge}
</CanvasTooltip>
);
}
return badge;
<span id={descriptionId} className="sr-only">
{tooltip}
Comment on lines +136 to +137
</span>
</>
);
};

export interface StageHeaderChipsProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,13 @@ export const ExecutionStatus: Story = {
},
],
],
headerChips: [{ type: StageHeaderChipType.EndsCase, label: 'Ended case' }],
headerChips: [
{
type: StageHeaderChipType.EndsCase,
label: 'Ended case',
tooltip: 'The case ended when this stage completed',
},
],
},
execution: {
stageStatus: {
Expand Down Expand Up @@ -501,8 +507,12 @@ export const ExecutionStatus: Story = {
[{ id: '2', label: 'Underwriting Verification', icon: <VerificationIcon /> }],
],
headerChips: [
{ type: StageHeaderChipType.Optional },
{ type: StageHeaderChipType.EndsCase, variant: 'outline' },
{ type: StageHeaderChipType.Optional, tooltip: 'Not required for case completion' },
{
type: StageHeaderChipType.EndsCase,
variant: 'outline',
tooltip: 'The case completes when this stage exits or completes',
},
],
},
onTaskClick: (id: string) => window.alert(`Task clicked: ${id}`),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import userEvent from '@testing-library/user-event';
import { ReactFlowProvider } from '@uipath/apollo-react/canvas/xyflow/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DAY, HOUR, MINUTE, SECOND, WEEK } from '../../../test/durations';
import { act, fireEvent, render, screen } from '../../utils/testing';
import type { StageNodeProps } from './StageNode.types';
import { StageHeaderChipType, type StageNodeProps } from './StageNode.types';
import { StageNodeHeader } from './StageNodeHeader';

// Uses the real Radix tooltip (the shared setup stubs it out) to cover that the exact duration
Expand Down Expand Up @@ -65,3 +66,42 @@ describe('StageNodeHeader duration tooltip (real CanvasTooltip)', () => {
expect(duration).toHaveAttribute('data-state', 'closed');
});
});

describe('StageNodeHeader status pill tooltip (real CanvasTooltip)', () => {
it('opens on keyboard focus and closes on blur and Escape', async () => {
const user = userEvent.setup();
const props = {
id: 'stage-1',
stageDetails: {
label: 'Stage 1',
tasks: [],
headerChips: [
{ type: StageHeaderChipType.Optional, tooltip: 'Not required for case completion' },
],
},
} as unknown as StageNodeProps;
render(
<ReactFlowProvider>
<StageNodeHeader
props={props}
isReadOnly={true}
status={undefined}
handleTaskAddClick={vi.fn()}
/>
</ReactFlowProvider>
);
const pill = screen.getByTestId('stage-optional-badge-stage-1');

await user.tab();
expect(pill).toHaveFocus();
expect(pill).toHaveAttribute('data-state', 'instant-open');

await user.tab();
expect(pill).toHaveAttribute('data-state', 'closed');

await user.tab({ shift: true });
expect(pill).toHaveAttribute('data-state', 'instant-open');
await user.keyboard('{Escape}');
expect(pill).toHaveAttribute('data-state', 'closed');
});
});
Loading