Skip to content

Commit 7cd702c

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

File tree

7 files changed

+338
-24
lines changed

7 files changed

+338
-24
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: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,31 @@ const typeToIcon: (size: IconProps.Size) => Record<StatusIndicatorProps.Type, JS
4747
loading: <InternalSpinner />,
4848
});
4949

50+
interface InternalStatusIconProps extends Pick<InternalStatusIndicatorProps, 'type' | 'iconAriaLabel'> {
51+
animate?: InternalStatusIndicatorProps['__animate'];
52+
size: InternalStatusIndicatorProps['__size'];
53+
display?: InternalStatusIndicatorProps['__display'];
54+
}
55+
56+
export function InternalStatusIcon({
57+
type,
58+
iconAriaLabel,
59+
animate,
60+
display,
61+
size = 'normal',
62+
}: InternalStatusIconProps) {
63+
return (
64+
<span
65+
className={clsx(styles.icon, animate && styles['icon-shake'])}
66+
aria-label={iconAriaLabel}
67+
role={iconAriaLabel ? 'img' : undefined}
68+
>
69+
{typeToIcon(size)[type]}
70+
{display === 'inline' && <>&nbsp;</>}
71+
</span>
72+
);
73+
}
74+
5075
export default function StatusIndicator({
5176
type,
5277
children,
@@ -85,14 +110,13 @@ export default function StatusIndicator({
85110
__animate && styles['container-fade-in']
86111
)}
87112
>
88-
<span
89-
className={clsx(styles.icon, __animate && styles['icon-shake'])}
90-
aria-label={iconAriaLabel}
91-
role={iconAriaLabel ? 'img' : undefined}
92-
>
93-
{typeToIcon(__size)[type]}
94-
{__display === 'inline' && <>&nbsp;</>}
95-
</span>
113+
<InternalStatusIcon
114+
type={type}
115+
iconAriaLabel={iconAriaLabel}
116+
animate={__animate}
117+
display={__display}
118+
size={__size}
119+
/>
96120
{children}
97121
</span>
98122
</WithNativeAttributes>

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/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
}

0 commit comments

Comments
 (0)