diff --git a/pages/steps/permutations-utils.tsx b/pages/steps/permutations-utils.tsx index be4bd33247..10bf0235cc 100644 --- a/pages/steps/permutations-utils.tsx +++ b/pages/steps/permutations-utils.tsx @@ -4,6 +4,7 @@ import React from 'react'; import Box from '~components/box'; import Button from '~components/button'; +import Icon from '~components/icon'; import Link from '~components/link'; import Popover from '~components/popover'; import { StepsProps } from '~components/steps'; @@ -738,6 +739,7 @@ const changesetStepsInteractive: ReadonlyArray = [ export const stepsPermutations = createPermutations([ { + orientation: ['vertical', 'horizontal'], steps: [ initialSteps, loadingSteps, @@ -761,4 +763,20 @@ export const stepsPermutations = createPermutations([ ], ariaLabel: ['test label'], }, + { + steps: [allStatusesSteps, successfulSteps], + ariaLabel: ['test label'], + orientation: ['vertical', 'horizontal'], + renderStep: [ + step => ({ + header: Custom header for {step.header}, + details: step.details && Custom details for {step.details}, + }), + step => ({ + header: step.header, + details: step.details && Custom details for {step.details}, + icon: , + }), + ], + }, ]); diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 086815499d..0f61e78bb7 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -23076,6 +23076,48 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "description": "The visual orientation of the steps (vertical or horizontal). +By default the orientation is vertical.", + "inlineType": { + "name": "StepsProps.Orientation", + "type": "union", + "values": [ + "horizontal", + "vertical", + ], + }, + "name": "orientation", + "optional": true, + "systemTags": [ + "core", + ], + "type": "string", + }, + { + "description": "Render a step. This overrides the default icon, header, and details provided by the component. +The function is called for each step and should return an object with the following keys: +* \`header\` (React.ReactNode) - Summary corresponding to the step. +* \`details\` (React.ReactNode) - (Optional) Additional information corresponding to the step. +* \`icon\` (React.ReactNode) - (Optional) Replaces the standard step icon from the status indicator.", + "inlineType": { + "name": "(step: StepsProps.Step) => { header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; }", + "parameters": [ + { + "name": "step", + "type": "StepsProps.Step", + }, + ], + "returnType": "{ header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; }", + "type": "function", + }, + "name": "renderStep", + "optional": true, + "systemTags": [ + "core", + ], + "type": "((step: StepsProps.Step) => { header: React.ReactNode; details?: React.ReactNode; icon?: React.ReactNode; })", + }, { "description": "An array of individual steps diff --git a/src/status-indicator/internal.tsx b/src/status-indicator/internal.tsx index ec6453d8c8..9882b52ee6 100644 --- a/src/status-indicator/internal.tsx +++ b/src/status-indicator/internal.tsx @@ -47,6 +47,31 @@ const typeToIcon: (size: IconProps.Size) => Record, }); +interface InternalStatusIconProps extends Pick { + animate?: InternalStatusIndicatorProps['__animate']; + size?: InternalStatusIndicatorProps['__size']; + display?: InternalStatusIndicatorProps['__display']; +} + +export function InternalStatusIcon({ + type, + iconAriaLabel, + animate, + display, + size = 'normal', +}: InternalStatusIconProps) { + return ( + + {typeToIcon(size)[type]} + {display === 'inline' && <> } + + ); +} + export default function StatusIndicator({ type, children, @@ -85,14 +110,13 @@ export default function StatusIndicator({ __animate && styles['container-fade-in'] )} > - - {typeToIcon(__size)[type]} - {__display === 'inline' && <> } - + {children} diff --git a/src/steps/__tests__/steps.test.tsx b/src/steps/__tests__/steps.test.tsx index 8cafeff0c2..112f8ff067 100644 --- a/src/steps/__tests__/steps.test.tsx +++ b/src/steps/__tests__/steps.test.tsx @@ -68,6 +68,18 @@ const stepsWithIconAriaLabel: ReadonlyArray = [ }, ]; +const stepsForCustomRender: ReadonlyArray = [ + { + header: 'Checked Cross Region Consent', + status: 'success', + }, + { + header: 'Analyzing security rules', + status: 'loading', + details: 'Step details', + }, +]; + const renderSteps = (props: Partial) => { const renderResult = render(); return createWrapper(renderResult.container).findSteps()!; @@ -142,4 +154,82 @@ describe('Steps', () => { }); }); }); + + describe('orientation', () => { + test('renders with default vertical orientation', () => { + const wrapper = renderSteps({ steps: successfullSteps }); + + expect(wrapper.getElement()).not.toHaveClass(stepsStyles.horizontal); + }); + + test('renders with horizontal orientation when explicitly set', () => { + const wrapper = renderSteps({ + steps: successfullSteps, + orientation: 'horizontal', + }); + + expect(wrapper.getElement()).toHaveClass(stepsStyles.horizontal); + }); + + test('renders with vertical orientation', () => { + const wrapper = renderSteps({ + steps: successfullSteps, + orientation: 'vertical', + }); + + expect(wrapper.getElement()).not.toHaveClass(stepsStyles.horizontal); + }); + }); + + describe('renderStep', () => { + const customRenderStep = (step: StepsProps.Step) => ({ + header: Custom: {step.header}, + details: step.details ?
Details: {step.details}
: undefined, + icon: icon, + }); + + test('renders custom content when using renderStep', () => { + const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep }); + + const customHeaders = wrapper.findAll('[data-testid="custom-header"]'); + expect(customHeaders).toHaveLength(stepsForCustomRender.length); + + expect(customHeaders[0].getElement()).toHaveTextContent('Checked Cross Region Consent'); + expect(customHeaders[1].getElement()).toHaveTextContent('Analyzing security rules'); + }); + + test('renders custom details when using renderStep', () => { + const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep }); + + const customDetails = wrapper.findAll('[data-testid="custom-details"]'); + // Only last step has details + expect(customDetails).toHaveLength(1); + expect(customDetails[0].getElement()).toHaveTextContent('Step details'); + }); + + test('renders custom icon when using renderStep', () => { + const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep }); + + const customHeaders = wrapper.findAll('[data-testid="custom-icon"]'); + expect(customHeaders).toHaveLength(stepsForCustomRender.length); + }); + + test('renders custom content in horizontal mode', () => { + const wrapper = renderSteps({ + steps: stepsForCustomRender, + orientation: 'horizontal', + renderStep: customRenderStep, + }); + + expect(wrapper.findAll('[data-testid="custom-header"]')).not.toHaveLength(0); + expect(wrapper.findAll('[data-testid="custom-details"]')).not.toHaveLength(0); + expect(wrapper.findAll('[data-testid="custom-icon"]')).not.toHaveLength(0); + }); + + test('does not render status indicators when using renderStep with icon', () => { + const wrapper = renderSteps({ steps: stepsForCustomRender, renderStep: customRenderStep }); + + expect(wrapper.findItems()[0].findHeader()?.findStatusIndicator()).toBeNull(); + }); + }); }); diff --git a/src/steps/interfaces.ts b/src/steps/interfaces.ts index d329e6163e..08d2fb4508 100644 --- a/src/steps/interfaces.ts +++ b/src/steps/interfaces.ts @@ -14,6 +14,27 @@ export interface StepsProps extends BaseComponentProps { * * `details` (ReactNode) - (Optional) Additional information corresponding to the step. */ steps: ReadonlyArray; + /** + * The visual orientation of the steps (vertical or horizontal). + * By default the orientation is vertical. + * + * @awsuiSystem core + */ + orientation?: StepsProps.Orientation; + /** + * Render a step. This overrides the default icon, header, and details provided by the component. + * The function is called for each step and should return an object with the following keys: + * * `header` (React.ReactNode) - Summary corresponding to the step. + * * `details` (React.ReactNode) - (Optional) Additional information corresponding to the step. + * * `icon` (React.ReactNode) - (Optional) Replaces the standard step icon from the status indicator. + * + * @awsuiSystem core + */ + renderStep?: (step: StepsProps.Step) => { + header: React.ReactNode; + details?: React.ReactNode; + icon?: React.ReactNode; + }; /** * Provides an `aria-label` to the progress steps container. * Don't use `ariaLabel` and `ariaLabelledby` at the same time. @@ -40,4 +61,6 @@ export namespace StepsProps { header: React.ReactNode; details?: React.ReactNode; } + + export type Orientation = 'vertical' | 'horizontal'; } diff --git a/src/steps/internal.tsx b/src/steps/internal.tsx index 7dd9af8626..bd95a7bdce 100644 --- a/src/steps/internal.tsx +++ b/src/steps/internal.tsx @@ -3,24 +3,85 @@ import React from 'react'; import clsx from 'clsx'; +import { BoxProps } from '../box/interfaces'; +import InternalBox from '../box/internal'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { SomeRequired } from '../internal/types'; -import StatusIndicator from '../status-indicator/internal'; +import InternalStatusIndicator, { InternalStatusIcon } from '../status-indicator/internal'; import { StepsProps } from './interfaces'; import styles from './styles.css.js'; type InternalStepsProps = SomeRequired & InternalBaseComponentProps; -const InternalStep = ({ status, statusIconAriaLabel, header, details }: StepsProps.Step) => { +const statusToColor: Record = { + error: 'text-status-error', + warning: 'text-status-warning', + success: 'text-status-success', + info: 'text-status-info', + stopped: 'text-status-inactive', + pending: 'text-status-inactive', + 'in-progress': 'text-status-inactive', + loading: 'text-status-inactive', +}; + +const CustomStep = ({ + step, + orientation, + renderStep, +}: { + step: StepsProps.Step; + orientation: StepsProps.Orientation; + renderStep: Required['renderStep']; +}) => { + const { status, statusIconAriaLabel } = step; + const { header, details, icon } = renderStep(step); + return ( +
  • +
    + {icon ? icon : } + {orientation === 'vertical' ? header :
    } +
    + {orientation === 'vertical' ? ( +
    + ) : ( +
    {header}
    + )} + {details &&
    {details}
    } +
  • + ); +}; + +const InternalStep = ({ + status, + statusIconAriaLabel, + header, + details, + orientation, +}: StepsProps.Step & { orientation: StepsProps.Orientation }) => { return (
  • - - {header} - + {orientation === 'vertical' ? ( + + {header} + + ) : ( + <> + + + +
    + + )}
    -
    + {orientation === 'vertical' ? ( +
    + ) : ( +
    + {header} +
    + )} {details &&
    {details}
    }
  • ); @@ -28,6 +89,8 @@ const InternalStep = ({ status, statusIconAriaLabel, header, details }: StepsPro const InternalSteps = ({ steps, + orientation = 'vertical', + renderStep, ariaLabel, ariaLabelledby, ariaDescribedby, @@ -35,22 +98,31 @@ const InternalSteps = ({ ...props }: InternalStepsProps) => { return ( -
    +
      - {steps.map((step, index) => ( - - ))} + {steps.map((step, index) => + renderStep ? ( + + ) : ( + + ) + )}
    ); diff --git a/src/steps/styles.scss b/src/steps/styles.scss index 9ddf1033f5..941907a1ec 100644 --- a/src/steps/styles.scss +++ b/src/steps/styles.scss @@ -8,6 +8,7 @@ .root { @include styles.styles-reset; + @include styles.text-wrapping; > .list { list-style: none; @@ -20,6 +21,8 @@ grid-template-rows: minmax(awsui.$space-static-l, auto); > .header { + display: flex; + gap: awsui.$space-xxs; grid-row: 1; grid-column: 1 / span 2; } @@ -49,5 +52,65 @@ > :last-of-type > .connector { display: none; } + + &.custom > .details { + // Remove built-in margins for custom rendering for maximum flexibility + margin-block-end: 0; + } + } +} + +.horizontal { + > .list { + display: grid; + align-items: flex-start; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + grid-auto-flow: column; + + > .container { + display: grid; + grid-template-columns: awsui.$space-static-l 1fr; + grid-template-rows: minmax(awsui.$space-static-l, auto); + align-items: center; + + > .header { + display: flex; + grid-row: 1; + grid-column: 1 / span 2; + align-items: center; + + > .connector { + display: block; + flex: 1; + background-color: awsui.$color-border-divider-default; + margin-block: 0; + border-block: 0; + border-inline: 0; + min-block-size: 0; + inset-inline-end: 0; + + block-size: awsui.$border-divider-list-width; + inline-size: auto; + min-inline-size: awsui.$space-static-xs; + margin-inline-end: awsui.$space-static-xxs; + } + } + + > .horizontal-header { + grid-row: 2; + grid-column: 1 / span 3; + } + + > .details { + grid-row: 3; + grid-column: 1 / span 3; + } + } + + > .container:last-child { + > .header > .connector { + display: none; + } + } } }