diff --git a/pages/steps/permutations-annotation.page.tsx b/pages/steps/permutations-annotation.page.tsx new file mode 100644 index 0000000000..5f5960a79f --- /dev/null +++ b/pages/steps/permutations-annotation.page.tsx @@ -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 = [ + { + annotation: , + status: 'log', + header: 'Provided preferences', + }, + { + annotation: , + status: 'success', + statusIconAriaLabel: 'Success', + header: 'Created environment', + details: 'Environment created successfully.', + }, + { + annotation: , + status: 'error', + statusIconAriaLabel: 'Error', + header: 'Validation failed', + details: 'One or more resources could not be validated.', + }, +]; + +export const varyingLengthAnnotationsSteps: ReadonlyArray = [ + { + annotation: , + status: 'log', + header: 'Shorter timestamp', + details: 'This is used to test the shorter timestamp and and we have detail here', + }, + { + annotation: ( + + ), + 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([ + { + steps: [varyingLengthAnnotationsSteps, stepsWithAnnotation], + ariaLabel: ['test label'], + orientation: ['vertical', 'horizontal'], + renderStep: [ + undefined, + step => ({ + annotation: step.annotation, + header: step.header, + details: step.details && Custom details for {step.details}, + icon: , + }), + step => ({ + annotation: step.annotation, + header: This step header ({step.header}) is wrapped in a custom HTML tag and has very long content, + details: step.details && Custom details for {step.details}, + }), + ], + }, + { + connectorLines: ['none'], + orientation: ['vertical', 'horizontal'], + steps: [varyingLengthAnnotationsSteps], + ariaLabel: ['test label'], + }, +]); + +export default function StepsPermutations() { + return ( + +
{}
} + /> +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a46d8dafb5..a817adf4f7 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -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", @@ -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", @@ -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", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index b0220aa79a..c1d4fc8b09 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -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", diff --git a/src/steps/__tests__/steps.test.tsx b/src/steps/__tests__/steps.test.tsx index baf889db18..0e85615c21 100644 --- a/src/steps/__tests__/steps.test.tsx +++ b/src/steps/__tests__/steps.test.tsx @@ -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 }); @@ -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: Custom: {step.header} }), + }); + + 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: Custom: {step.header} }), + }); + + expect(wrapper.findItems()[0].findAnnotation()!.getElement()).toHaveTextContent('10:30'); + }); + }); + describe('Accessibility', () => { test('applies ARIA label to steps', () => { const testAriaLabel = 'Test aria label'; @@ -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 }); diff --git a/src/steps/interfaces.ts b/src/steps/interfaces.ts index 75f234e24e..41b17edf09 100644 --- a/src/steps/interfaces.ts +++ b/src/steps/interfaces.ts @@ -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; /** @@ -62,6 +63,7 @@ export namespace StepsProps { statusIconAriaLabel?: string; header: React.ReactNode; details?: React.ReactNode; + annotation?: React.ReactNode; } export type Orientation = 'vertical' | 'horizontal'; diff --git a/src/steps/internal.tsx b/src/steps/internal.tsx index 9f86ce2e28..42aa10ec19 100644 --- a/src/steps/internal.tsx +++ b/src/steps/internal.tsx @@ -28,6 +28,20 @@ const statusToColor: Record = { log: 'text-status-inactive', }; +const StepAnnotation = ({ children }: { children: StepsProps.Step['annotation'] }) => { + if (children === undefined || children === null) { + return null; + } + return
{children}
; +}; + +const StepDetails = ({ children }: { children: StepsProps.Step['details'] }) => { + if (children === undefined || children === null) { + return null; + } + return
{children}
; +}; + const CustomStep = ({ step, orientation, @@ -39,7 +53,7 @@ const CustomStep = ({ renderStep: Required['renderStep']; hideConnectors: boolean; }) => { - const { status, statusIconAriaLabel } = step; + const { status, statusIconAriaLabel, annotation } = step; const { header, details, icon } = renderStep(step); const iconNode = icon ? icon : ; const connectorClassName = clsx(styles.connector, hideConnectors && styles['connector-hidden']); @@ -47,12 +61,15 @@ const CustomStep = ({ if (orientation === 'horizontal') { return (
  • -
    + {annotation} +
    {iconNode}
    -
    {header}
    - {details &&
    {details}
    } +
    +
    {header}
    + {details} +
  • ); } @@ -60,16 +77,18 @@ const CustomStep = ({ // 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 (
  • + {annotation}
    {iconNode}
    {header}
    - {details &&
    {details}
    } + {details}
  • ); @@ -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 (
  • -
    + {annotation} +
    {orientation === 'vertical' ? ( - - {header} - + <> +
    + + {header} + +
    + {details} +
    + ) : ( <> @@ -100,14 +127,14 @@ const InternalStep = ({ )}
    - {orientation === 'vertical' ? ( -
    - ) : ( -
    - {header} + {orientation === 'horizontal' && ( +
    +
    + {header} +
    + {details}
    )} - {details &&
    {details}
    }
  • ); }; @@ -124,6 +151,7 @@ const InternalSteps = ({ ...props }: InternalStepsProps) => { const hideConnectors = connectorLines === 'none'; + const hasAnnotations = steps.some(step => step.annotation !== undefined && step.annotation !== null); return (
      diff --git a/src/steps/styles.scss b/src/steps/styles.scss index e4d504ff4d..4f1628944f 100644 --- a/src/steps/styles.scss +++ b/src/steps/styles.scss @@ -13,6 +13,10 @@ background-color: transparent; } +.header { + /* used in test-utils */ +} + .root { @include styles.styles-reset; @include styles.text-wrapping; @@ -27,7 +31,13 @@ grid-template-columns: awsui.$space-static-l 1fr; grid-template-rows: minmax(awsui.$space-static-l, auto); - > .header { + > .annotation { + justify-self: flex-end; + min-inline-size: 0; + color: awsui.$color-text-status-inactive; + } + + > .step-layout { display: flex; gap: awsui.$space-xxs; grid-row: 1; @@ -40,23 +50,6 @@ grid-column: 2; margin-block-end: awsui.$space-static-xs; } - - > .connector { - grid-row: 2; - grid-column: 1; - margin-block: 0; - border-block: 0; - border-inline: 0; - inline-size: awsui.$border-divider-list-width; - block-size: auto; - min-block-size: awsui.$space-static-xs; - position: relative; - inset-inline-end: awsui.$space-static-xxxs; - } - } - - > :last-of-type > .connector { - display: none; } &.custom > .details { @@ -71,18 +64,26 @@ display: grid; align-items: flex-start; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + // Shared rows: annotation | icon and connector | header and details. + grid-template-rows: auto minmax(awsui.$space-static-l, auto) auto; grid-auto-flow: column; > .container { - display: grid; - grid-template-columns: awsui.$space-static-l 1fr; - grid-template-rows: minmax(awsui.$space-static-l, auto); + // Spanning the subgrid rows keeps steps with different-height annotations aligned. + grid-row: 1 / -1; + grid-template-rows: subgrid; align-items: center; - > .header { - display: flex; + > .annotation { + justify-self: flex-start; + align-self: flex-end; grid-row: 1; grid-column: 1 / span 2; + padding-inline-end: awsui.$space-xs; + } + + > .step-layout { + grid-row: 2; align-items: center; > .connector { @@ -101,27 +102,68 @@ } } - > .horizontal-header { - grid-row: 2; - grid-column: 1 / span 3; - padding-inline-end: awsui.$space-xs; - } - - > .details { + > .content { + align-self: flex-start; grid-row: 3; grid-column: 1 / span 3; + min-inline-size: 0; padding-inline-end: awsui.$space-xs; + + > .details { + margin-block-end: awsui.$space-static-xs; + } } } > .container:last-child { - > .header > .connector { + > .step-layout > .connector { display: none; } } } } +.vertical { + > .list { + // A single connector fills extra annotation/details height and provides the default spacing otherwise. + > .container > .step-layout { + display: grid; + grid-template-columns: subgrid; + grid-template-rows: auto 1fr; + gap: 0; + + > :first-child { + grid-column: 1 / -1; + } + + > .details { + grid-row: 2; + grid-column: 2; + margin-block-end: awsui.$space-static-xs; + } + + > .connector-continuation { + grid-row: 2; + grid-column: 1; + justify-self: center; + margin-block: 0; + margin-inline: 0; + border-block: 0; + border-inline: 0; + inline-size: awsui.$border-divider-list-width; + block-size: auto; + min-block-size: awsui.$space-static-xs; + position: relative; + inset-inline-end: awsui.$space-static-xxxs; + } + } + + > :last-of-type > .step-layout > .connector-continuation { + display: none; + } + } +} + // Vertical custom steps: a column-1 "rail" stacks the icon above a connector that grows to fill // the full height of the step (header + details), keeping the vertical line continuous and // connecting consecutive steps regardless of header height. Placed after `.horizontal` so @@ -156,6 +198,46 @@ } } +// Shared columns via subgrid: widest annotation | icon and connector | remaining content. +.root > .list.with-annotation { + display: grid; + grid-template-columns: fit-content(50%) awsui.$space-static-l 1fr; + + > .container { + grid-column: 1 / -1; + grid-template-columns: subgrid; + + > .annotation { + grid-row: 1; + grid-column: 1; + padding-inline-end: awsui.$space-xxs; + } + } + + // The step layout spans the icon and content columns. + > .container:not(.custom-vertical) { + > .step-layout { + grid-column: 2 / span 2; + } + + > .details { + grid-column: 3; + } + } + + // Custom DOM: rail in the icon column, header and details in the content column. + > .container.custom-vertical { + > .rail { + grid-column: 2; + } + + > .content { + grid-column: 3; + } + } +} + +// Hide the connector after the final custom/rail step. Placed last so its higher specificity .root > .list > .container.custom-vertical:last-of-type > .rail > .connector { display: none; } diff --git a/src/test-utils/dom/steps/index.ts b/src/test-utils/dom/steps/index.ts index e8e607a131..ee14f316b2 100644 --- a/src/test-utils/dom/steps/index.ts +++ b/src/test-utils/dom/steps/index.ts @@ -18,6 +18,13 @@ class StepWrapper extends ComponentWrapper { findDetails(): ElementWrapper | null { return this.findByClassName(styles.details); } + + /** + * Finds the annotation of a step + */ + findAnnotation(): ElementWrapper | null { + return this.findByClassName(styles.annotation); + } } export default class StepsWrapper extends ComponentWrapper { static rootSelector: string = styles.root;