diff --git a/pages/steps/playground.page.tsx b/pages/steps/playground.page.tsx new file mode 100644 index 0000000000..a8c9ffe858 --- /dev/null +++ b/pages/steps/playground.page.tsx @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Checkbox from '~components/checkbox'; +import ColumnLayout from '~components/column-layout'; +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; +import Steps, { StepsProps } from '~components/steps'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const orientationOptions: ReadonlyArray = [{ value: 'vertical' }, { value: 'horizontal' }]; + +// The three step shapes worth exercising: a status step with details, a status step without +// details, and a neutral (no status) event. +const baseEntries: ReadonlyArray<{ status?: StepsProps.Status; header: string }> = [ + { status: 'success', header: 'Created environment' }, + { status: 'error', header: 'Validation failed' }, + { status: undefined, header: 'Plain event (no status)' }, +]; + +export default function StepsPlayground() { + const [orientation, setOrientation] = useState(orientationOptions[0]); + const [ariaLabel, setAriaLabel] = useState('Steps playground'); + + const [withTimestamps, setWithTimestamps] = useState(true); + const [timeAsElement, setTimeAsElement] = useState(true); + const [withDetails, setWithDetails] = useState(true); + const [useCustomRender, setUseCustomRender] = useState(false); + + const times = ['09:00 AM', '10:30 AM', '2 hr ago']; + + const getHeaderStart = (timeText: string): React.ReactNode => { + if (!withTimestamps) { + return undefined; + } + if (timeAsElement) { + return ( + + ); + } + return timeText; + }; + + const steps: ReadonlyArray = baseEntries.map((entry, index) => ({ + status: entry.status, + statusIconAriaLabel: entry.status ? entry.status : undefined, + header: entry.header, + details: withDetails ? ( + + Additional information for “{entry.header}”. + + ) : undefined, + headerStart: getHeaderStart(times[index % times.length]), + })); + + const renderStep: StepsProps['renderStep'] = useCustomRender + ? step => ({ + header: Custom: {step.header}, + details: step.details ? {step.details} : undefined, + }) + : undefined; + + return ( + + + +
Steps playground
+ + Properties}> + + + + setAriaLabel(detail.value)} /> + + + + + setWithTimestamps(detail.checked)}> + Show headerStart (timestamps) + + setTimeAsElement(detail.checked)} + > + Timestamp as <time> element + + setWithDetails(detail.checked)}> + Show details + + setUseCustomRender(detail.checked)}> + Use renderStep + + + + + + Preview}> + + +
+
+
+ ); +} diff --git a/pages/steps/timeline.page.tsx b/pages/steps/timeline.page.tsx new file mode 100644 index 0000000000..9fc9cc6973 --- /dev/null +++ b/pages/steps/timeline.page.tsx @@ -0,0 +1,120 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Link from '~components/link'; +import SpaceBetween from '~components/space-between'; +import Steps, { StepsProps } from '~components/steps'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const timelineWithLinks: ReadonlyArray = [ + { + headerStart: , + header: Provided preferences, + }, + { + headerStart: , + header: Created environment: CloudAppConfig, + }, + { + headerStart: , + header: 'Waiting for approval', + }, +]; + +const timelineWithStatuses: ReadonlyArray = [ + { + headerStart: , + status: 'success', + statusIconAriaLabel: 'Success', + header: 'Created environment', + }, + { + headerStart: , + status: 'loading', + statusIconAriaLabel: 'Loading', + header: 'Checking EKS clusters', + }, + { + headerStart: , + status: 'error', + statusIconAriaLabel: 'Error', + header: 'Validation failed', + details: 'One or more resources could not be validated.', + }, +]; + +const mixedTimeline: ReadonlyArray = [ + { + headerStart: , + status: 'success', + statusIconAriaLabel: 'Success', + header: 'Created environment', + }, + { + // No headerStart: the leading column is still reserved so the dots stay aligned. + header: Provided preferences, + }, + { + headerStart: , + header: 'Waiting for approval', + }, +]; + +// Values of very different lengths, to verify the shared leading column keeps the dots/rail +// aligned. The column is sized via `max-content` to the widest value, so the long value widens the +// column for every row (it does not wrap or truncate). The long value is placed in the middle to +// confirm the column width is shared across all rows, not per-row. +const varyingWidthTimeline: ReadonlyArray = [ + { + headerStart: , + status: 'success', + statusIconAriaLabel: 'Success', + header: 'Short timestamp', + }, + { + headerStart: ( + + ), + status: 'error', + statusIconAriaLabel: 'Error', + header: 'Long absolute timestamp that widens the shared leading column', + details: 'The leading column grows to fit this value on one line, and every row shares that width.', + }, + { + headerStart: '2 hr ago', + header: 'Relative, short', + }, +]; + +export default function StepsTimelinePage() { + return ( + +

Steps — timeline

+ +
+

Timeline (timestamps + neutral dots, anchor-link labels)

+ +
+ +
+

Timeline combined with status icons

+ +
+ +
+

Mixed steps (some without a timestamp)

+ +
+ +
+

Varying widths (short vs. long timestamps)

+ +
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 39162079e1..ee0b0ccd5a 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -27528,10 +27528,11 @@ The function is called for each step and should return an object with the follow "description": "An array of individual steps Each step definition has the following properties: - * \`status\` (string) - Status of the step corresponding to a status indicator. - * \`statusIconAriaLabel\` - (string) - (Optional) Alternative text for the status icon. + * \`status\` (string) - (Optional) Status of the step corresponding to a status indicator. If not set, the step renders a neutral dot instead of a status icon. + * \`statusIconAriaLabel\` - (string) - (Optional) Alternative text for the status icon or neutral dot. * \`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. + * \`headerStart\` (ReactNode) - (Optional) Content rendered in a leading column at the start of the step, before the icon. Typically a timestamp in a timeline view. Applies to the default \`vertical\` orientation and is ignored when \`orientation="horizontal"\`.", "name": "steps", "optional": false, "type": "ReadonlyArray", @@ -44344,6 +44345,21 @@ Returns the current value of the input.", ], }, }, + { + "description": "Finds the leading content (e.g. a timestamp) rendered at the start of a step. +Returns null when the step has no \`headerStart\`.", + "name": "findHeaderStart", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, ], "name": "StepWrapper", }, @@ -53511,6 +53527,16 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from "name": "ElementWrapper", }, }, + { + "description": "Finds the leading content (e.g. a timestamp) rendered at the start of a step. +Returns null when the step has no \`headerStart\`.", + "name": "findHeaderStart", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, ], "name": "StepWrapper", }, 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 4b1af42bc5..247fee71a5 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 @@ -641,6 +641,7 @@ exports[`test-utils selectors 1`] = ` "steps": [ "awsui_container_gxp9y", "awsui_details_gxp9y", + "awsui_header-start_gxp9y", "awsui_header_gxp9y", "awsui_root_gxp9y", ], diff --git a/src/status-indicator/internal.tsx b/src/status-indicator/internal.tsx index 5cad3017ab..c7addb7228 100644 --- a/src/status-indicator/internal.tsx +++ b/src/status-indicator/internal.tsx @@ -19,26 +19,31 @@ import { StatusIndicatorProps } from './interfaces'; import styles from './styles.css.js'; -export interface InternalStatusIndicatorProps - extends SomeRequired, - InternalBaseComponentProps { - /** - * Play an animation on the error icon when first rendered - */ - __animate?: boolean; +export type InternalStatusIndicatorProps = Omit, 'type'> & + InternalBaseComponentProps & { + /** + * Status type. The internal `'neutral'` value renders a plain dot (used by Steps for events that + * have no status); it is intentionally not exposed on the public `StatusIndicatorProps.Type`. + */ + type: StatusIndicatorProps.Type | 'neutral'; - /** - * Size of icon. - */ - __size?: IconProps.Size; + /** + * Play an animation on the error icon when first rendered + */ + __animate?: boolean; - /** - * The CSS behavior of the status indicator container element. - */ - __display?: 'inline' | 'inline-block'; -} + /** + * Size of icon. + */ + __size?: IconProps.Size; + + /** + * The CSS behavior of the status indicator container element. + */ + __display?: 'inline' | 'inline-block'; + }; -const typeToIcon: (size: IconProps.Size) => Record = size => ({ +const typeToIcon: (size: IconProps.Size) => Record = size => ({ error: , warning: , success: , @@ -48,6 +53,7 @@ const typeToIcon: (size: IconProps.Size) => Record, loading: , 'not-started': , + neutral: , }); interface InternalStatusIconProps extends Pick { diff --git a/src/status-indicator/styles.scss b/src/status-indicator/styles.scss index e224bb9c1a..6bee4c13b2 100644 --- a/src/status-indicator/styles.scss +++ b/src/status-indicator/styles.scss @@ -20,6 +20,7 @@ $_status-colors: ( 'in-progress': awsui.$color-text-status-inactive, 'loading': awsui.$color-text-status-inactive, 'not-started': awsui.$color-text-status-inactive, + 'neutral': awsui.$color-text-status-inactive, ); $_color-overrides: ( @@ -40,6 +41,7 @@ $_status-backgrounds: ( 'in-progress': awsui.$color-background-status-indicator-neutral, 'loading': awsui.$color-background-status-indicator-neutral, 'not-started': awsui.$color-background-status-indicator-neutral, + 'neutral': awsui.$color-background-status-indicator-neutral, ); $_background-overrides: ( diff --git a/src/steps/__tests__/steps.test.tsx b/src/steps/__tests__/steps.test.tsx index 112f8ff067..06ac40dddd 100644 --- a/src/steps/__tests__/steps.test.tsx +++ b/src/steps/__tests__/steps.test.tsx @@ -232,4 +232,105 @@ describe('Steps', () => { expect(wrapper.findItems()[0].findHeader()?.findStatusIndicator()).toBeNull(); }); }); + + describe('neutral status (optional status)', () => { + test('renders a neutral status indicator when status is omitted', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event' }] }); + const indicator = wrapper.findItems()[0].findHeader()!.findStatusIndicator(); + + expect(indicator).not.toBeNull(); + expect(indicator!.getElement()).toHaveClass(statusIconStyles['status-neutral']); + }); + + test('does not use the neutral status when status is set', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event', status: 'success' }] }); + const indicator = wrapper.findItems()[0].findHeader()!.findStatusIndicator()!.getElement(); + + expect(indicator).not.toHaveClass(statusIconStyles['status-neutral']); + expect(indicator).toHaveClass(statusIconStyles['status-success']); + }); + + test('neutral status icon has no accessible name by default', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event' }] }); + const icon = wrapper.findItems()[0].findHeader()!.findByClassName(statusIconStyles.icon)!.getElement(); + + expect(icon).not.toHaveAttribute('role', 'img'); + }); + + test('neutral status icon uses statusIconAriaLabel as accessible name when provided', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event', statusIconAriaLabel: 'Event marker' }] }); + const icon = wrapper.findItems()[0].findHeader()!.findByClassName(statusIconStyles.icon)!.getElement(); + + expect(icon).toHaveAccessibleName('Event marker'); + }); + + test('renders a status icon in horizontal orientation when status is omitted', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event' }], orientation: 'horizontal' }); + + expect(wrapper.findItems()[0].findHeader()!.findByClassName(statusIconStyles.icon)).not.toBeNull(); + }); + }); + + describe('headerStart', () => { + test('renders headerStart content', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event', headerStart: '10:30' }] }); + + expect(wrapper.findItems()[0].findHeaderStart()!.getElement()).toHaveTextContent('10:30'); + }); + + test('findHeaderStart returns null when headerStart is not provided', () => { + const wrapper = renderSteps({ steps: [{ header: 'Event', status: 'success' }] }); + + expect(wrapper.findItems()[0].findHeaderStart()).toBeNull(); + }); + + test('reserves the leading column for steps without headerStart when any step has one', () => { + const wrapper = renderSteps({ + steps: [{ header: 'First', headerStart: '10:30' }, { header: 'Second' }], + }); + + // Every step gets a (possibly empty) leading cell so the columns align. + expect(wrapper.findItems()[0].findHeaderStart()).not.toBeNull(); + expect(wrapper.findItems()[1].findHeaderStart()).not.toBeNull(); + }); + + test('is ignored in horizontal orientation', () => { + const wrapper = renderSteps({ + steps: [{ header: 'Event', headerStart: '10:30' }], + orientation: 'horizontal', + }); + + expect(wrapper.findItems()[0].findHeaderStart()).toBeNull(); + }); + + test('renders headerStart for custom-rendered steps', () => { + const wrapper = renderSteps({ + steps: [{ header: 'Event', headerStart: '10:30' }], + renderStep: (step: StepsProps.Step) => ({ header: Custom: {step.header} }), + }); + + expect(wrapper.findItems()[0].findHeaderStart()!.getElement()).toHaveTextContent('10:30'); + }); + + test('renders headerStart values of different lengths in the shared leading column', () => { + const shortValue = '9:00 AM'; + const longValue = 'December 31, 2024, 11:59:59 PM (UTC+14:00)'; + const wrapper = renderSteps({ + steps: [ + { header: 'Short timestamp', headerStart: shortValue }, + { header: 'Long timestamp', headerStart: longValue }, + ], + }); + + // Both steps allocate a leading cell. The column is shared across rows and sized via + // `max-content` to the widest value, so each renders its full value on one line. + const first = wrapper.findItems()[0].findHeaderStart()!.getElement(); + const second = wrapper.findItems()[1].findHeaderStart()!.getElement(); + + expect(first).toHaveTextContent(shortValue); + expect(second).toHaveTextContent(longValue); + // No truncation/ellipsis mechanics: the full long value is present in the DOM. + expect(second.textContent).toBe(longValue); + }); + }); }); diff --git a/src/steps/interfaces.ts b/src/steps/interfaces.ts index 37810626e3..b932aef721 100644 --- a/src/steps/interfaces.ts +++ b/src/steps/interfaces.ts @@ -8,10 +8,11 @@ export interface StepsProps extends BaseComponentProps { * An array of individual steps * * Each step definition has the following properties: - * * `status` (string) - Status of the step corresponding to a status indicator. - * * `statusIconAriaLabel` - (string) - (Optional) Alternative text for the status icon. + * * `status` (string) - (Optional) Status of the step corresponding to a status indicator. If not set, the step renders a neutral dot instead of a status icon. + * * `statusIconAriaLabel` - (string) - (Optional) Alternative text for the status icon or neutral dot. * * `header` (ReactNode) - Summary corresponding to the step. * * `details` (ReactNode) - (Optional) Additional information corresponding to the step. + * * `headerStart` (ReactNode) - (Optional) Content rendered in a leading column at the start of the step, before the icon. Typically a timestamp in a timeline view. Applies to the default `vertical` orientation and is ignored when `orientation="horizontal"`. */ steps: ReadonlyArray; /** @@ -52,10 +53,11 @@ export namespace StepsProps { export type Status = StatusIndicatorProps.Type; export interface Step { - status: Status; + status?: Status; statusIconAriaLabel?: string; header: React.ReactNode; details?: React.ReactNode; + headerStart?: React.ReactNode; } export type Orientation = 'vertical' | 'horizontal'; diff --git a/src/steps/internal.tsx b/src/steps/internal.tsx index 27209879d3..5c4e8ee42c 100644 --- a/src/steps/internal.tsx +++ b/src/steps/internal.tsx @@ -26,18 +26,24 @@ const statusToColor: Record = { 'not-started': 'text-status-inactive', }; +// A step without an explicit status renders a neutral dot in the inactive color. +const getStatusColor = (status?: StepsProps.Status): BoxProps.Color => + status ? statusToColor[status] : 'text-status-inactive'; + const CustomStep = ({ step, orientation, renderStep, + showHeaderStart, }: { step: StepsProps.Step; orientation: StepsProps.Orientation; renderStep: Required['renderStep']; + showHeaderStart: boolean; }) => { - const { status, statusIconAriaLabel } = step; + const { status, statusIconAriaLabel, headerStart } = step; const { header, details, icon } = renderStep(step); - const iconNode = icon ? icon : ; + const iconNode = icon ? icon : ; if (orientation === 'horizontal') { return ( @@ -52,12 +58,14 @@ const CustomStep = ({ ); } - // Vertical orientation: render the icon and the connector together in a column-1 "rail" so the + // Vertical orientation: render the icon and the connector together in a "rail" column 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. `headerStart` (e.g. a timeline timestamp) is + // an optional leading column rendered before the rail. return (
  • + {showHeaderStart &&
    {headerStart}
    }
    {iconNode}
    @@ -75,19 +83,22 @@ const InternalStep = ({ statusIconAriaLabel, header, details, + headerStart, orientation, -}: StepsProps.Step & { orientation: StepsProps.Orientation }) => { + showHeaderStart, +}: StepsProps.Step & { orientation: StepsProps.Orientation; showHeaderStart: boolean }) => { return (
  • + {showHeaderStart &&
    {headerStart}
    }
    {orientation === 'vertical' ? ( - + {header} ) : ( <> - - + +
    @@ -97,7 +108,7 @@ const InternalStep = ({
    ) : (
    - {header} + {header}
    )} {details &&
    {details}
    } @@ -115,6 +126,8 @@ const InternalSteps = ({ __internalRootRef, ...props }: InternalStepsProps) => { + const showHeaderStart = + orientation === 'vertical' && steps.some(step => step.headerStart !== undefined && step.headerStart !== null); return (
      {steps.map((step, index) => renderStep ? ( - + ) : ( ) )} diff --git a/src/steps/styles.scss b/src/steps/styles.scss index 238fe8ab5f..256b8a6b7f 100644 --- a/src/steps/styles.scss +++ b/src/steps/styles.scss @@ -59,6 +59,17 @@ } } } +// Leading column (e.g. a timestamp), rendered before the icon in vertical orientation. The column +// auto-sizes to the widest value across steps (see `.with-header-start` below); values are aligned +// to the start (left in LTR) so shorter values line up under the longest one. +.header-start { + min-inline-size: 0; + color: awsui.$color-text-status-inactive; + text-align: start; + // Gap between the leading column (e.g. timestamp) and the icon. Included in the shared column + // width, so every icon stays aligned. + padding-inline-end: awsui.$space-m; +} .horizontal { > .list { @@ -152,6 +163,66 @@ } } +// Timeline leading column. Defined last so its higher-specificity selectors follow the base and +// custom-vertical rules (satisfies `no-descending-specificity`). The list owns the columns and each +// step adopts them via `grid-template-columns: subgrid`, so a single leading column (sized to the +// widest `headerStart` via `max-content`) is shared across all steps. The middle column matches the +// base icon column, so both step layouts just shift one column to the right, keeping the connector +// under the icon. +// +// Design note: this is the first use of CSS `subgrid` in this package. It is within our +// supported-browser SLA (browserslist: last 3 major versions of Chrome/Firefox/Edge/Safari; +// subgrid baseline is Chrome/Edge 117, Firefox 71, Safari 16) and passes the +// `no-unsupported-browser-features` lint. Outside the SLA (browsers without subgrid) this timeline +// column collapses and timestamps stop aligning; the degradation is scoped to this `headerStart` +// layout only — default vertical and horizontal steps are unaffected. No `@supports` fallback is +// provided because the scenario is out of SLA and a fallback would add meaningful CSS complexity. +.root > .list.with-header-start { + display: grid; + grid-template-columns: max-content awsui.$space-static-l 1fr; + + > .container { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + + > .header-start { + grid-row: 1; + grid-column: 1; + // Top-align the value so a taller row does not float the timestamp to the vertical middle. + align-self: start; + } + } + + // Default step (status indicator + connector), shifted right by the leading column. + > .container:not(.custom-vertical) { + > .header { + grid-column: 2 / span 2; + } + + > .connector { + grid-column: 2; + } + + > .details { + grid-column: 3; + } + } + + // Custom / rail step: icon rail in the middle column, content in the last. + > .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 +// follows the timeline connector rules above (satisfies `no-descending-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..fe0da7ee6b 100644 --- a/src/test-utils/dom/steps/index.ts +++ b/src/test-utils/dom/steps/index.ts @@ -18,6 +18,14 @@ class StepWrapper extends ComponentWrapper { findDetails(): ElementWrapper | null { return this.findByClassName(styles.details); } + + /** + * Finds the leading content (e.g. a timestamp) rendered at the start of a step. + * Returns null when the step has no `headerStart`. + */ + findHeaderStart(): ElementWrapper | null { + return this.findByClassName(styles['header-start']); + } } export default class StepsWrapper extends ComponentWrapper { static rootSelector: string = styles.root;