diff --git a/pages/wizard/with-app-layout.page.tsx b/pages/wizard/with-app-layout.page.tsx new file mode 100644 index 0000000000..46211bbf06 --- /dev/null +++ b/pages/wizard/with-app-layout.page.tsx @@ -0,0 +1,280 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import AppLayout from '~components/app-layout'; +import Box from '~components/box'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +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 Link from '~components/link'; +import RadioGroup from '~components/radio-group'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; +import Tiles from '~components/tiles'; +import Wizard, { WizardProps } from '~components/wizard'; + +import ScreenshotArea from '../utils/screenshot-area'; +import { i18nStrings } from './common'; + +const appLayoutLabels = { + navigation: 'Side navigation', + navigationToggle: 'Open navigation', + navigationClose: 'Close navigation', + notifications: 'Notifications', + tools: 'Tools', + toolsToggle: 'Open tools', + toolsClose: 'Close tools', +}; + +// Step 1: Choose instance type +function Step1Content() { + const [instanceType, setInstanceType] = useState('t3.micro'); + + return ( + + Instance type}> + setInstanceType(detail.value)} + columns={3} + items={[ + { + value: 't3.micro', + label: 't3.micro', + description: '1 vCPU, 1 GiB memory', + }, + { + value: 't3.small', + label: 't3.small', + description: '2 vCPU, 2 GiB memory', + }, + { + value: 't3.medium', + label: 't3.medium', + description: '2 vCPU, 4 GiB memory', + }, + { + value: 't3.large', + label: 't3.large', + description: '2 vCPU, 8 GiB memory', + }, + { + value: 'm5.large', + label: 'm5.large', + description: '2 vCPU, 8 GiB memory', + }, + { + value: 'm5.xlarge', + label: 'm5.xlarge', + description: '4 vCPU, 16 GiB memory', + }, + ]} + /> + + + ); +} + +// Step 2: Configure storage +function Step2Content() { + const [volumeSize, setVolumeSize] = useState('30'); + const [volumeType, setVolumeType] = useState({ + value: 'gp3', + label: 'General Purpose SSD (gp3)', + }); + + return ( + + Storage configuration}> + + + setVolumeSize(detail.value)} type="number" /> + + + setKeyPair(detail.selectedOption)} + placeholder="Select a key pair" + options={[ + { value: 'my-key-pair', label: 'my-key-pair' }, + { value: 'dev-key', label: 'dev-key' }, + { value: 'production-key', label: 'production-key' }, + ]} + /> + + + + ); +} + +// Step 4: Add tags (optional) +function Step4Content() { + const [tagKey, setTagKey] = useState('Name'); + const [tagValue, setTagValue] = useState(''); + + return ( + + Tags}> + + + setTagKey(detail.value)} /> + + + setTagValue(detail.value)} + placeholder="Enter tag value" + /> + + + + + ); +} + +// Step 5: Review +function Step5Content() { + return ( + + Review instance configuration}> + + +
+ Instance type +
t3.micro
+
+
+ Storage +
30 GiB gp3
+
+
+ +
+ Security group +
Create new
+
+
+ Key pair +
my-key-pair
+
+
+
+
+
+ ); +} + +const steps: WizardProps.Step[] = [ + { + title: 'Choose instance type', + info: Info, + description: 'Select the hardware configuration for your instance.', + content: , + }, + { + title: 'Configure storage', + info: Info, + description: 'Configure the storage options for your instance.', + content: , + }, + { + title: 'Configure security', + info: Info, + description: 'Set up security groups and key pairs.', + content: , + }, + { + title: 'Add tags', + info: Info, + description: 'Add tags to help organize and identify your resources.', + isOptional: true, + content: , + }, + { + title: 'Review and launch', + description: 'Review your configuration before launching the instance.', + content: , + }, +]; + +export default function WizardWithAppLayoutPage() { + const [activeStepIndex, setActiveStepIndex] = useState(0); + + return ( + + + } + content={ + setActiveStepIndex(detail.requestedStepIndex)} + onCancel={() => console.log('Cancelled')} + onSubmit={() => console.log('Submitted')} + /> + } + /> + + ); +} diff --git a/src/wizard/__integ__/wizard.test.ts b/src/wizard/__integ__/wizard.test.ts index ad8aaafabf..b5c893f653 100644 --- a/src/wizard/__integ__/wizard.test.ts +++ b/src/wizard/__integ__/wizard.test.ts @@ -94,6 +94,29 @@ describe('Wizard keyboard navigation', () => { }); }); +describe('Wizard narrow viewport navigation', () => { + test( + 'shows expandable step navigation at narrow viewport', + useBrowser(async browser => { + const page = new WizardPageObject(browser); + // Set narrow viewport first using page object with object syntax + await page.setWindowSize({ width: 320, height: 600 }); + await browser.url('/#/light/wizard/simple?visualRefresh=true'); + await page.waitForVisible(wizardWrapper.findPrimaryButton().toSelector()); + + // Collapsed steps container should exist at narrow viewport + // Note: Using attribute selector because CSS class names are hashed in the build output + // Using isExisting() because ExpandableSection header has screenreader-only styling when collapsed + const collapsedStepsSelector = `${wizardWrapper.toSelector()} [class*="collapsed-steps"]`; + await expect(page.isExisting(collapsedStepsSelector)).resolves.toBe(true); + + // Sidebar navigation should be hidden at narrow viewport + const navigationSelector = `${wizardWrapper.toSelector()} [class*="navigation"]`; + await expect(page.isDisplayed(navigationSelector)).resolves.toBe(false); + }) + ); +}); + describe('Wizard scroll to top upon navigation', () => { test( 'in window', diff --git a/src/wizard/__tests__/wizard.test.tsx b/src/wizard/__tests__/wizard.test.tsx index 2e2c7ddbbc..dec20f5424 100644 --- a/src/wizard/__tests__/wizard.test.tsx +++ b/src/wizard/__tests__/wizard.test.tsx @@ -8,6 +8,7 @@ import TestI18nProvider from '../../../lib/components/i18n/testing'; import createWrapper from '../../../lib/components/test-utils/dom'; import WizardWrapper from '../../../lib/components/test-utils/dom/wizard'; import Wizard, { WizardProps } from '../../../lib/components/wizard'; +import { handleStepNavigation, StepStatusValues } from '../../../lib/components/wizard/wizard-step-list'; import { DEFAULT_I18N_SETS, DEFAULT_STEPS } from './common'; import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js'; @@ -66,7 +67,9 @@ describe('i18nStrings', () => { i18nStrings.navigationAriaLabel ); - wrapper.findAllByClassName(styles['navigation-link-label']).forEach((label, index) => { + // Only test labels from the desktop navigation (not the expandable collapsed-steps navigation) + const desktopNav = wrapper.findByClassName(styles.navigation); + desktopNav!.findAllByClassName(styles['navigation-link-label']).forEach((label, index) => { const expectedTitle = i18nStrings.stepNumberLabel!(index + 1); const expectedLabel = DEFAULT_STEPS[index].isOptional ? `${expectedTitle} - ${i18nStrings.optional}` @@ -619,6 +622,24 @@ describe('Custom primary actions', () => { }); }); +describe('handleStepNavigation', () => { + test('calls onStepClick for visited steps', () => { + const onStepClick = jest.fn(); + const onSkipToClick = jest.fn(); + handleStepNavigation(2, StepStatusValues.Visited, onStepClick, onSkipToClick); + expect(onStepClick).toHaveBeenCalledWith(2); + expect(onSkipToClick).not.toHaveBeenCalled(); + }); + + test('calls onSkipToClick for next steps', () => { + const onStepClick = jest.fn(); + const onSkipToClick = jest.fn(); + handleStepNavigation(3, StepStatusValues.Next, onStepClick, onSkipToClick); + expect(onSkipToClick).toHaveBeenCalledWith(3); + expect(onStepClick).not.toHaveBeenCalled(); + }); +}); + describe('i18n', () => { test('supports rendering static strings using i18n provider', () => { const { container } = render( diff --git a/src/wizard/internal.tsx b/src/wizard/internal.tsx index ad0709ae55..53e204f39c 100644 --- a/src/wizard/internal.tsx +++ b/src/wizard/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import clsx from 'clsx'; import { useMergeRefs, warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -27,6 +27,7 @@ import { GeneratedAnalyticsMetadataWizardComponent } from './analytics-metadata/ import { WizardProps } from './interfaces'; import WizardForm, { STEP_NAME_SELECTOR } from './wizard-form'; import WizardNavigation from './wizard-navigation'; +import WizardStepNavigationExpandable from './wizard-step-navigation-expandable'; import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './styles.css.js'; @@ -73,6 +74,8 @@ export default function InternalWizard({ const isVisualRefresh = useVisualRefresh(); const isLastStep = actualActiveStepIndex >= steps.length - 1; + const [isStepNavigationExpanded, setIsStepNavigationExpanded] = useState(false); + const navigationEvent = (requestedStepIndex: number, reason: WizardProps.NavigationReason) => { if (funnelInteractionId) { const stepName = getTextFromSelector(STEP_NAME_SELECTOR); @@ -192,12 +195,27 @@ export default function InternalWizard({ onSkipToClick={onSkipToClick} steps={steps} /> + {smallContainer && ( +
+ +
+ )}
.form-header { grid-column: 1 / span 2; + grid-row: 2; } > .form-header > .form-header-content { @@ -252,6 +254,7 @@ > .form-component { grid-column: 1 / span 2; + grid-row: 3; } } } @@ -261,13 +264,22 @@ } .collapsed-steps { - color: awsui.$color-text-heading-secondary; - font-weight: styles.$font-weight-bold; - padding-block-start: awsui.$space-scaled-xxs; - &-hidden { - display: none; + grid-column: 1 / span 2; + grid-row: 1; +} + +/* Override fixed sidebar width for expandable navigation */ +/* stylelint-disable selector-max-type */ +.navigation.refresh.collapsed-steps-navigation { + grid-column: unset; + grid-row: unset; + padding-block-start: 0; + + > ul.refresh { + inline-size: 100%; } } +/* stylelint-enable selector-max-type */ .form-header-component { &-wrapper { diff --git a/src/wizard/wizard-form.tsx b/src/wizard/wizard-form.tsx index 1db4b301e5..3f807914be 100644 --- a/src/wizard/wizard-form.tsx +++ b/src/wizard/wizard-form.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import React, { MutableRefObject, useEffect, useRef } from 'react'; -import clsx from 'clsx'; import { useComponentMetadata, useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import { AnalyticsMetadata } from '@cloudscape-design/component-toolkit/internal/base-component/metrics/interfaces'; @@ -31,7 +30,6 @@ import styles from './styles.css.js'; interface WizardFormProps extends InternalBaseComponentProps { steps: ReadonlyArray; activeStepIndex: number; - showCollapsedSteps: boolean; i18nStrings: WizardProps.I18nStrings; submitButtonText?: string; isPrimaryLoading: boolean; @@ -75,7 +73,6 @@ function WizardForm({ stepHeaderRef, steps, activeStepIndex, - showCollapsedSteps, i18nStrings, submitButtonText, isPrimaryLoading, @@ -131,9 +128,6 @@ function WizardForm({ return ( <> -
- {i18nStrings.collapsedStepsLabel?.(activeStepIndex + 1, steps.length)} -
void; onSkipToClick: (stepIndex: number) => void; - status: string; + status: StepStatus; step: WizardProps.Step; } -enum Statuses { - Active = 'active', - Unvisited = 'unvisited', - Visited = 'visited', - Next = 'next', -} - export default function Navigation({ activeStepIndex, farthestStepIndex, @@ -52,152 +48,63 @@ export default function Navigation({ steps, }: NavigationProps) { const isVisualRefresh = useVisualRefresh(); + return ( ); - - function getStatus(index: number) { - if (activeStepIndex === index) { - return Statuses.Active; - } - if (isLoadingNextStep) { - return Statuses.Unvisited; - } - if (farthestStepIndex >= index) { - return Statuses.Visited; - } - if (allowSkipTo && canSkip(activeStepIndex + 1, index)) { - return Statuses.Next; - } - return Statuses.Unvisited; - } - - function canSkip(fromIndex: number, toIndex: number) { - let index = fromIndex; - do { - if (!steps[index].isOptional) { - return false; - } - index++; - } while (index < toIndex); - return true; - } -} - -function NavigationStepVisualRefresh({ - i18nStrings, - index, - onStepClick, - onSkipToClick, - status, - step, -}: NavigationStepProps) { - function handleStepInteraction() { - if (status === Statuses.Visited) { - onStepClick(index); - } - if (status === Statuses.Next) { - onSkipToClick(index); - } - } - - const state = { - active: 'active', - unvisited: 'disabled', - visited: 'enabled', - next: 'enabled', - }[status]; - - const linkClassName = clsx(styles['navigation-link'], { - [styles['navigation-link-active']]: status === Statuses.Active, - [styles['navigation-link-disabled']]: status === Statuses.Unvisited, - }); - - return ( -
  • -
    - - - {i18nStrings.stepNumberLabel && i18nStrings.stepNumberLabel(index + 1)} - {step.isOptional && {` - ${i18nStrings.optional}`}} - - - { - event.preventDefault(); - handleStepInteraction(); - }} - onKeyDown={event => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - } - // Enter activates the button on key down instead of key up. - if (event.key === 'Enter') { - handleStepInteraction(); - } - }} - onKeyUp={event => { - // Emulate button behavior, which also fires on space. - if (event.key === ' ') { - handleStepInteraction(); - } - }} - role="button" - tabIndex={status === Statuses.Visited || status === Statuses.Next ? 0 : undefined} - {...(status === Statuses.Unvisited - ? {} - : getNavigationActionDetail(index, 'step', true, `.${analyticsSelectors['step-title']}`))} - > -
  • - ); } function NavigationStepClassic({ i18nStrings, index, onStepClick, onSkipToClick, status, step }: NavigationStepProps) { const spanClassName = clsx( styles['navigation-link'], - status === Statuses.Active ? styles['navigation-link-active'] : styles['navigation-link-disabled'] + status === StepStatusValues.Active ? styles['navigation-link-active'] : styles['navigation-link-disabled'] ); + const optionalDescriptionId = useUniqueId('wizard-step-optional-'); return (
  • @@ -208,29 +115,31 @@ function NavigationStepClassic({ i18nStrings, index, onStepClick, onSkipToClick, margin={{ bottom: 'xxs' }} > {i18nStrings.stepNumberLabel && i18nStrings.stepNumberLabel(index + 1)} - {step.isOptional && {` - ${i18nStrings.optional}`}} + {step.isOptional && {` - ${i18nStrings.optional}`}}
    - {status === Statuses.Visited || status === Statuses.Next ? ( + {status === StepStatusValues.Visited || status === StepStatusValues.Next ? ( { evt.preventDefault(); - if (status === Statuses.Visited) { + if (status === StepStatusValues.Visited) { onStepClick(index); } else { onSkipToClick(index); } }} variant="primary" + nativeAttributes={step.isOptional ? { 'aria-describedby': optionalDescriptionId } : undefined} > {step.title} ) : ( {step.title} diff --git a/src/wizard/wizard-step-list.tsx b/src/wizard/wizard-step-list.tsx new file mode 100644 index 0000000000..ccde68bf17 --- /dev/null +++ b/src/wizard/wizard-step-list.tsx @@ -0,0 +1,209 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { getNavigationActionDetail } from './analytics-metadata/utils'; +import { WizardProps } from './interfaces'; + +import analyticsSelectors from './analytics-metadata/styles.css.js'; +import styles from './styles.css.js'; + +export const StepStatusValues = { + Active: 'active', + Visited: 'visited', + Unvisited: 'unvisited', + Next: 'next', +} as const; + +export type StepStatus = (typeof StepStatusValues)[keyof typeof StepStatusValues]; + +export interface WizardStepListProps { + activeStepIndex: number; + farthestStepIndex: number; + allowSkipTo: boolean; + i18nStrings: WizardProps.I18nStrings; + isLoadingNextStep: boolean; + onStepClick: (stepIndex: number) => void; + onSkipToClick: (stepIndex: number) => void; + steps: ReadonlyArray; +} + +export function getStepStatus( + index: number, + activeStepIndex: number, + farthestStepIndex: number, + isLoadingNextStep: boolean, + allowSkipTo: boolean, + steps: ReadonlyArray<{ isOptional?: boolean }> +): StepStatus { + if (activeStepIndex === index) { + return StepStatusValues.Active; + } + if (isLoadingNextStep) { + return StepStatusValues.Unvisited; + } + if (farthestStepIndex >= index) { + return StepStatusValues.Visited; + } + if (allowSkipTo && index > activeStepIndex) { + // Can we skip to this step? (all steps between current and this one are optional) + if (canSkip(activeStepIndex + 1, index, steps)) { + return StepStatusValues.Next; + } + // Immediate next step is also navigable if it's optional + if (index === activeStepIndex + 1 && steps[index]?.isOptional) { + return StepStatusValues.Next; + } + } + return StepStatusValues.Unvisited; +} + +export function canSkip(fromIndex: number, toIndex: number, steps: ReadonlyArray<{ isOptional?: boolean }>): boolean { + // Can't skip if there are no steps to skip over + if (fromIndex >= toIndex) { + return false; + } + for (let i = fromIndex; i < toIndex; i++) { + if (!steps[i].isOptional) { + return false; + } + } + return true; +} + +export function handleStepNavigation( + stepIndex: number, + status: StepStatus, + onStepClick: (index: number) => void, + onSkipToClick: (index: number) => void +): void { + if (status === StepStatusValues.Visited) { + onStepClick(stepIndex); + } else if (status === StepStatusValues.Next) { + onSkipToClick(stepIndex); + } +} + +export default function WizardStepList({ + activeStepIndex, + farthestStepIndex, + allowSkipTo, + i18nStrings, + isLoadingNextStep, + onStepClick, + onSkipToClick, + steps, +}: WizardStepListProps) { + return ( +
      + {steps.map((step, index) => ( + + ))} +
    + ); +} + +interface WizardStepListItemProps { + index: number; + step: WizardProps.Step; + activeStepIndex: number; + farthestStepIndex: number; + allowSkipTo: boolean; + i18nStrings: WizardProps.I18nStrings; + isLoadingNextStep: boolean; + onStepClick: (stepIndex: number) => void; + onSkipToClick: (stepIndex: number) => void; + steps: ReadonlyArray; +} + +function WizardStepListItem({ + index, + step, + activeStepIndex, + farthestStepIndex, + allowSkipTo, + i18nStrings, + isLoadingNextStep, + onStepClick, + onSkipToClick, + steps, +}: WizardStepListItemProps) { + const status = getStepStatus(index, activeStepIndex, farthestStepIndex, isLoadingNextStep, allowSkipTo, steps); + const isClickable = status === StepStatusValues.Visited || status === StepStatusValues.Next; + const stepLabel = i18nStrings.stepNumberLabel?.(index + 1); + const fullStepLabel = `${stepLabel}: ${step.title}`; + const optionalDescriptionId = useUniqueId('wizard-step-optional-'); + const state = { + active: 'active', + unvisited: 'disabled', + visited: 'enabled', + next: 'enabled', + }[status]; + + const handleInteraction = () => handleStepNavigation(index, status, onStepClick, onSkipToClick); + + return ( +
  • +
    + + + {i18nStrings.stepNumberLabel?.(index + 1)} + {step.isOptional && {` - ${i18nStrings.optional}`}} + + + { + event.preventDefault(); + handleInteraction(); + }} + onKeyDown={event => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + } + // Enter activates the button on key down instead of key up. + if (event.key === 'Enter') { + handleInteraction(); + } + }} + onKeyUp={event => { + // Emulate button behavior, which also fires on space. + if (event.key === ' ') { + handleInteraction(); + } + }} + role="button" + tabIndex={isClickable ? 0 : undefined} + aria-label={fullStepLabel} + aria-describedby={step.isOptional ? optionalDescriptionId : undefined} + {...(status === StepStatusValues.Unvisited + ? {} + : getNavigationActionDetail(index, 'step', true, `.${analyticsSelectors['step-title']}`))} + > +
  • + ); +} diff --git a/src/wizard/wizard-step-navigation-expandable.tsx b/src/wizard/wizard-step-navigation-expandable.tsx new file mode 100644 index 0000000000..0c80807308 --- /dev/null +++ b/src/wizard/wizard-step-navigation-expandable.tsx @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import InternalExpandableSection from '../expandable-section/internal'; +import { WizardProps } from './interfaces'; +import WizardStepList from './wizard-step-list'; + +import styles from './styles.css.js'; + +interface WizardStepNavigationExpandableProps { + activeStepIndex: number; + farthestStepIndex: number; + allowSkipTo: boolean; + i18nStrings: WizardProps.I18nStrings; + isLoadingNextStep: boolean; + onStepClick: (stepIndex: number) => void; + onSkipToClick: (stepIndex: number) => void; + steps: ReadonlyArray; + expanded: boolean; + onExpandChange: (expanded: boolean) => void; +} + +export default function WizardStepNavigationExpandable({ + activeStepIndex, + farthestStepIndex, + allowSkipTo, + i18nStrings, + isLoadingNextStep, + onStepClick, + onSkipToClick, + steps, + expanded, + onExpandChange, +}: WizardStepNavigationExpandableProps) { + const collapsedStepsLabel = i18nStrings.collapsedStepsLabel?.(activeStepIndex + 1, steps.length); + const headerAriaLabel = collapsedStepsLabel + ? `${collapsedStepsLabel} - ${i18nStrings.navigationAriaLabel ?? 'Steps'}` + : undefined; + + return ( + onExpandChange(detail.expanded)} + > + + + ); +}