Skip to content

Commit da2f215

Browse files
authored
feat: Horizontal variant of steps (#3959)
1 parent a5c0ddf commit da2f215

File tree

7 files changed

+356
-24
lines changed

7 files changed

+356
-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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23099,6 +23099,48 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
2309923099
"optional": true,
2310023100
"type": "string",
2310123101
},
23102+
{
23103+
"description": "The visual orientation of the steps (vertical or horizontal).
23104+
By default the orientation is vertical.",
23105+
"inlineType": {
23106+
"name": "StepsProps.Orientation",
23107+
"type": "union",
23108+
"values": [
23109+
"horizontal",
23110+
"vertical",
23111+
],
23112+
},
23113+
"name": "orientation",
23114+
"optional": true,
23115+
"systemTags": [
23116+
"core",
23117+
],
23118+
"type": "string",
23119+
},
23120+
{
23121+
"description": "Render a step. This overrides the default icon, header, and details provided by the component.
23122+
The function is called for each step and should return an object with the following keys:
23123+
* \`header\` (React.ReactNode) - Summary corresponding to the step.
23124+
* \`details\` (React.ReactNode) - (Optional) Additional information corresponding to the step.
23125+
* \`icon\` (React.ReactNode) - (Optional) Replaces the standard step icon from the status indicator.",
23126+
"inlineType": {
23127+
"name": "(step: StepsProps.Step) => { header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; }",
23128+
"parameters": [
23129+
{
23130+
"name": "step",
23131+
"type": "StepsProps.Step",
23132+
},
23133+
],
23134+
"returnType": "{ header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; }",
23135+
"type": "function",
23136+
},
23137+
"name": "renderStep",
23138+
"optional": true,
23139+
"systemTags": [
23140+
"core",
23141+
],
23142+
"type": "((step: StepsProps.Step) => { header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; })",
23143+
},
2310223144
{
2310323145
"description": "An array of individual steps
2310423146

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: 90 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,82 @@ 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('renders custom icon when using renderStep', () => {
211+
const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep });
212+
213+
const customHeaders = wrapper.findAll('[data-testid="custom-icon"]');
214+
expect(customHeaders).toHaveLength(stepsForCustomRender.length);
215+
});
216+
217+
test('renders custom content in horizontal mode', () => {
218+
const wrapper = renderSteps({
219+
steps: stepsForCustomRender,
220+
orientation: 'horizontal',
221+
renderStep: customRenderStep,
222+
});
223+
224+
expect(wrapper.findAll('[data-testid="custom-header"]')).not.toHaveLength(0);
225+
expect(wrapper.findAll('[data-testid="custom-details"]')).not.toHaveLength(0);
226+
expect(wrapper.findAll('[data-testid="custom-icon"]')).not.toHaveLength(0);
227+
});
228+
229+
test('does not render status indicators when using renderStep with icon', () => {
230+
const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep });
231+
232+
expect(wrapper.findItems()[0].findHeader()?.findStatusIndicator()).toBeNull();
233+
});
234+
});
145235
});

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)