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
95 changes: 95 additions & 0 deletions pages/steps/permutations-annotation.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import { Icon } from '~components';
import Steps, { StepsProps } from '~components/steps';

import { SimplePage } from '../app/templates';
import createPermutations from '../utils/permutations';
import PermutationsView from '../utils/permutations-view';

export const stepsWithAnnotation: ReadonlyArray<StepsProps.Step> = [
{
annotation: <time dateTime="2024-05-01T15:01:23Z">3:01:23 PM</time>,
status: 'log',
header: 'Provided preferences',
},
{
annotation: <time dateTime="2024-05-01T15:03:10Z">3:03:10 PM</time>,
status: 'success',
statusIconAriaLabel: 'Success',
header: 'Created environment',
details: 'Environment created successfully.',
},
{
annotation: <time dateTime="2024-05-01T15:04:45Z">3:04:45 PM</time>,
status: 'error',
statusIconAriaLabel: 'Error',
header: 'Validation failed',
details: 'One or more resources could not be validated.',
},
];

export const varyingLengthAnnotationsSteps: ReadonlyArray<StepsProps.Step> = [
{
annotation: <time dateTime="2024-05-01T09:00:00Z">May 05, 2024, 9:00 AM</time>,
status: 'log',
header: 'Shorter timestamp',
details: 'This is used to test the shorter timestamp and and we have detail here',
},
{
annotation: (
<time dateTime="2024-12-31T23:59:59+14:00" title="December 31, 2024, 11:59:59 PM">
December 31, 2024, 11:59 PM
</time>
),
status: 'log',
details: 'One or more resources could not be validated. A detail in the middle',
header: 'Long timestamp',
},
{
status: 'loading',
header: 'No annotation yet',
details: 'This is used to test without annotation.',
},
];

const stepsPermutations = createPermutations<StepsProps>([
{
steps: [varyingLengthAnnotationsSteps, stepsWithAnnotation],
ariaLabel: ['test label'],
orientation: ['vertical', 'horizontal'],
renderStep: [
undefined,
step => ({
annotation: step.annotation,
header: step.header,
details: step.details && <i>Custom details for {step.details}</i>,
icon: <Icon ariaLabel="log" name="dot" variant="normal" />,
}),
step => ({
annotation: step.annotation,
header: <b>This step header ({step.header}) is wrapped in a custom HTML tag and has very long content</b>,
details: step.details && <i>Custom details for {step.details}</i>,
}),
],
},
{
connectorLines: ['none'],
orientation: ['vertical', 'horizontal'],
steps: [varyingLengthAnnotationsSteps],
ariaLabel: ['test label'],
},
]);

export default function StepsPermutations() {
return (
<SimplePage screenshotArea={{ disableAnimations: true }} title="Steps permutations with annotation">
<PermutationsView
permutations={stepsPermutations}
render={permutation => <div>{<Steps {...permutation} />}</div>}
/>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27962,7 +27962,8 @@ Each step definition has the following properties:
* \`status\` (string) - Status of the step corresponding to a status indicator. The \`log\` status renders a neutral dot marker.
* \`statusIconAriaLabel\` - (string) - (Optional) Alternative text for the status icon.
* \`header\` (ReactNode) - Summary corresponding to the step.
* \`details\` (ReactNode) - (Optional) Additional information corresponding to the step.",
* \`details\` (ReactNode) - (Optional) Additional information corresponding to the step.
* \`annotation\` (ReactNode) - (Optional) Content rendered at the start of the step, before the icon. Typically a timestamp in a timeline view.",
"name": "steps",
"optional": false,
"type": "ReadonlyArray<StepsProps.Step>",
Expand Down Expand Up @@ -45415,6 +45416,20 @@ Returns the current value of the input.",
},
{
"methods": [
{
"description": "Finds the annotation of a step",
"name": "findAnnotation",
"parameters": [],
"returnType": {
"isNullable": true,
"name": "ElementWrapper",
"typeArguments": [
{
"name": "HTMLElement",
},
],
},
},
{
"description": "Finds the details of a step",
"name": "findDetails",
Expand Down Expand Up @@ -55021,6 +55036,15 @@ Supported options:
},
{
"methods": [
{
"description": "Finds the annotation of a step",
"name": "findAnnotation",
"parameters": [],
"returnType": {
"isNullable": false,
"name": "ElementWrapper",
},
},
{
"description": "Finds the details of a step",
"name": "findDetails",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ exports[`test-utils selectors 1`] = `
"awsui_root_1cbgc",
],
"steps": [
"awsui_annotation_gxp9y",
"awsui_container_gxp9y",
"awsui_details_gxp9y",
"awsui_header_gxp9y",
Expand Down
64 changes: 64 additions & 0 deletions src/steps/__tests__/steps.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ describe('Steps', () => {
expect(wrapper.findItems()[3]!.findHeader()!.getElement()).toHaveTextContent('Analyzing security rules');
});

test('renders correct steps headers in horizontal orientation', () => {
const wrapper = renderSteps({ steps: successfulSteps, orientation: 'horizontal' });

expect(wrapper.findItems()[0]!.findHeader()!.getElement()).toHaveTextContent('Listed EC2 instances');
expect(wrapper.findItems()[1]!.findHeader()!.getElement()).toHaveTextContent('Gathered Security Group IDs');
expect(wrapper.findItems()[2]!.findHeader()!.getElement()).toHaveTextContent('Checked Cross Region Consent');
expect(wrapper.findItems()[3]!.findHeader()!.getElement()).toHaveTextContent('Analyzing security rules');
});

test('renders correct steps details', () => {
const wrapper = renderSteps({ steps: successfulSteps });

Expand All @@ -116,6 +125,48 @@ describe('Steps', () => {
expect(wrapper.findItems()[3]!.findDetails()).toBeNull();
});

describe('annotation', () => {
test('renders annotation content', () => {
const wrapper = renderSteps({ steps: [{ header: 'Event', status: 'log', annotation: '10:30' }] });

expect(wrapper.findItems()[0].findAnnotation()!.getElement()).toHaveTextContent('10:30');
});

test('does not render annotation when not provided', () => {
const wrapper = renderSteps({ steps: [{ header: 'Event', status: 'success' }] });

expect(wrapper.findItems()[0].findAnnotation()).toBeNull();
});

test('renders annotation content in horizontal mode', () => {
const wrapper = renderSteps({
steps: [{ header: 'Event', status: 'log', annotation: '10:30' }],
orientation: 'horizontal',
});

expect(wrapper.findItems()[0].findAnnotation()!.getElement()).toHaveTextContent('10:30');
});

test('renders annotation when using renderStep', () => {
const wrapper = renderSteps({
steps: [{ header: 'Event', status: 'log', annotation: '10:30' }],
renderStep: (step: StepsProps.Step) => ({ header: <span>Custom: {step.header}</span> }),
});

expect(wrapper.findItems()[0].findAnnotation()!.getElement()).toHaveTextContent('10:30');
});

test('renders annotation in horizontal mode when using renderStep', () => {
const wrapper = renderSteps({
steps: [{ header: 'Event', status: 'log', annotation: '10:30' }],
orientation: 'horizontal',
renderStep: (step: StepsProps.Step) => ({ header: <span>Custom: {step.header}</span> }),
});

expect(wrapper.findItems()[0].findAnnotation()!.getElement()).toHaveTextContent('10:30');
});
});

describe('Accessibility', () => {
test('applies ARIA label to steps', () => {
const testAriaLabel = 'Test aria label';
Expand Down Expand Up @@ -265,6 +316,19 @@ describe('Steps', () => {
expect(wrapper.findAll('[data-testid="custom-icon"]')).not.toHaveLength(0);
});

test('renders correct custom step headers in horizontal orientation', () => {
const wrapper = renderSteps({
steps: stepsForCustomRender,
orientation: 'horizontal',
renderStep: customRenderStep,
});

expect(wrapper.findItems()[0].findHeader()!.getElement()).toHaveTextContent(
'Custom: Checked Cross Region Consent'
);
expect(wrapper.findItems()[1].findHeader()!.getElement()).toHaveTextContent('Custom: Analyzing security rules');
});

test('does not render status indicators when using renderStep with icon', () => {
const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep });

Expand Down
2 changes: 2 additions & 0 deletions src/steps/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface StepsProps extends BaseComponentProps {
* * `statusIconAriaLabel` - (string) - (Optional) Alternative text for the status icon.
* * `header` (ReactNode) - Summary corresponding to the step.
* * `details` (ReactNode) - (Optional) Additional information corresponding to the step.
* * `annotation` (ReactNode) - (Optional) Content rendered at the start of the step, before the icon. Typically a timestamp in a timeline view.
*/
steps: ReadonlyArray<StepsProps.Step>;
/**
Expand Down Expand Up @@ -62,6 +63,7 @@ export namespace StepsProps {
statusIconAriaLabel?: string;
header: React.ReactNode;
details?: React.ReactNode;
annotation?: React.ReactNode;
}

export type Orientation = 'vertical' | 'horizontal';
Expand Down
63 changes: 46 additions & 17 deletions src/steps/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ const statusToColor: Record<StepsProps.Status, BoxProps.Color> = {
log: 'text-status-inactive',
};

const StepAnnotation = ({ children }: { children: StepsProps.Step['annotation'] }) => {
if (children === undefined || children === null) {
return null;
}
return <div className={styles.annotation}>{children}</div>;
};

const StepDetails = ({ children }: { children: StepsProps.Step['details'] }) => {
if (children === undefined || children === null) {
return null;
}
return <div className={styles.details}>{children}</div>;
};

const CustomStep = ({
step,
orientation,
Expand All @@ -39,37 +53,42 @@ const CustomStep = ({
renderStep: Required<StepsProps>['renderStep'];
hideConnectors: boolean;
}) => {
const { status, statusIconAriaLabel } = step;
const { status, statusIconAriaLabel, annotation } = step;
const { header, details, icon } = renderStep(step);
const iconNode = icon ? icon : <InternalStatusIcon type={status} iconAriaLabel={statusIconAriaLabel} />;
const connectorClassName = clsx(styles.connector, hideConnectors && styles['connector-hidden']);

if (orientation === 'horizontal') {
return (
<li className={styles.container}>
<div className={styles.header}>

@amanabiy amanabiy Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This element does not contain the header, so using the header class here is incorrect. It contains the icon and connector. This is unexpected behavior, which means our test util findHeader is not also being able to find it. The refactor helps to make sure findHeader finds the header.

<StepAnnotation>{annotation}</StepAnnotation>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include the annotation in renderSteps for feature parity with the header, icon and description?

@amanabiy amanabiy Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the API proposal, we agreed not to include annotation in renderStep because builders can already customize it as a ReactNode. If a use case emerges, we can always add it later, but I’d prefer not to block this PR on it.

<div className={styles['step-layout']}>
{iconNode}
<hr className={connectorClassName} role="none" />
</div>
<div className={styles['horizontal-header']}>{header}</div>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A separate horizontal-header class is not necessary anymore because this element is the actual header and can use the existing header class.

{details && <div className={styles.details}>{details}</div>}
<div className={styles.content}>
<div className={styles.header}>{header}</div>
<StepDetails>{details}</StepDetails>
</div>
</li>
);
}

// Vertical orientation: render the icon and the connector together in a column-1 "rail" so the
// connector starts directly beneath the icon and stretches the full height of the step. Unlike
// placing the header in the same row as the icon, this keeps the vertical line continuous even
// when the custom header wraps onto multiple lines.
// when the custom header wraps onto multiple lines. `annotation` (for example, a timeline timestamp)
// is rendered before the rail.
return (
<li className={clsx(styles.container, styles['custom-vertical'])}>
<StepAnnotation>{annotation}</StepAnnotation>
<div className={styles.rail}>
{iconNode}
<hr className={connectorClassName} role="none" />
</div>
<div className={styles.content}>
<div className={styles.header}>{header}</div>
{details && <div className={styles.details}>{details}</div>}
<StepDetails>{details}</StepDetails>
</div>
</li>
);
Expand All @@ -80,17 +99,25 @@ const InternalStep = ({
statusIconAriaLabel,
header,
details,
annotation,
orientation,
hideConnectors,
}: StepsProps.Step & { orientation: StepsProps.Orientation; hideConnectors: boolean }) => {
const connectorClassName = clsx(styles.connector, hideConnectors && styles['connector-hidden']);
return (
<li className={styles.container}>
<div className={styles.header}>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wrapper was named header even though it does not consistently contain only the header. Renaming it to step-layout reserves header for the actual header content used by findHeader().

<StepAnnotation>{annotation}</StepAnnotation>
<div className={styles['step-layout']}>
{orientation === 'vertical' ? (
<InternalStatusIndicator type={status} iconAriaLabel={statusIconAriaLabel}>
{header}
</InternalStatusIndicator>
<>
<div className={styles.header}>
<InternalStatusIndicator type={status} iconAriaLabel={statusIconAriaLabel}>
{header}
</InternalStatusIndicator>
</div>
<StepDetails>{details}</StepDetails>
<hr className={clsx(connectorClassName, styles['connector-continuation'])} role="none" />
</>
) : (
<>
<InternalBox color={statusToColor[status]}>
Expand All @@ -100,14 +127,14 @@ const InternalStep = ({
</>
)}
</div>
{orientation === 'vertical' ? (
<hr className={connectorClassName} role="none" />
) : (
<div className={styles['horizontal-header']}>
<InternalBox color={statusToColor[status]}>{header}</InternalBox>
{orientation === 'horizontal' && (
<div className={styles.content}>
<div className={styles.header}>
<InternalBox color={statusToColor[status]}>{header}</InternalBox>
</div>
<StepDetails>{details}</StepDetails>
</div>
)}
{details && <div className={styles.details}>{details}</div>}
</li>
);
};
Expand All @@ -124,14 +151,15 @@ const InternalSteps = ({
...props
}: InternalStepsProps) => {
const hideConnectors = connectorLines === 'none';
const hasAnnotations = steps.some(step => step.annotation !== undefined && step.annotation !== null);
return (
<div
{...props}
className={clsx(styles.root, props.className, orientation === 'horizontal' ? styles.horizontal : styles.vertical)}
ref={__internalRootRef}
>
<ol
className={styles.list}
className={clsx(styles.list, orientation === 'vertical' && hasAnnotations && styles['with-annotation'])}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
Expand All @@ -152,6 +180,7 @@ const InternalSteps = ({
statusIconAriaLabel={step.statusIconAriaLabel}
header={step.header}
details={step.details}
annotation={step.annotation}
orientation={orientation}
hideConnectors={hideConnectors}
/>
Expand Down
Loading
Loading