Skip to content

Commit 8d87555

Browse files
committed
feat: Add horizontal layout and custom rendering to steps
1 parent 2935dd6 commit 8d87555

File tree

9 files changed

+306
-17
lines changed

9 files changed

+306
-17
lines changed

pages/steps/permutations-utils.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React from 'react';
44

55
import Box from '~components/box';
66
import Button from '~components/button';
7+
import Icon from '~components/icon';
78
import Link from '~components/link';
89
import Popover from '~components/popover';
910
import { StepsProps } from '~components/steps';
@@ -738,6 +739,7 @@ const changesetStepsInteractive: ReadonlyArray<StepsProps.Step> = [
738739

739740
export const stepsPermutations = createPermutations<StepsProps>([
740741
{
742+
orientation: ['vertical', 'horizontal'],
741743
steps: [
742744
initialSteps,
743745
loadingSteps,
@@ -761,4 +763,20 @@ export const stepsPermutations = createPermutations<StepsProps>([
761763
],
762764
ariaLabel: ['test label'],
763765
},
766+
{
767+
steps: [allStatusesSteps, successfulSteps],
768+
ariaLabel: ['test label'],
769+
orientation: ['vertical', 'horizontal'],
770+
renderStep: [
771+
step => ({
772+
header: <b>Custom header for {step.header}</b>,
773+
details: step.details && <i>Custom details for {step.details}</i>,
774+
}),
775+
step => ({
776+
header: step.header,
777+
details: step.details && <i>Custom details for {step.details}</i>,
778+
icon: <Icon ariaLabel="success" name="status-positive" variant="success" />,
779+
}),
780+
],
781+
},
764782
]);

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23069,6 +23069,49 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
2306923069
"optional": true,
2307023070
"type": "string",
2307123071
},
23072+
{
23073+
"defaultValue": "'vertical'",
23074+
"description": "The visual orientation of the steps (vertical or horizontal).
23075+
By default the orientation is vertical.",
23076+
"inlineType": {
23077+
"name": "StepsProps.Orientation",
23078+
"type": "union",
23079+
"values": [
23080+
"horizontal",
23081+
"vertical",
23082+
],
23083+
},
23084+
"name": "orientation",
23085+
"optional": true,
23086+
"systemTags": [
23087+
"core",
23088+
],
23089+
"type": "string",
23090+
},
23091+
{
23092+
"description": "Render a step. This overrides the default icon, header, and details provided by the component.
23093+
The function is called for each step and should return an object with the following keys:
23094+
* \`header\` (React.ReactNode) - Summary corresponding to the step.
23095+
* \`details\` (React.ReactNode) - (Optional) Additional information corresponding to the step.
23096+
* \`icon\` (React.ReactNode) - (Optional) Replaces the standard step icon from the status indicator.",
23097+
"inlineType": {
23098+
"name": "(step: StepsProps.Step) => { header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; }",
23099+
"parameters": [
23100+
{
23101+
"name": "step",
23102+
"type": "StepsProps.Step",
23103+
},
23104+
],
23105+
"returnType": "{ header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; }",
23106+
"type": "function",
23107+
},
23108+
"name": "renderStep",
23109+
"optional": true,
23110+
"systemTags": [
23111+
"core",
23112+
],
23113+
"type": "((step: StepsProps.Step) => { header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; })",
23114+
},
2307223115
{
2307323116
"description": "An array of individual steps
2307423117

src/status-indicator/internal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export default function StatusIndicator({
8282
styles.container,
8383
styles[`display-${__display}`],
8484
wrapText === false && styles['overflow-ellipsis'],
85-
__animate && styles['container-fade-in']
85+
__animate && styles['container-fade-in'],
86+
!children && styles['no-text']
8687
)}
8788
>
8889
<span

src/status-indicator/styles.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ $_color-overrides: (
6161
padding-inline-end: awsui.$space-xxs;
6262
}
6363
}
64+
65+
&.no-text > .icon {
66+
padding-inline-end: 0;
67+
}
6468
}
6569

6670
.overflow-ellipsis {

src/steps/__tests__/steps.test.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ const stepsWithIconAriaLabel: ReadonlyArray<StepsProps.Step> = [
6868
},
6969
];
7070

71+
const stepsForCustomRender: ReadonlyArray<StepsProps.Step> = [
72+
{
73+
header: 'Checked Cross Region Consent',
74+
status: 'success',
75+
},
76+
{
77+
header: 'Analyzing security rules',
78+
status: 'loading',
79+
details: 'Step details',
80+
},
81+
];
82+
7183
const renderSteps = (props: Partial<StepsProps>) => {
7284
const renderResult = render(<Steps {...defaultProps} {...props} />);
7385
return createWrapper(renderResult.container).findSteps()!;
@@ -142,4 +154,63 @@ describe('Steps', () => {
142154
});
143155
});
144156
});
157+
158+
describe('orientation', () => {
159+
test('renders with default vertical orientation', () => {
160+
const wrapper = renderSteps({ steps: successfullSteps });
161+
162+
expect(wrapper.getElement()).not.toHaveClass(stepsStyles.horizontal);
163+
});
164+
165+
test('renders with horizontal orientation when explicitly set', () => {
166+
const wrapper = renderSteps({
167+
steps: successfullSteps,
168+
orientation: 'horizontal',
169+
});
170+
171+
expect(wrapper.getElement()).toHaveClass(stepsStyles.horizontal);
172+
});
173+
174+
test('renders with vertical orientation', () => {
175+
const wrapper = renderSteps({
176+
steps: successfullSteps,
177+
orientation: 'vertical',
178+
});
179+
180+
expect(wrapper.getElement()).not.toHaveClass(stepsStyles.horizontal);
181+
});
182+
});
183+
184+
describe('renderStep', () => {
185+
const customRenderStep = (step: StepsProps.Step) => ({
186+
header: <span data-testid="custom-header">Custom: {step.header}</span>,
187+
details: step.details ? <div data-testid="custom-details">Details: {step.details}</div> : undefined,
188+
icon: <span data-testid="custom-icon">icon</span>,
189+
});
190+
191+
test('renders custom content when using renderStep', () => {
192+
const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep });
193+
194+
const customHeaders = wrapper.findAll('[data-testid="custom-header"]');
195+
expect(customHeaders).toHaveLength(stepsForCustomRender.length);
196+
197+
expect(customHeaders[0].getElement()).toHaveTextContent('Checked Cross Region Consent');
198+
expect(customHeaders[1].getElement()).toHaveTextContent('Analyzing security rules');
199+
});
200+
201+
test('renders custom details when using renderStep', () => {
202+
const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep });
203+
204+
const customDetails = wrapper.findAll('[data-testid="custom-details"]');
205+
// Only last step has details
206+
expect(customDetails).toHaveLength(1);
207+
expect(customDetails[0].getElement()).toHaveTextContent('Step details');
208+
});
209+
210+
test('does not render status indicators when using renderStep with icon', () => {
211+
const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep });
212+
213+
expect(wrapper.findItems()[0].findHeader()?.findStatusIndicator()).toBeNull();
214+
});
215+
});
145216
});

src/steps/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import InternalSteps from './internal';
1212

1313
export { StepsProps };
1414

15-
const Steps = ({ steps, ...props }: StepsProps) => {
15+
const Steps = ({ steps, orientation = 'vertical', ...props }: StepsProps) => {
1616
const baseProps = getBaseProps(props);
1717
const baseComponentProps = useBaseComponent('Steps');
1818
const externalProps = getExternalProps(props);
1919

20-
return <InternalSteps {...baseProps} {...baseComponentProps} {...externalProps} steps={steps} />;
20+
return (
21+
<InternalSteps {...baseProps} {...baseComponentProps} {...externalProps} steps={steps} orientation={orientation} />
22+
);
2123
};
2224

2325
applyDisplayName(Steps, 'Steps');

src/steps/interfaces.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@ export interface StepsProps extends BaseComponentProps {
1414
* * `details` (ReactNode) - (Optional) Additional information corresponding to the step.
1515
*/
1616
steps: ReadonlyArray<StepsProps.Step>;
17+
/**
18+
* The visual orientation of the steps (vertical or horizontal).
19+
* By default the orientation is vertical.
20+
*
21+
* @awsuiSystem core
22+
*/
23+
orientation?: StepsProps.Orientation;
24+
/**
25+
* Render a step. This overrides the default icon, header, and details provided by the component.
26+
* The function is called for each step and should return an object with the following keys:
27+
* * `header` (React.ReactNode) - Summary corresponding to the step.
28+
* * `details` (React.ReactNode) - (Optional) Additional information corresponding to the step.
29+
* * `icon` (React.ReactNode) - (Optional) Replaces the standard step icon from the status indicator.
30+
*
31+
* @awsuiSystem core
32+
*/
33+
renderStep?: (step: StepsProps.Step) => {
34+
header: React.ReactNode;
35+
details?: React.ReactNode;
36+
icon?: React.ReactNode;
37+
};
1738
/**
1839
* Provides an `aria-label` to the progress steps container.
1940
* Don't use `ariaLabel` and `ariaLabelledby` at the same time.
@@ -40,4 +61,6 @@ export namespace StepsProps {
4061
header: React.ReactNode;
4162
details?: React.ReactNode;
4263
}
64+
65+
export type Orientation = 'vertical' | 'horizontal';
4366
}

src/steps/internal.tsx

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import React from 'react';
44
import clsx from 'clsx';
55

6+
import { BoxProps } from '../box/interfaces';
7+
import InternalBox from '../box/internal';
68
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
79
import { SomeRequired } from '../internal/types';
810
import StatusIndicator from '../status-indicator/internal';
@@ -12,45 +14,107 @@ import styles from './styles.css.js';
1214

1315
type InternalStepsProps = SomeRequired<StepsProps, 'steps'> & InternalBaseComponentProps;
1416

15-
const InternalStep = ({ status, statusIconAriaLabel, header, details }: StepsProps.Step) => {
17+
const statusToColor: Record<StepsProps.Status, BoxProps.Color> = {
18+
error: 'text-status-error',
19+
warning: 'text-status-warning',
20+
success: 'text-status-success',
21+
info: 'text-status-info',
22+
stopped: 'text-status-inactive',
23+
pending: 'text-status-inactive',
24+
'in-progress': 'text-status-inactive',
25+
loading: 'text-status-inactive',
26+
};
27+
28+
const CustomStep = ({
29+
step,
30+
orientation,
31+
renderStep,
32+
}: {
33+
step: StepsProps.Step;
34+
orientation: StepsProps.Orientation;
35+
renderStep: Required<StepsProps>['renderStep'];
36+
}) => {
37+
const { status, statusIconAriaLabel } = step;
38+
const { header, details, icon } = renderStep(step);
39+
return (
40+
<li className={styles.container}>
41+
<div className={styles.header}>
42+
{icon ? icon : <StatusIndicator type={status} iconAriaLabel={statusIconAriaLabel} />}
43+
{orientation === 'vertical' ? header : <hr className={styles.connector} role="none" />}
44+
</div>
45+
{orientation === 'vertical' ? (
46+
<hr className={styles.connector} role="none" />
47+
) : (
48+
<div className={styles['horizontal-header']}>{header}</div>
49+
)}
50+
{details && <div className={styles.details}>{details}</div>}
51+
</li>
52+
);
53+
};
54+
55+
const InternalStep = ({
56+
status,
57+
statusIconAriaLabel,
58+
header,
59+
details,
60+
orientation,
61+
}: StepsProps.Step & { orientation: StepsProps.Orientation }) => {
1662
return (
1763
<li className={styles.container}>
1864
<div className={styles.header}>
1965
<StatusIndicator type={status} iconAriaLabel={statusIconAriaLabel}>
20-
{header}
66+
{orientation === 'vertical' && header}
2167
</StatusIndicator>
68+
{orientation === 'horizontal' && <hr className={styles.connector} role="none" />}
2269
</div>
23-
<hr className={styles.connector} role="none" />
70+
{orientation === 'vertical' ? (
71+
<hr className={styles.connector} role="none" />
72+
) : (
73+
<div className={styles['horizontal-header']}>
74+
<InternalBox color={statusToColor[status]}>{header}</InternalBox>
75+
</div>
76+
)}
2477
{details && <div className={styles.details}>{details}</div>}
2578
</li>
2679
);
2780
};
2881

2982
const InternalSteps = ({
3083
steps,
84+
orientation,
85+
renderStep,
3186
ariaLabel,
3287
ariaLabelledby,
3388
ariaDescribedby,
3489
__internalRootRef,
3590
...props
36-
}: InternalStepsProps) => {
91+
}: SomeRequired<InternalStepsProps, 'orientation'>) => {
3792
return (
38-
<div {...props} className={clsx(styles.root, props.className)} ref={__internalRootRef}>
93+
<div
94+
{...props}
95+
className={clsx(styles.root, props.className, orientation === 'horizontal' ? styles.horizontal : styles.vertical)}
96+
ref={__internalRootRef}
97+
>
3998
<ol
4099
className={styles.list}
41100
aria-label={ariaLabel}
42101
aria-labelledby={ariaLabelledby}
43102
aria-describedby={ariaDescribedby}
44103
>
45-
{steps.map((step, index) => (
46-
<InternalStep
47-
key={index}
48-
status={step.status}
49-
statusIconAriaLabel={step.statusIconAriaLabel}
50-
header={step.header}
51-
details={step.details}
52-
/>
53-
))}
104+
{steps.map((step, index) =>
105+
renderStep ? (
106+
<CustomStep key={index} orientation={orientation} step={step} renderStep={renderStep} />
107+
) : (
108+
<InternalStep
109+
key={index}
110+
status={step.status}
111+
statusIconAriaLabel={step.statusIconAriaLabel}
112+
header={step.header}
113+
details={step.details}
114+
orientation={orientation}
115+
/>
116+
)
117+
)}
54118
</ol>
55119
</div>
56120
);

0 commit comments

Comments
 (0)