From 650d2f21edcfb60cceb56032690effe77d5278ee Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 11 Mar 2026 09:29:28 +0200 Subject: [PATCH] refactor(stepper): Stepper and step refactor - Sync stepper and steps through Lit context - Absracted state logic to a separate file - Moved common types and animations to a common folder - Updated tests and stories to reflect the changes --- .../stepper/{ => common}/animations.ts | 6 +- src/components/stepper/common/context.ts | 12 + src/components/stepper/common/state.ts | 228 +++ .../{stepper.common.ts => common/types.ts} | 0 src/components/stepper/step.ts | 456 +++-- src/components/stepper/stepper-utils.spec.ts | 107 - src/components/stepper/stepper.spec.ts | 1760 ++++++----------- src/components/stepper/stepper.ts | 659 +++--- src/index.ts | 4 +- stories/stepper.stories.ts | 413 +++- 10 files changed, 1779 insertions(+), 1866 deletions(-) rename src/components/stepper/{ => common}/animations.ts (95%) create mode 100644 src/components/stepper/common/context.ts create mode 100644 src/components/stepper/common/state.ts rename src/components/stepper/{stepper.common.ts => common/types.ts} (100%) delete mode 100644 src/components/stepper/stepper-utils.spec.ts diff --git a/src/components/stepper/animations.ts b/src/components/stepper/common/animations.ts similarity index 95% rename from src/components/stepper/animations.ts rename to src/components/stepper/common/animations.ts index 30fc3329e..d09711064 100644 --- a/src/components/stepper/animations.ts +++ b/src/components/stepper/common/animations.ts @@ -1,12 +1,12 @@ -import { EaseOut } from '../../animations/easings.js'; +import { EaseOut } from '../../../animations/easings.js'; import { type AnimationReferenceMetadata, animation, -} from '../../animations/types.js'; +} from '../../../animations/types.js'; import type { HorizontalTransitionAnimation, StepperVerticalAnimation, -} from '../types.js'; +} from '../../types.js'; const baseOptions: KeyframeAnimationOptions = { duration: 320, diff --git a/src/components/stepper/common/context.ts b/src/components/stepper/common/context.ts new file mode 100644 index 000000000..1e525080c --- /dev/null +++ b/src/components/stepper/common/context.ts @@ -0,0 +1,12 @@ +import { createContext } from '@lit/context'; +import type IgcStepperComponent from '../stepper.js'; +import type { StepperState } from './state.js'; + +export type StepperContext = { + stepper: IgcStepperComponent; + state: StepperState; +}; + +export const STEPPER_CONTEXT = createContext( + Symbol('stepper-context') +); diff --git a/src/components/stepper/common/state.ts b/src/components/stepper/common/state.ts new file mode 100644 index 000000000..97be20b59 --- /dev/null +++ b/src/components/stepper/common/state.ts @@ -0,0 +1,228 @@ +import type { PropertyValues } from 'lit'; +import type IgcStepComponent from '../step.js'; + +type StepState = { + linearDisabled: boolean; + previousCompleted: boolean; + visited: boolean; +}; + +class StepperState { + private readonly _state = new WeakMap(); + + private _steps: IgcStepComponent[] = []; + private _activeStep?: IgcStepComponent; + + public linear = false; + + //#region Collection accessors + + /** Returns all registered step components. */ + public get steps(): readonly IgcStepComponent[] { + return this._steps; + } + + /** Returns the currently active step. */ + public get activeStep(): IgcStepComponent | undefined { + return this._activeStep; + } + + /** Returns all steps that are currently accessible (not disabled or linear-disabled). */ + public get accessibleSteps(): IgcStepComponent[] { + return this._steps.filter((step) => this.isAccessible(step)); + } + + //#endregion + + //#region Per-step state + + /** + * Sets the state of a given step component. + * + * If the step already has an existing state, it merges the new state with the existing one. + * If the step does not have an existing state, it initializes it with default values and then applies the new state. + * + * After updating the state, it requests an update on the step component to reflect the changes in the UI. + */ + public set(step: IgcStepComponent, state: Partial): void { + this.has(step) + ? this._state.set(step, { ...this.get(step)!, ...state }) + : this._state.set(step, { + linearDisabled: false, + previousCompleted: false, + visited: false, + ...state, + }); + + step.requestUpdate(); + } + + /** Checks if a given step component has an associated state. */ + public has(step: IgcStepComponent): boolean { + return this._state.has(step); + } + + /** Retrieves the state of a given step component. */ + public get(step: IgcStepComponent): StepState | undefined { + return this._state.get(step); + } + + /** Deletes the state of a given step component. */ + public delete(step: IgcStepComponent): boolean { + return this._state.delete(step); + } + + /** + * Determines if a given step component is accessible based on its `disabled` state + * and the `linearDisabled` state from the stepper state management. + */ + public isAccessible(step: IgcStepComponent): boolean { + return !(step.disabled || this.get(step)?.linearDisabled); + } + + //#endregion + + //#region Active step management + + /** Updates the registered steps collection. */ + public setSteps(steps: IgcStepComponent[]): void { + this._steps = steps; + } + + /** Changes the active step, deactivating the previous one and marking the new one as visited. */ + public changeActiveStep(step: IgcStepComponent): void { + if (step === this._activeStep) { + return; + } + + if (this._activeStep) { + this._activeStep.active = false; + } + step.active = true; + this.set(step, { visited: true }); + this._activeStep = step; + } + + /** Activates the first non-disabled step. */ + public activateFirstStep(): void { + const step = this._steps.find((s) => !s.disabled); + if (step) { + this.changeActiveStep(step); + } + } + + /** Returns the next or previous accessible step relative to the active step. */ + public getAdjacentStep(next = true): IgcStepComponent | undefined { + const steps = this.accessibleSteps; + const activeIndex = steps.indexOf(this._activeStep!); + + if (activeIndex === -1) { + return undefined; + } + + return next ? steps[activeIndex + 1] : steps[activeIndex - 1]; + } + + //#endregion + + //#region State synchronization + + /** Synchronizes the `active` and `previousCompleted` state across all steps. */ + public syncState(): void { + for (const [index, step] of this._steps.entries()) { + step.active = this._activeStep === step; + + if (index > 0) { + this.set(step, { + previousCompleted: this._steps[index - 1].complete, + }); + } + } + } + + /** + * Sets the visited state for all steps based on the current active step and the linear mode. + */ + public setVisitedState(value: boolean): void { + const activeIndex = this._steps.indexOf(this._activeStep!); + this.linear = value; + + for (const [index, step] of this._steps.entries()) { + this.set(step, { visited: index <= activeIndex }); + } + + this.setLinearState(); + } + + /** Computes and applies the linear-disabled state for all steps. */ + public setLinearState(): void { + if (!this.linear) { + for (const step of this._steps) { + this.set(step, { linearDisabled: false }); + } + return; + } + + const invalidIndex = this._steps.findIndex( + (step) => !(step.disabled || step.optional) && step.invalid + ); + + if (invalidIndex > -1) { + for (const [index, step] of this._steps.entries()) { + this.set(step, { linearDisabled: index > invalidIndex }); + } + } else { + for (const step of this._steps) { + this.set(step, { linearDisabled: false }); + } + } + } + + /** Handles step property changes, updating active step tracking and re-syncing state. */ + public onStepPropertyChanged( + step: IgcStepComponent, + changed: PropertyValues + ): void { + if (changed.has('active') && step.active) { + this.changeActiveStep(step); + } + this.syncState(); + this.setLinearState(); + } + + /** Processes a change in the steps collection, resolving the active step and syncing state. */ + public stepsChanged(): void { + const lastActiveStep = this._steps.findLast((step) => step.active); + + if (lastActiveStep) { + this.changeActiveStep(lastActiveStep); + } else { + this.activateFirstStep(); + } + + this.syncState(); + this.setLinearState(); + } + + /** Resets all step states and activates the first step. */ + public reset(): void { + for (const step of this._steps) { + this.delete(step); + } + + this.activateFirstStep(); + this.setLinearState(); + } + + //#endregion +} + +/** + * Creates a new instance of the StepperState class, which manages the state of steps in a stepper component. + */ +function createStepperState(): StepperState { + return new StepperState(); +} + +export { createStepperState }; +export type { StepperState }; diff --git a/src/components/stepper/stepper.common.ts b/src/components/stepper/common/types.ts similarity index 100% rename from src/components/stepper/stepper.common.ts rename to src/components/stepper/common/types.ts diff --git a/src/components/stepper/step.ts b/src/components/stepper/step.ts index 687e0b45a..67811ddab 100644 --- a/src/components/stepper/step.ts +++ b/src/components/stepper/step.ts @@ -1,31 +1,39 @@ -import { html, LitElement, nothing } from 'lit'; -import { property, query, queryAssignedElements } from 'lit/decorators.js'; -import { createRef, type Ref, ref } from 'lit/directives/ref.js'; -import { when } from 'lit/directives/when.js'; - +import { consume } from '@lit/context'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { EaseInOut } from '../../animations/easings.js'; import { addAnimationController } from '../../animations/player.js'; import { addThemingController } from '../../theming/theming-controller.js'; -import { watch } from '../common/decorators/watch.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import { partMap } from '../common/part-map.js'; import type { + HorizontalTransitionAnimation, StepperOrientation, StepperStepType, StepperTitlePosition, + StepperVerticalAnimation, } from '../types.js'; -import { - type Animation, - bodyAnimations, - contentAnimations, -} from './animations.js'; +import { bodyAnimations, contentAnimations } from './common/animations.js'; +import { STEPPER_CONTEXT, type StepperContext } from './common/context.js'; +import type { StepperState } from './common/state.js'; +import type IgcStepperComponent from './stepper.js'; import { styles as shared } from './themes/step/shared/step.common.css.js'; import { styles } from './themes/step/step.base.css.js'; import { all } from './themes/step/themes.js'; /** - * The step component is used within the `igc-stepper` element and it holds the content of each step. - * It also supports custom indicators, title and subtitle. + * A step component used within an `igc-stepper` to represent an individual step in a wizard-like workflow. + * + * @remarks + * Each step has a header (with an indicator, title, and subtitle) and a content area. + * Steps can be marked as `active`, `complete`, `disabled`, `optional`, or `invalid` + * to control their appearance and behavior within the stepper. + * + * Custom indicators can be provided via the `indicator` slot, and the content area + * is rendered in the default slot. * * @element igc-step * @@ -47,119 +55,211 @@ import { all } from './themes/step/themes.js'; * @csspart header - Wrapper of the step's `indicator` and `text`. * @csspart indicator - The indicator of the step. * @csspart text - Wrapper of the step's `title` and `subtitle`. - * @csspart empty - Indicates that no title and subtitle has been provided to the step. Applies to `text`. + * @csspart empty - Indicates that no title and subtitle have been provided to the step. Applies to `text`. * @csspart title - The title of the step. * @csspart subtitle - The subtitle of the step. * @csspart body - Wrapper of the step's `content`. - * @csspart content - The steps `content`. + * @csspart content - The step's `content`. + * + * @example + * ```html + * + * + * Home + * Return to the home page + *

Welcome! This is the first step.

+ *
+ * ``` + * + * @example Step with state attributes + * ```html + * + * Completed step + *

This step has been completed.

+ *
+ * + * + * Current step + *

This step has validation errors.

+ *
+ * + * + * Disabled step + *

This step is not accessible.

+ *
+ * ``` */ export default class IgcStepComponent extends LitElement { public static readonly tagName = 'igc-step'; public static override styles = [styles, shared]; /* blazorSuppress */ - public static register() { + public static register(): void { registerComponent(IgcStepComponent); } - private bodyRef: Ref = createRef(); - private contentRef: Ref = createRef(); + //#region Internal state and properties - private bodyAnimationPlayer = addAnimationController(this, this.bodyRef); - private contentAnimationPlayer = addAnimationController( + private readonly _bodyRef = createRef(); + private readonly _contentRef = createRef(); + + private readonly _bodyPlayer = addAnimationController(this, this._bodyRef); + private readonly _contentPlayer = addAnimationController( this, - this.contentRef + this._contentRef ); - @queryAssignedElements({ slot: 'title' }) - private _titleChildren!: Array; + private readonly _slots = addSlotController(this, { + slots: setSlots('indicator', 'title', 'subtitle'), + }); - @queryAssignedElements({ slot: 'subtitle' }) - private _subTitleChildren!: Array; + @consume({ context: STEPPER_CONTEXT, subscribe: true }) + private readonly _stepperContext?: StepperContext; - /* blazorSuppress */ - @query('[part~="header"]') - public header!: HTMLElement; + private get _stepper(): IgcStepperComponent | undefined { + return this._stepperContext?.stepper; + } - /* blazorSuppress */ - @query('[part~="body"]') - public contentBody!: HTMLElement; + private get _state(): StepperState | undefined { + return this._stepperContext?.state; + } + + private get _isHorizontal(): boolean { + return this._orientation === 'horizontal'; + } + + private get _hasTitle(): boolean { + return this._slots.hasAssignedElements('title'); + } + + private get _hasSubtitle(): boolean { + return this._slots.hasAssignedElements('subtitle'); + } + + private get _animation(): + | StepperVerticalAnimation + | HorizontalTransitionAnimation { + const animation = this._isHorizontal + ? this._stepper?.horizontalAnimation + : this._stepper?.verticalAnimation; + + return animation ?? (this._isHorizontal ? 'slide' : 'grow'); + } + + private get _animationDuration(): number { + return this._stepper?.animationDuration ?? 320; + } + + private get _contentTop(): boolean { + return this._stepper?.contentTop ?? false; + } + + private get _index(): number { + return this._stepper?.steps.indexOf(this) ?? -1; + } - /** Gets/sets whether the step is invalid. */ - @property({ reflect: true, type: Boolean }) + private get _orientation(): StepperOrientation { + return this._stepper?.orientation ?? 'horizontal'; + } + + private get _titlePosition(): StepperTitlePosition { + return this._stepper?.titlePosition ?? 'auto'; + } + + private get _stepType(): StepperStepType { + return this._stepper?.stepType ?? 'full'; + } + + private get _previousComplete(): boolean { + return this._state?.get(this)?.previousCompleted ?? false; + } + + private get _visited(): boolean { + return this._state?.get(this)?.visited ?? false; + } + + private get _isAccessible(): boolean { + return this._state?.isAccessible(this) ?? true; + } + + //#endregion + + //#region Public attributes and properties + + /** + * Whether the step is invalid. + * + * Invalid steps are styled with an error state and are not + * interactive when the stepper is in linear mode. + * + * @attr invalid + * @default false + */ + @property({ type: Boolean, reflect: true }) public invalid = false; - /** Gets/sets whether the step is activе. */ - @property({ reflect: true, type: Boolean }) + /** + * Whether the step is active. + * + * Active steps are styled with an active state and their content is visible. + * + * @attr active + * @default false + */ + @property({ type: Boolean, reflect: true }) public active = false; /** - * Gets/sets whether the step is optional. + * Whether the step is optional. * - * @remarks * Optional steps validity does not affect the default behavior when the stepper is in linear mode i.e. * if optional step is invalid the user could still move to the next step. + * + * @attr optional + * @default false */ - @property({ type: Boolean }) + @property({ type: Boolean, reflect: true }) public optional = false; - /** Gets/sets whether the step is interactable. */ - @property({ reflect: true, type: Boolean }) + /** + * Whether the step is disabled. + * + * Disabled steps are styled with a disabled state and are not interactive. + * + * @attr disabled + * @default false + */ + @property({ type: Boolean, reflect: true }) public disabled = false; - /** Gets/sets whether the step is completed. + /** + * Whether the step is completed. * - * @remarks - * When set to `true` the following separator is styled `solid`. + * @attr complete + * @default false */ - @property({ reflect: true, type: Boolean }) + @property({ type: Boolean, reflect: true }) public complete = false; - /** @hidden @internal @private */ - @property({ attribute: false }) - public previousComplete = false; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public stepType: StepperStepType = 'full'; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public titlePosition: StepperTitlePosition = 'auto'; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public orientation: StepperOrientation = 'horizontal'; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public index = -1; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public contentTop = false; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public linearDisabled = false; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public visited = false; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public animation: Animation = 'fade'; - - /** @hidden @internal @private */ - @property({ attribute: false }) - public animationDuration = 350; + //#endregion constructor() { super(); addThemingController(this, all); } + protected override willUpdate(changed: PropertyValues): void { + if ( + changed.has('active') || + changed.has('complete') || + changed.has('disabled') || + changed.has('invalid') || + changed.has('optional') + ) { + this._state?.onStepPropertyChanged(this, changed); + } + } + /** * @hidden @internal * @deprecated since 5.4.0. Use the Stepper's `navigateTo` method instead. @@ -168,14 +268,14 @@ export default class IgcStepComponent extends LitElement { type: 'in' | 'out', direction: 'normal' | 'reverse' = 'normal' ) { - const bodyAnimation = bodyAnimations.get(this.animation)!.get(type)!; - const contentAnimation = contentAnimations.get(this.animation)!.get(type)!; - const bodyHeight = window - .getComputedStyle(this) - .getPropertyValue('--vertical-body-height'); + const bodyAnimation = bodyAnimations.get(this._animation)!.get(type)!; + const contentAnimation = contentAnimations.get(this._animation)!.get(type)!; + const bodyHeight = getComputedStyle(this).getPropertyValue( + '--vertical-body-height' + ); const options: KeyframeAnimationOptions = { - duration: this.animationDuration, + duration: this._animationDuration, easing: EaseInOut.Quad, direction, }; @@ -185,10 +285,10 @@ export default class IgcStepComponent extends LitElement { }; const result = await Promise.all([ - this.bodyAnimationPlayer.playExclusive( + this._bodyPlayer.playExclusive( bodyAnimation({ keyframe: options, step }) ), - this.contentAnimationPlayer.playExclusive( + this._contentPlayer.playExclusive( contentAnimation({ keyframe: options, step }) ), ]); @@ -196,102 +296,52 @@ export default class IgcStepComponent extends LitElement { return result.every(Boolean); } - @watch('active', { waitUntilFirstUpdate: true }) - protected activeChange() { - if (this.active) { - this.dispatchEvent( - new CustomEvent('stepActiveChanged', { bubbles: true, detail: false }) - ); - } - } - - @watch('disabled', { waitUntilFirstUpdate: true }) - @watch('invalid', { waitUntilFirstUpdate: true }) - @watch('optional', { waitUntilFirstUpdate: true }) - protected disabledInvalidOptionalChange() { - this.dispatchEvent( - new CustomEvent('stepDisabledInvalidChanged', { bubbles: true }) - ); - } - - @watch('complete', { waitUntilFirstUpdate: true }) - protected completeChange() { - this.dispatchEvent( - new CustomEvent('stepCompleteChanged', { bubbles: true }) - ); - } - - private handleClick(event: MouseEvent): void { - event.stopPropagation(); - if (this.isAccessible) { - this.dispatchEvent( - new CustomEvent('stepActiveChanged', { bubbles: true, detail: true }) - ); - } - } - - private handleKeydown(event: KeyboardEvent): void { - this.dispatchEvent( - new CustomEvent('stepHeaderKeydown', { - bubbles: true, - detail: { event, focusedStep: this }, - }) - ); - } - - /** @hidden @internal */ - public get isAccessible(): boolean { - return !this.disabled && !this.linearDisabled; - } - - private get headerContainerParts() { + private get _headerContainerParts() { return { 'header-container': true, - disabled: !this.isAccessible, + disabled: !this._isAccessible, 'complete-start': this.complete, - 'complete-end': this.previousComplete, + 'complete-end': this._previousComplete, optional: this.optional, invalid: - this.invalid && this.visited && !this.active && this.isAccessible, - top: this.titlePosition === 'top', + this.invalid && this._visited && !this.active && this._isAccessible, + top: this._titlePosition === 'top', bottom: - this.titlePosition === 'bottom' || - (this.orientation === 'horizontal' && this.titlePosition === 'auto'), - start: this.titlePosition === 'start', + this._titlePosition === 'bottom' || + (this._isHorizontal && this._titlePosition === 'auto'), + start: this._titlePosition === 'start', end: - this.titlePosition === 'end' || - (this.orientation === 'vertical' && this.titlePosition === 'auto'), + this._titlePosition === 'end' || + (!this._isHorizontal && this._titlePosition === 'auto'), }; } - private get textParts() { + private get _textParts() { + const hasText = this._hasTitle || this._hasSubtitle; + return { text: true, empty: - this.stepType === 'indicator' - ? true - : this.stepType === 'full' && - !this._titleChildren.length && - !this._subTitleChildren.length, + this._stepType === 'indicator' || + (this._stepType === 'full' && !hasText), }; } - protected renderIndicator() { - if (this.stepType !== 'title') { - return html` -
- - ${this.index + 1} - -
- `; - } - return nothing; + private _renderIndicator() { + return this._stepType !== 'title' + ? html` +
+ + ${this._index + 1} + +
+ ` + : nothing; } - protected renderTitleAndSubtitle() { + private _renderTitleAndSubtitle() { return html` -
+
@@ -302,43 +352,55 @@ export default class IgcStepComponent extends LitElement { `; } - protected renderContent() { - return html`
-
- -
-
`; - } + private _renderHeader() { + const index = this._index + 1; + const size = this._stepper?.steps.length ?? 0; - protected override render() { return html` - ${when( - this.contentTop && this.orientation === 'horizontal', - () => this.renderContent(), - () => nothing - )} -
+
- ${when( - this.orientation === 'vertical' || !this.contentTop, - () => this.renderContent(), - () => nothing + `; + } + + private _renderContent() { + const index = this._index + 1; + + return html` +
+
+ +
+
+ `; + } + + protected override render() { + const renderBeforeHeader = this._contentTop && this._isHorizontal; + + return html` + ${cache( + renderBeforeHeader + ? html`${this._renderContent()} ${this._renderHeader()}` + : html`${this._renderHeader()} ${this._renderContent()}` )} `; } diff --git a/src/components/stepper/stepper-utils.spec.ts b/src/components/stepper/stepper-utils.spec.ts deleted file mode 100644 index c00b0ec28..000000000 --- a/src/components/stepper/stepper-utils.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { fixture, html, unsafeStatic } from '@open-wc/testing'; - -import type IgcStepComponent from './step.js'; -import type IgcStepperComponent from './stepper.js'; - -export const DIFF_OPTIONS = { - ignoreAttributes: ['id', 'part', 'tabindex', 'role'], -}; - -export const SLOTS = { - indicator: 'slot[name="indicator"]', - title: 'slot[name="title"]', - subTitle: 'slot[name="sub-title"]', -}; - -export const PARTS = { - header: 'div[part="header"]', - headerContainer: 'div[part~="header-container"]', - body: 'div[part~="body"]', - indentation: 'div[part="indentation"]', - indicator: 'div[part="indicator"]', - text: 'div[part~="text"]', - title: 'div[part="title"]', - subtitle: 'div[part="subtitle"]', - select: 'div[part="select"]', - label: 'div[part="label"]', -}; - -export class StepperTestFunctions { - public static createStepperElement = ( - template = '' - ) => { - return fixture(html`${unsafeStatic(template)}`); - }; - - public static getSlot = ( - step: IgcStepComponent, - selector: string - ): HTMLSlotElement => { - return step.shadowRoot!.querySelector(selector) as HTMLSlotElement; - }; - - public static getElementByPart = ( - step: IgcStepComponent, - selector: string - ): HTMLElement => { - return step.shadowRoot!.querySelector(selector) as HTMLSlotElement; - }; -} - -// Templates -export const simpleStepper = ` - - Step 1 - STEP 1 CONTENT - - - Step 2 - STEP 2 CONTENT - - - Step 3 - STEP 3 CONTENT - - - Step 4 - STEP 4 CONTENT - - - Step 5 - STEP 5 CONTENT - - `; - -export const linearModeStepper = ` - - Step 1 - STEP 1 CONTENT - - - Step 2 - STEP 2 CONTENT - - - - Step 3 - STEP 3 CONTENT - - `; - -export const stepperActiveDisabledSteps = ` - - Step 1 - STEP 1 CONTENT - - - Step 2 - STEP 2 CONTENT - - - - - - Step 4 - STEP 4 CONTENT - - `; diff --git a/src/components/stepper/stepper.spec.ts b/src/components/stepper/stepper.spec.ts index a6cfb6314..deac7adcb 100644 --- a/src/components/stepper/stepper.spec.ts +++ b/src/components/stepper/stepper.spec.ts @@ -1,1462 +1,1004 @@ -import { elementUpdated, expect } from '@open-wc/testing'; +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { spy } from 'sinon'; - -import { defineComponents } from '../../index.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { first } from '../common/util.js'; +import { simulateClick, simulateKeyboard } from '../common/utils.spec.js'; +import IgcIconComponent from '../icon/icon.js'; import IgcStepComponent from './step.js'; import IgcStepperComponent from './stepper.js'; -import { - linearModeStepper, - PARTS, - SLOTS, - StepperTestFunctions, - simpleStepper, - stepperActiveDisabledSteps, -} from './stepper-utils.spec.js'; describe('Stepper', () => { before(() => { - defineComponents(IgcStepperComponent); + defineComponents(IgcStepperComponent, IgcIconComponent); }); let stepper: IgcStepperComponent; - let eventSpy: any; - describe('Basic', async () => { + describe('Initialization & rendering', () => { beforeEach(async () => { - stepper = await StepperTestFunctions.createStepperElement(simpleStepper); + stepper = await fixture(createStepper()); }); - it('Should render a horizontal stepper containing a sequence of steps', async () => { - // stepper.steps should return all steps - expect(stepper.steps.length).to.equal(5); - expect(stepper).to.contain('igc-step'); + it('should be initialized correctly', async () => { + expect(stepper).to.exist; + expect(stepper.steps).lengthOf(5); + expect(stepper.orientation).to.equal('horizontal'); + expect(stepper.stepType).to.equal('full'); + expect(stepper.linear).to.be.false; + expect(stepper.contentTop).to.be.false; + expect(stepper.titlePosition).to.equal('auto'); + expect(stepper.verticalAnimation).to.equal('grow'); + expect(stepper.horizontalAnimation).to.equal('slide'); + expect(stepper.animationDuration).to.equal(320); + }); + + it('should correctly render slotted content', async () => { + const steps = stepper.steps.map((step) => getStepDOM(step)); + + for (const [i, step] of steps.entries()) { + expect(step.slots.indicator).to.exist; + expect(step.slots.subTitle).to.exist; + expect(step.slots.title).to.exist; + expect(first(step.slots.title.assignedElements()).textContent).to.equal( + `Step ${i + 1}` + ); + expect( + first(step.slots.default.assignedElements()).textContent + ).to.equal(`STEP ${i + 1} CONTENT`); + } + }); - expect(stepper.getAttribute('orientation')).to.not.be.null; - expect(stepper.getAttribute('orientation')).to.equal('horizontal'); + it('should have proper ARIA attributes for steps', async () => { + const steps = stepper.steps.map((step) => getStepDOM(step)); + const length = steps.length.toString(); - // Verify step slots are rendered successfully and elements are correctly displayed. - stepper.steps.forEach((step, index) => { - const indicatorSlot = StepperTestFunctions.getSlot( - step, - SLOTS.indicator + for (const [i, step] of steps.entries()) { + expect(step.parts.header.role).to.equal('tab'); + expect(step.parts.header.ariaPosInSet).to.equal(`${i + 1}`); + expect(step.parts.header.ariaSetSize).to.equal(length); + expect(step.parts.header.ariaControlsElements?.at(0)).to.equal( + step.parts.body ); - expect(indicatorSlot).not.to.be.null; - const titleSlot = StepperTestFunctions.getSlot(step, SLOTS.title); - expect(titleSlot).not.to.be.null; - // the last slot is unnamed and is where the step content is rendered - const slots = step.shadowRoot!.querySelectorAll('slot'); - const childrenSlot = Array.from(slots).filter( - (s) => s.name === '' - )[0] as HTMLSlotElement; - expect(childrenSlot).not.to.be.null; - expect(childrenSlot.assignedElements()[0].textContent).to.equal( - `STEP ${index + 1} CONTENT` + // First step is active by default + expect(step.parts.header.ariaSelected).to.equal( + i === 0 ? 'true' : 'false' ); - }); + } }); - it('Should expand a step through API by activating it', async () => { - const step = stepper.steps[1]; - - stepper.navigateTo(1); - await elementUpdated(stepper); - - expect(step.active).to.be.true; - expect(step).to.have.attribute('active'); + it('should have proper tabindex values based on active state', async () => { + for (const step of stepper.steps) { + const header = getStepDOM(step).parts.header; + expect(header.tabIndex).to.equal(step.active ? 0 : -1); + } }); - it('Should expand a step through UI by activating it', async () => { - const step = stepper.steps[1]; - const stepHeader = step.shadowRoot!.querySelector( - PARTS.header - ) as HTMLElement; - - stepHeader?.dispatchEvent(new MouseEvent('click')); - await elementUpdated(stepper); - - expect(step.active).to.be.true; - expect(step).to.have.attribute('active'); + it('should render proper layout parts for each step', () => { + for (const step of stepper.steps) { + const dom = getStepDOM(step); + expect(dom.parts.headerContainer).not.to.be.null; + expect(dom.parts.header).not.to.be.null; + expect(dom.parts.body).not.to.be.null; + } }); - it('Should not allow activating a step with the next/prev methods when disabled is set to true', async () => { - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; - - step1.disabled = true; - await elementUpdated(stepper); - - expect(step1).to.have.attribute('disabled'); - - stepper.next(); - expect(step1.active).to.be.false; - expect(step2.active).to.be.true; + it('should display the step index + 1 as the default indicator', () => { + for (const [i, step] of stepper.steps.entries()) { + const indicator = getStepDOM(step).parts.indicator; + expect(indicator).not.to.be.null; + expect(indicator.querySelector('span')!.textContent).to.equal( + `${i + 1}` + ); + } + }); + }); - stepper.prev(); - expect(step1.active).to.be.false; - expect(step0.active).to.be.true; + describe('Activation', () => { + beforeEach(async () => { + stepper = await fixture(createStepper()); }); - it('Should do nothing when all steps are not accessible and next/prev methods are called', async () => { - stepper.steps[1].active = true; - stepper.steps.forEach((step) => { - step.disabled = true; - }); - await elementUpdated(stepper); + it('should activate the first non-disabled step by default', () => { + expect(stepper.steps[0].active).to.be.true; + for (let i = 1; i < stepper.steps.length; i++) { + expect(stepper.steps[i].active).to.be.false; + } + }); - stepper.next(); - await elementUpdated(stepper); + it('should not allow more than one active step at a time', async () => { + stepper = await fixture(createDisabledStepper()); + // two steps declared as active — only the last one wins + expect(stepper.steps[0].active).to.be.false; expect(stepper.steps[1].active).to.be.true; + }); - stepper.prev(); - await elementUpdated(stepper); + it('should activate a step that is both active and disabled initially', async () => { + stepper = await fixture(createDisabledStepper()); + expect(stepper.steps[1].disabled).to.be.true; expect(stepper.steps[1].active).to.be.true; }); - it('Should not allow activating more than one step at a time', async () => { - stepper = await StepperTestFunctions.createStepperElement( - stepperActiveDisabledSteps - ); - // the first two steps are set to be active initially - // should be activated only the last one - expect(stepper.steps[0].active).to.be.false; - expect(stepper.steps[1].active).to.be.true; - }); + it('should activate steps through `active` property', async () => { + const [firstStep, secondStep] = stepper.steps; - it('Should not change the active step when the navigateTo method is called with a mismatched index', async () => { - stepper.navigateTo(999); + secondStep.active = true; await elementUpdated(stepper); - expect(stepper.steps[0].active).to.be.true; - expect(stepper.steps[0]).to.have.attribute('active'); + expect(firstStep.active).to.be.false; + expect(secondStep.active).to.be.true; }); - it('Should properly set the active state of the steps when the active step is removed dynamically', async () => { - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - - expect(step0.active).to.be.true; + it('should activate steps through `navigateTo`', async () => { + const [firstStep, secondStep] = stepper.steps; - stepper.removeChild(step0); + stepper.navigateTo(1); await elementUpdated(stepper); - expect(step1.active).to.be.true; + expect(firstStep.active).to.be.false; + expect(secondStep.active).to.be.true; }); - it('Should activate a step when it is set to be active and disabled initially', async () => { - stepper = await StepperTestFunctions.createStepperElement( - stepperActiveDisabledSteps - ); - - // the step at index 1 is set to be disabled and active initially - expect(stepper.steps[1].disabled).to.be.true; - expect(stepper.steps[1].active).to.be.true; - }); + it('should do nothing when `navigateTo` is called with an out-of-bounds index', async () => { + stepper.navigateTo(999); + await elementUpdated(stepper); - it('Should properly set the active step of the stepper when an active step is added dynamically', async () => { - // initially the step at index 0 is the active step expect(stepper.steps[0].active).to.be.true; + }); - const newStepAtIndex0 = document.createElement(IgcStepComponent.tagName); - newStepAtIndex0.active = true; + it('should activate steps through UI interaction - click', async () => { + const [firstStep, secondStep] = stepper.steps; - // add an active step before the currently active step in the stepper - stepper.prepend(newStepAtIndex0); + simulateClick(getStepDOM(secondStep).parts.header); await elementUpdated(stepper); - // the newly added step shouldn't be the active step of the stepper - expect(newStepAtIndex0.active).to.be.false; - expect(newStepAtIndex0.visited).to.be.false; - expect(stepper.steps[1].active).to.be.true; + expect(firstStep.active).to.be.false; + expect(secondStep.active).to.be.true; + }); - const newStepAtIndex2 = document.createElement(IgcStepComponent.tagName); - newStepAtIndex2.active = true; + it('should not activate a disabled step through click', async () => { + stepper.steps[1].disabled = true; + await elementUpdated(stepper); - // add an active step after the currently active step in the stepper - stepper.insertBefore(newStepAtIndex2, stepper.steps[2]); + simulateClick(getStepDOM(stepper.steps[1]).parts.header); await elementUpdated(stepper); - // the newly added step should be the active step of the stepper - expect(newStepAtIndex2.active).to.be.true; - expect(newStepAtIndex2.visited).to.be.true; + expect(stepper.steps[0].active).to.be.true; expect(stepper.steps[1].active).to.be.false; }); + }); - it('Should emit ing and ed events when a step is activated through UI', async () => { - eventSpy = spy(stepper, 'emitEvent'); - await elementUpdated(stepper); - - const argsIng = { - detail: { - owner: stepper, - oldIndex: 0, - newIndex: 1, - }, - cancelable: true, - }; + describe('Events', () => { + let eventSpy: ReturnType; - const argsEd = { - detail: { - owner: stepper, - index: 1, - }, - }; + beforeEach(async () => { + stepper = await fixture(createStepper()); + eventSpy = spy(stepper, 'emitEvent'); + }); - const stepHeader = stepper.steps[1].shadowRoot!.querySelector( - PARTS.header - ) as HTMLElement; - stepHeader?.dispatchEvent(new MouseEvent('click')); + it('should emit `igcActiveStepChanging` and `igcActiveStepChanged` when activated through UI', async () => { + simulateClick(getStepDOM(stepper.steps[1]).parts.header); await elementUpdated(stepper); expect(eventSpy.callCount).to.equal(2); - expect(eventSpy.firstCall).calledWith('igcActiveStepChanging', argsIng); - expect(eventSpy.secondCall).calledWith('igcActiveStepChanged', argsEd); + expect(eventSpy.firstCall).calledWith('igcActiveStepChanging', { + detail: { oldIndex: 0, newIndex: 1 }, + cancelable: true, + }); + expect(eventSpy.secondCall).calledWith('igcActiveStepChanged', { + detail: { index: 1 }, + }); }); - it('Should not emit events when a step is activated through API', async () => { - eventSpy = spy(stepper, 'emitEvent'); - await elementUpdated(stepper); - - expect(stepper.steps[0].active).to.be.true; - + it('should not emit events when activated through API (`next`, `prev`, `navigateTo`)', async () => { stepper.next(); await elementUpdated(stepper); - - expect(stepper.steps[1].active).to.be.true; expect(eventSpy.callCount).to.equal(0); stepper.prev(); await elementUpdated(stepper); - - expect(stepper.steps[0].active).to.be.true; expect(eventSpy.callCount).to.equal(0); stepper.navigateTo(2); await elementUpdated(stepper); - - expect(stepper.steps[2].active).to.be.true; expect(eventSpy.callCount).to.equal(0); }); - it('Should be able to cancel the igcActiveStepChanging event', async () => { - stepper.addEventListener('igcActiveStepChanging', (event) => { - event.preventDefault(); - }); + it('should be able to cancel `igcActiveStepChanging`', async () => { + stepper.addEventListener('igcActiveStepChanging', (e) => + e.preventDefault() + ); - const stepHeader = stepper.steps[1].shadowRoot!.querySelector( - PARTS.header - ) as HTMLElement; - stepHeader?.dispatchEvent(new MouseEvent('click')); + simulateClick(getStepDOM(stepper.steps[1]).parts.header); await elementUpdated(stepper); - expect(stepper.steps[1].active).to.be.false; expect(stepper.steps[0].active).to.be.true; + expect(stepper.steps[1].active).to.be.false; }); + }); - it('Should not mark a step as visited if it has not been activated before', async () => { - stepper = await StepperTestFunctions.createStepperElement( - stepperActiveDisabledSteps - ); - // two steps are set to be active initially - expect(stepper.steps[0].visited).to.be.false; - expect(stepper.steps[1].visited).to.be.true; + describe('Navigation API', () => { + beforeEach(async () => { + stepper = await fixture(createStepper()); + }); - stepper.steps[3].active = true; + it('should activate the next accessible step with `next()`', async () => { + const [step0, , step2] = stepper.steps; + stepper.steps[1].disabled = true; await elementUpdated(stepper); - // the step at index 2 has not been activated before - expect(stepper.steps[2].visited).to.be.false; - - const newStep = document.createElement(IgcStepComponent.tagName); - stepper.insertBefore(newStep, stepper.steps[3]); + stepper.next(); await elementUpdated(stepper); - // the newly added step is inserted before the active step and has not been activated yet - expect(newStep.visited).to.be.false; + expect(step0.active).to.be.false; + expect(step2.active).to.be.true; }); - it('Should determine the steps that are marked as visited based on the active step in linear mode', async () => { - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; - const step4 = stepper.steps[4]; + it('should activate the previous accessible step with `prev()`', async () => { + const [step0, , step2] = stepper.steps; + stepper.steps[1].disabled = true; + stepper.navigateTo(2); + await elementUpdated(stepper); - step4.active = true; + stepper.prev(); await elementUpdated(stepper); - expect(step4.visited).to.be.true; + expect(step2.active).to.be.false; + expect(step0.active).to.be.true; + }); - step2.active = true; + it('should do nothing at the boundary with `next()` / `prev()`', async () => { + // at last step, next() does nothing + stepper.navigateTo(4); await elementUpdated(stepper); - // when linear mode is enabled all steps before the active one should be marked as visited - // and all steps after that as not visited - stepper.linear = true; + stepper.next(); await elementUpdated(stepper); - expect(step4.visited).to.be.false; - expect(step1.visited).to.be.true; - }); + expect(stepper.steps[4].active).to.be.true; - it('Should activate the first accessible step and clear the visited steps collection when the stepper is reset', async () => { - // "visit" some steps - for (const step of stepper.steps) { - step.active = true; - await elementUpdated(stepper); - expect(step.visited).to.be.true; - } + // at first step, prev() does nothing + stepper.navigateTo(0); + await elementUpdated(stepper); - stepper.reset(); + stepper.prev(); await elementUpdated(stepper); expect(stepper.steps[0].active).to.be.true; - expect(stepper.steps[0].visited).to.be.true; - - expect(stepper.steps[1].visited).to.be.false; - expect(stepper.steps[2].visited).to.be.false; }); - it('Should determine the steps that are disabled in linear mode based on the validity of the active step', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; + it('should do nothing when all steps are inaccessible', async () => { + stepper.navigateTo(1); + for (const step of stepper.steps) step.disabled = true; + await elementUpdated(stepper); - // the optional state is set to true initially - step0.optional = false; + stepper.next(); await elementUpdated(stepper); + expect(stepper.steps[1].active).to.be.true; - for (let i = 1; i < stepper.steps.length; i++) { - expect(stepper.steps[i].isAccessible).to.be.false; - } + stepper.prev(); + await elementUpdated(stepper); + expect(stepper.steps[1].active).to.be.true; + }); - step0.invalid = false; + it('should reset to the first accessible step and clear visited state', async () => { + // visit several steps + stepper.navigateTo(1); + await elementUpdated(stepper); + stepper.navigateTo(2); await elementUpdated(stepper); - expect(step1.isAccessible).to.be.true; - expect(step2.isAccessible).to.be.false; + stepper.reset(); + await elementUpdated(stepper); - step1.invalid = false; + expect(stepper.steps[0].active).to.be.true; + // after reset, steps 1 and 2 are no longer visited — their invalid part should not appear + stepper.steps[1].invalid = true; + stepper.steps[2].invalid = true; await elementUpdated(stepper); - expect(step2.isAccessible).to.be.true; + expect( + getStepDOM(stepper.steps[1]).parts.headerContainer.part.contains( + 'invalid' + ) + ).to.be.false; + expect( + getStepDOM(stepper.steps[2]).parts.headerContainer.part.contains( + 'invalid' + ) + ).to.be.false; }); + }); - it('Should not allow moving forward to the next step in linear mode if the previous step is invalid', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; - - // the optional state is set to true initially - step0.optional = false; - await elementUpdated(stepper); + describe('Dynamic steps', () => { + beforeEach(async () => { + stepper = await fixture(createStepper()); + }); - expect(step0.invalid).to.be.true; - expect(step0).to.have.attribute('invalid'); + it('should activate the next step when the active step is removed', async () => { + const [step0, step1] = stepper.steps; + expect(step0.active).to.be.true; - stepper.next(); + stepper.removeChild(step0); await elementUpdated(stepper); - expect(step1.active).to.be.false; - expect(step0.active).to.be.true; - expect(step1.linearDisabled).to.be.true; - expect(step2.linearDisabled).to.be.true; + expect(step1.active).to.be.true; }); - it('Should set a step to be accessible in linear mode if the previous accessible step is being disabled', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); + it('should not activate a newly prepended active step if current active is later in the list', async () => { + const newStep = document.createElement(IgcStepComponent.tagName); + newStep.active = true; - stepper.steps[0].invalid = false; + stepper.prepend(newStep); await elementUpdated(stepper); - expect(stepper.steps[1].isAccessible).to.be.true; - expect(stepper.steps[2].isAccessible).to.be.false; + expect(newStep.active).to.be.false; + expect(stepper.steps[1].active).to.be.true; + }); + + it('should activate a newly inserted active step if inserted after the current active', async () => { + const newStep = document.createElement(IgcStepComponent.tagName); + newStep.active = true; - stepper.steps[1].disabled = true; + stepper.insertBefore(newStep, stepper.steps[2]); await elementUpdated(stepper); - expect(stepper.steps[2].isAccessible).to.be.true; + expect(newStep.active).to.be.true; + expect(stepper.steps[1].active).to.be.false; }); - it('Should set a step to be not accessible in linear mode if before it is inserted a new invalid step', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); + it('should update `complete-end` part when adjacent steps are removed or their complete state changes', async () => { + const [step0, step1, step2] = stepper.steps; + step0.complete = true; + step1.complete = true; + step2.complete = true; + await elementUpdated(stepper); - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; + const step3Dom = getStepDOM(stepper.steps[3]); + expect(step3Dom.parts.headerContainer.part.contains('complete-end')).to.be + .true; - step0.invalid = false; + step2.complete = false; await elementUpdated(stepper); - expect(step1.isAccessible).to.be.true; + expect(step3Dom.parts.headerContainer.part.contains('complete-end')).to.be + .false; - const newStep = document.createElement(IgcStepComponent.tagName); - newStep.invalid = true; + // restore and remove step2 — step3 should now see step1 as previous + step2.complete = true; await elementUpdated(stepper); - - stepper.insertBefore(newStep, step1); + stepper.removeChild(step2); await elementUpdated(stepper); - expect(newStep.isAccessible).to.be.true; - expect(step1.isAccessible).to.be.false; + // stepper.steps[2] is now the old step3 + expect( + getStepDOM(stepper.steps[2]).parts.headerContainer.part.contains( + 'complete-end' + ) + ).to.be.true; }); - it('Should properly set the linear disabled steps when the active step is removed from the DOM', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); - - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; + it('should update step count CSS property when steps are added or removed', async () => { + expect(stepper.style.getPropertyValue('--steps-count')).to.equal('5'); - step0.invalid = false; - step1.active = true; + const newStep = document.createElement(IgcStepComponent.tagName); + stepper.append(newStep); await elementUpdated(stepper); - expect(step2.isAccessible).to.be.false; + expect(stepper.style.getPropertyValue('--steps-count')).to.equal('6'); - stepper.removeChild(step1); + stepper.removeChild(newStep); await elementUpdated(stepper); - expect(step0.active).to.be.true; - expect(step2.isAccessible).to.be.true; + expect(stepper.style.getPropertyValue('--steps-count')).to.equal('5'); }); + }); - it('Should set a step to be accessible if the previous one is being removed from the DOM and was accessible before that', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); - - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; + describe('Linear mode', () => { + beforeEach(async () => { + stepper = await fixture(createLinearStepper()); + }); - step0.invalid = false; + it('should lock all steps after the first invalid required step', async () => { + // step 0 is invalid+optional initially — step 1 is accessible + // remove optional so step 0 becomes a blocking invalid step + stepper.steps[0].optional = false; await elementUpdated(stepper); - expect(step1.isAccessible).to.be.true; - expect(step2.isAccessible).to.be.false; + expect(isStepAccessible(stepper.steps[0])).to.be.true; + expect(isStepAccessible(stepper.steps[1])).to.be.false; + expect(isStepAccessible(stepper.steps[2])).to.be.false; + }); - stepper.removeChild(step1); + it('should unlock the next step when the currently invalid step becomes valid', async () => { + stepper.steps[0].optional = false; await elementUpdated(stepper); - expect(step2.isAccessible).to.be.true; - }); + stepper.steps[0].invalid = false; + await elementUpdated(stepper); - it('Should set a newly added step to be accessible if is inserted before the active step', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); + expect(isStepAccessible(stepper.steps[1])).to.be.true; + expect(isStepAccessible(stepper.steps[2])).to.be.false; - const newStep = document.createElement(IgcStepComponent.tagName); + stepper.steps[1].invalid = false; await elementUpdated(stepper); - stepper.prepend(newStep); - - expect(newStep.isAccessible).to.be.true; + expect(isStepAccessible(stepper.steps[2])).to.be.true; }); - it("Should not set previous steps to be accessible if a linear disabled step's invalid or disabled states are changed through API", async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); - - // the optional state is set to true initially + it('should not allow moving to the next step with `next()` if current is invalid and required', async () => { stepper.steps[0].optional = false; await elementUpdated(stepper); - stepper.navigateTo(2); + stepper.next(); await elementUpdated(stepper); - // the step at index 1 should not be accessible because the previous one is required and invalid - expect(stepper.steps[1].isAccessible).to.be.false; + expect(stepper.steps[0].active).to.be.true; + expect(stepper.steps[1].active).to.be.false; + }); - stepper.steps[2].disabled = true; + it('should skip a disabled step when computing the blocking invalid step', async () => { + stepper.steps[0].invalid = false; await elementUpdated(stepper); - expect(stepper.steps[1].isAccessible).to.be.false; + expect(isStepAccessible(stepper.steps[1])).to.be.true; + expect(isStepAccessible(stepper.steps[2])).to.be.false; - stepper.steps[2].invalid = true; + stepper.steps[1].disabled = true; await elementUpdated(stepper); - expect(stepper.steps[1].isAccessible).to.be.false; + expect(isStepAccessible(stepper.steps[2])).to.be.true; }); - it('Should set a step to be accessible in linear mode if the previous one is accessible and optional', async () => { - stepper = - await StepperTestFunctions.createStepperElement(linearModeStepper); - // the step at index 0 is set to be invalid and optional initially - expect(stepper.steps[1].isAccessible).to.be.true; + it('should treat an optional invalid step as non-blocking', async () => { + // step 0 is invalid but optional — step 1 should be accessible + expect(isStepAccessible(stepper.steps[1])).to.be.true; - // test whether a step will become accessible when the previous step is invalid but is accessible and optional + // step 1 is now also optional and invalid — step 2 should be accessible stepper.steps[1].optional = true; await elementUpdated(stepper); - expect(stepper.steps[2].isAccessible).to.be.true; - }); - }); - describe('Appearance', async () => { - beforeEach(async () => { - stepper = await StepperTestFunctions.createStepperElement(simpleStepper); + expect(isStepAccessible(stepper.steps[2])).to.be.true; }); - it('Should apply the appropriate attribute to a stepper in a horizontal and vertical orientation', async () => { - expect(stepper.orientation).to.equal('horizontal'); - - stepper.orientation = 'vertical'; + it('should re-evaluate linear state when an invalid step is inserted between valid ones', async () => { + stepper.steps[0].invalid = false; await elementUpdated(stepper); - expect(stepper.attributes.getNamedItem('orientation')?.value).to.equal( - 'vertical' - ); + expect(isStepAccessible(stepper.steps[1])).to.be.true; - stepper.orientation = 'horizontal'; + const newStep = document.createElement(IgcStepComponent.tagName); + newStep.invalid = true; + stepper.insertBefore(newStep, stepper.steps[1]); await elementUpdated(stepper); - expect(stepper.attributes.getNamedItem('orientation')?.value).to.equal( - 'horizontal' - ); - }); - - it("Should properly render the step's layout", () => { - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - const stepHeader = StepperTestFunctions.getElementByPart( - step, - PARTS.header - ) as HTMLElement; - const stepBody = StepperTestFunctions.getElementByPart( - step, - PARTS.body - ) as HTMLElement; - - expect(stepHeaderContainer).not.to.be.null; - expect(stepHeader).not.to.be.null; - expect(stepBody).not.to.be.null; - } + expect(isStepAccessible(newStep)).to.be.true; + expect(isStepAccessible(stepper.steps[2])).to.be.false; }); - it('Should apply the appropriate part to the header container of a step that is disabled or linear disabled', async () => { - stepper.steps[0].disabled = true; + it('should re-evaluate linear state when the active step is removed', async () => { + stepper.steps[0].invalid = false; + stepper.steps[1].active = true; await elementUpdated(stepper); - const step0HeaderContainer = StepperTestFunctions.getElementByPart( - stepper.steps[0], - PARTS.headerContainer - ) as HTMLElement; - - expect(step0HeaderContainer.part.contains('disabled')).to.be.true; - - stepper.linear = true; - await elementUpdated(stepper); + expect(isStepAccessible(stepper.steps[2])).to.be.false; - stepper.steps[1].invalid = true; + stepper.removeChild(stepper.steps[1]); await elementUpdated(stepper); - const step2HeaderContainer = StepperTestFunctions.getElementByPart( - stepper.steps[2], - PARTS.headerContainer - ) as HTMLElement; - - expect(step2HeaderContainer.part.contains('disabled')).to.be.true; + expect(stepper.steps[0].active).to.be.true; + expect(isStepAccessible(stepper.steps[1])).to.be.true; }); - it('Should indicate that a step is completed', async () => { - expect(stepper.steps[0].complete).to.be.false; - expect(stepper.steps[0]).to.not.have.attribute('complete'); - - stepper.steps[0].complete = true; + it('should not change accessibility of earlier steps when a later locked step is mutated', async () => { + stepper.steps[0].optional = false; await elementUpdated(stepper); - expect(stepper.steps[0]).to.have.attribute('complete'); + // step 1 is locked + expect(isStepAccessible(stepper.steps[1])).to.be.false; - stepper.steps[1].complete = true; + stepper.steps[2].disabled = true; await elementUpdated(stepper); + expect(isStepAccessible(stepper.steps[1])).to.be.false; - expect(stepper.steps[1]).to.have.attribute('complete'); + stepper.steps[2].invalid = true; + await elementUpdated(stepper); + expect(isStepAccessible(stepper.steps[1])).to.be.false; }); - it('Should properly indicate where a completed step starts and ends', async () => { - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; + it('should clear linear disabled state when `linear` is turned off', async () => { + stepper.steps[0].optional = false; + await elementUpdated(stepper); - const step0HeaderContainer = StepperTestFunctions.getElementByPart( - step0, - PARTS.headerContainer - ) as HTMLElement; + expect(isStepAccessible(stepper.steps[1])).to.be.false; - const step1HeaderContainer = StepperTestFunctions.getElementByPart( - step1, - PARTS.headerContainer - ) as HTMLElement; + stepper.linear = false; + await elementUpdated(stepper); - const step2HeaderContainer = StepperTestFunctions.getElementByPart( - step2, - PARTS.headerContainer - ) as HTMLElement; + expect(isStepAccessible(stepper.steps[1])).to.be.true; + }); - step0.complete = true; - step1.complete = true; + it('should reset and clear linear disabled state', async () => { + stepper.steps[0].optional = false; await elementUpdated(stepper); - expect(step0).to.have.attribute('complete'); - expect(step1).to.have.attribute('complete'); + expect(isStepAccessible(stepper.steps[1])).to.be.false; - expect(step0HeaderContainer.part.contains('complete-start')).to.be.true; - expect(step0HeaderContainer.part.contains('complete-end')).to.be.false; + stepper.reset(); + await elementUpdated(stepper); - expect(step1HeaderContainer.part.contains('complete-start')).to.be.true; - expect(step1HeaderContainer.part.contains('complete-end')).to.be.true; + expect(stepper.steps[0].active).to.be.true; + // after reset the linear state is re-evaluated from scratch + // step 0 is still invalid and required, so step 1 remains locked + expect(isStepAccessible(stepper.steps[1])).to.be.false; + }); + }); - expect(step2HeaderContainer.part.contains('complete-end')).to.be.true; - expect(step2HeaderContainer.part.contains('complete-start')).to.be.false; + describe('Appearance', () => { + beforeEach(async () => { + stepper = await fixture(createStepper()); + }); - step2.complete = true; + it('should reflect the `orientation` attribute', async () => { + stepper.orientation = 'vertical'; await elementUpdated(stepper); + expect(stepper.getAttribute('orientation')).to.equal('vertical'); - expect(step2HeaderContainer.part.contains('complete-start')).to.be.true; - expect(step2HeaderContainer.part.contains('complete-end')).to.be.true; - - // should properly indicate whether the previous step of a newly added step is completed - const newStep = document.createElement(IgcStepComponent.tagName); - stepper.append(newStep); + stepper.orientation = 'horizontal'; await elementUpdated(stepper); + expect(stepper.getAttribute('orientation')).to.equal('horizontal'); + }); - const step3HeaderContainer = StepperTestFunctions.getElementByPart( - stepper.steps[3], - PARTS.headerContainer - ) as HTMLElement; - - expect(step3HeaderContainer.part.contains('complete-end')).to.be.true; - - step2.complete = false; + it('should apply the `disabled` part to inaccessible step header containers', async () => { + stepper.steps[0].disabled = true; await elementUpdated(stepper); - expect(step3HeaderContainer.part.contains('complete-end')).to.be.false; + expect( + getStepDOM(stepper.steps[0]).parts.headerContainer.part.contains( + 'disabled' + ) + ).to.be.true; - // should indicate the complete state of the previous step when the step between them is removed from the DOM - stepper.removeChild(step2); + stepper.steps[0].disabled = false; await elementUpdated(stepper); - expect(step3HeaderContainer.part.contains('complete-end')).to.be.true; + expect( + getStepDOM(stepper.steps[0]).parts.headerContainer.part.contains( + 'disabled' + ) + ).to.be.false; }); - it('Should apply the appropriate part to the header container of an optional step', async () => { - stepper.steps[0].optional = true; + it('should apply the `disabled` part when a step is linear-disabled', async () => { + stepper.linear = true; + stepper.steps[0].invalid = true; await elementUpdated(stepper); - const step0HeaderContainer = StepperTestFunctions.getElementByPart( - stepper.steps[0], - PARTS.headerContainer - ) as HTMLElement; - - expect(step0HeaderContainer.part.contains('optional')).to.be.true; + expect( + getStepDOM(stepper.steps[1]).parts.headerContainer.part.contains( + 'disabled' + ) + ).to.be.true; }); - it('Should indicate that a step is invalid', async () => { - expect(stepper.steps[0].invalid).to.be.false; - expect(stepper.steps[0]).to.not.have.attribute('invalid'); - - stepper.steps[0].invalid = true; + it('should apply the `complete-start` and `complete-end` parts correctly', async () => { + const [step0, step1, step2] = stepper.steps; + step0.complete = true; + step1.complete = true; await elementUpdated(stepper); - expect(stepper.steps[0]).to.have.attribute('invalid'); + expect( + getStepDOM(step0).parts.headerContainer.part.contains('complete-start') + ).to.be.true; + expect( + getStepDOM(step0).parts.headerContainer.part.contains('complete-end') + ).to.be.false; - stepper.steps[1].invalid = true; + expect( + getStepDOM(step1).parts.headerContainer.part.contains('complete-start') + ).to.be.true; + expect( + getStepDOM(step1).parts.headerContainer.part.contains('complete-end') + ).to.be.true; + + expect( + getStepDOM(step2).parts.headerContainer.part.contains('complete-end') + ).to.be.true; + expect( + getStepDOM(step2).parts.headerContainer.part.contains('complete-start') + ).to.be.false; + }); + + it('should apply the `optional` part to an optional step', async () => { + stepper.steps[0].optional = true; await elementUpdated(stepper); - expect(stepper.steps[0]).to.have.attribute('invalid'); + expect( + getStepDOM(stepper.steps[0]).parts.headerContainer.part.contains( + 'optional' + ) + ).to.be.true; }); - it('Should apply the appropriate part to the header container of an invalid step', async () => { + it('should apply the `invalid` part only when the step is visited, invalid, not active, and accessible', async () => { const step1 = stepper.steps[1]; - const step1HeaderContainer = StepperTestFunctions.getElementByPart( - step1, - PARTS.headerContainer - ) as HTMLElement; + const step1Dom = getStepDOM(step1); step1.invalid = true; await elementUpdated(stepper); - // the step at index 1 is accessible but its invalid state is set to false - expect(step1.isAccessible).to.be.true; - expect(step1HeaderContainer.part.contains('invalid')).to.be.false; - - // the invalid state is set to true but the step is not visited yet - expect(step1HeaderContainer.part.contains('invalid')).to.be.false; + // not yet visited + expect(step1Dom.parts.headerContainer.part.contains('invalid')).to.be + .false; step1.active = true; await elementUpdated(stepper); - // the step is visited but is the currently active step - expect(step1HeaderContainer.part.contains('invalid')).to.be.false; + // visited but currently active + expect(step1Dom.parts.headerContainer.part.contains('invalid')).to.be + .false; stepper.steps[2].active = true; await elementUpdated(stepper); - // the step is accessible, invalid, visited and is not the currently active step - expect(step1HeaderContainer.part.contains('invalid')).to.be.true; + // visited, invalid, not active, accessible + expect(step1Dom.parts.headerContainer.part.contains('invalid')).to.be + .true; }); - it('Should apply the appropriate part to the header container of a step that has no title and subtitle', async () => { - stepper = await StepperTestFunctions.createStepperElement( - stepperActiveDisabledSteps - ); + it('should apply the `empty` part when stepType is `indicator` or there is no title/subtitle', async () => { + stepper = await fixture(createDisabledStepper()); stepper.stepType = 'indicator'; await elementUpdated(stepper); - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const stepHeaderTitleAndSubtitleWrapper = - StepperTestFunctions.getElementByPart( - step, - PARTS.text - ) as HTMLElement; - - expect(stepHeaderTitleAndSubtitleWrapper.part.contains('empty')).to.be - .true; + for (const step of stepper.steps) { + expect(getStepDOM(step).parts.text.part.contains('empty')).to.be.true; } stepper.stepType = 'full'; await elementUpdated(stepper); - // the step at index 2 has not title and subtitle - const step2 = stepper.steps[2]; - const step2HeaderTitleAndSubtitleWrapper = - StepperTestFunctions.getElementByPart(step2, PARTS.text) as HTMLElement; - - expect(step2HeaderTitleAndSubtitleWrapper.part.contains('empty')).to.be - .true; + // step at index 2 of createDisabledStepper has no title/subtitle + expect(getStepDOM(stepper.steps[2]).parts.text.part.contains('empty')).to + .be.true; }); - it('Should indicate which is the currently active step', async () => { - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; - - step1.active = true; - await elementUpdated(stepper); - - expect(step1).to.have.attribute('active'); - - step2.active = true; + it('should show/hide the indicator element based on `stepType`', async () => { + stepper.stepType = 'indicator'; await elementUpdated(stepper); - expect(step1).to.have.not.attribute('active'); - expect(step2).to.have.attribute('active'); - }); - - it('Should place the title in the step element according to the specified titlePosition when stepType is set to "full"', async () => { - // test default title positions for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal('auto'); - expect(stepHeaderContainer.part.contains('bottom')).to.be.true; + expect(getStepDOM(step).parts.indicator).not.to.be.null; + expect(getStepDOM(step).parts.text.part.contains('empty')).to.be.true; } - const positions = ['bottom', 'top', 'end', 'start']; - for (const pos of positions) { - stepper.titlePosition = pos as any; - await elementUpdated(stepper); - - for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal(pos); - expect(stepHeaderContainer.part.contains(pos)).to.be.true; - } - } - - stepper.orientation = 'vertical'; - stepper.titlePosition = 'auto'; + stepper.stepType = 'title'; await elementUpdated(stepper); - // test default title positions for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal('auto'); - expect(stepHeaderContainer.part.contains('end')).to.be.true; - } - - for (const pos of positions) { - stepper.titlePosition = pos as any; - await elementUpdated(stepper); - - for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal(pos); - expect(stepHeaderContainer.part.contains(pos)).to.be.true; - } + expect(getStepDOM(step).parts.indicator).to.be.null; + expect(getStepDOM(step).parts.text.part.contains('empty')).to.be.false; } - stepper.orientation = 'horizontal'; - stepper.titlePosition = 'top'; + stepper.stepType = 'full'; await elementUpdated(stepper); for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal('top'); - expect(stepHeaderContainer.part.contains('top')).to.be.true; + expect(getStepDOM(step).parts.indicator).not.to.be.null; + expect(getStepDOM(step).parts.text.part.contains('empty')).to.be.false; } + }); - stepper.orientation = 'vertical'; - await elementUpdated(stepper); - + it('should position the title according to `titlePosition` in horizontal orientation', async () => { + // default auto -> bottom in horizontal for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal('top'); - expect(stepHeaderContainer.part.contains('top')).to.be.true; + expect(getStepDOM(step).parts.headerContainer.part.contains('bottom')) + .to.be.true; } - // set to the default title position - stepper.orientation = 'horizontal'; - stepper.titlePosition = 'auto'; - await elementUpdated(stepper); - - // test default title positions - for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; + for (const pos of ['bottom', 'top', 'end', 'start'] as const) { + stepper.titlePosition = pos; + await elementUpdated(stepper); - expect(step.titlePosition).to.equal('auto'); - expect(stepHeaderContainer.part.contains('bottom')).to.be.true; + for (const step of stepper.steps) { + expect(getStepDOM(step).parts.headerContainer.part.contains(pos)).to + .be.true; + } } + }); + it('should position the title according to `titlePosition` in vertical orientation', async () => { stepper.orientation = 'vertical'; + stepper.titlePosition = 'auto'; await elementUpdated(stepper); - // test default title positions + // default auto -> end in vertical for (const step of stepper.steps) { - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - - expect(step.titlePosition).to.equal('auto'); - expect(stepHeaderContainer.part.contains('end')).to.be.true; + expect(getStepDOM(step).parts.headerContainer.part.contains('end')).to + .be.true; } - }); - it('Should render the visual step element according to the specified stepType', async () => { - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const indicator = StepperTestFunctions.getElementByPart( - step, - PARTS.indicator - ) as HTMLElement; - const textWrapper = StepperTestFunctions.getElementByPart( - step, - PARTS.text - ) as HTMLElement; - - expect(indicator).not.to.be.null; - expect(textWrapper.part.contains('empty')).to.be.false; - } - - stepper.stepType = 'indicator'; - await elementUpdated(stepper); - - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const indicator = StepperTestFunctions.getElementByPart( - step, - PARTS.indicator - ) as HTMLElement; - const textWrapper = StepperTestFunctions.getElementByPart( - step, - PARTS.text - ) as HTMLElement; - - expect(indicator).not.to.be.null; - expect(textWrapper.part.contains('empty')).to.be.true; - } - - stepper.stepType = 'title'; - await elementUpdated(stepper); - - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const indicator = StepperTestFunctions.getElementByPart( - step, - PARTS.indicator - ) as HTMLElement; - const textWrapper = StepperTestFunctions.getElementByPart( - step, - PARTS.text - ) as HTMLElement; + for (const pos of ['bottom', 'top', 'end', 'start'] as const) { + stepper.titlePosition = pos; + await elementUpdated(stepper); - expect(indicator).to.be.null; - expect(textWrapper.part.contains('empty')).to.be.false; + for (const step of stepper.steps) { + expect(getStepDOM(step).parts.headerContainer.part.contains(pos)).to + .be.true; + } } }); - it("Should indicate each step with a corresponding number when the steps' indicators are not specified and stepType is either “indicator” or “full”", async () => { - const step3 = stepper.steps[2]; - - let step3IndicatorElement = StepperTestFunctions.getElementByPart( - step3, - PARTS.indicator - ) as HTMLElement; - - expect(step3IndicatorElement).not.be.null; - expect( - step3IndicatorElement.children[0].children[0].textContent - ).to.equal((step3.index + 1).toString()); - - stepper.stepType = 'indicator'; - await elementUpdated(stepper); - - step3IndicatorElement = StepperTestFunctions.getElementByPart( - step3, - PARTS.indicator - ) as HTMLElement; - - expect(step3IndicatorElement).not.be.null; - expect( - step3IndicatorElement.children[0].children[0].textContent - ).to.equal((step3.index + 1).toString()); - }); - - it("Should be able to display the steps' content above the steps headers when the stepper is horizontally orientated", async () => { - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - const stepBody = StepperTestFunctions.getElementByPart( - step, - PARTS.body - ) as HTMLElement; - - const stepHeaderContainerIndex = Array.from( - step.shadowRoot!.children - ).indexOf(stepHeaderContainer); - const stepBodyIndex = Array.from(step.shadowRoot!.children).indexOf( - stepBody + it('should display content above headers when `contentTop` is true in horizontal orientation', async () => { + for (const step of stepper.steps) { + const dom = getStepDOM(step); + const children = Array.from(step.renderRoot.children); + expect(children.indexOf(dom.parts.headerContainer)).to.be.lessThan( + children.indexOf(dom.parts.body) ); - - expect(stepHeaderContainerIndex).to.be.lessThan(stepBodyIndex); } stepper.contentTop = true; await elementUpdated(stepper); - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - const stepBody = StepperTestFunctions.getElementByPart( - step, - PARTS.body - ) as HTMLElement; - - const stepHeaderContainerIndex = Array.from( - step.shadowRoot!.children - ).indexOf(stepHeaderContainer); - const stepBodyIndex = Array.from(step.shadowRoot!.children).indexOf( - stepBody + for (const step of stepper.steps) { + const dom = getStepDOM(step); + const children = Array.from(step.renderRoot.children); + expect(children.indexOf(dom.parts.headerContainer)).to.be.greaterThan( + children.indexOf(dom.parts.body) ); - - expect(stepHeaderContainerIndex).to.be.greaterThan(stepBodyIndex); } }); - it("Should properly render the step's content in a vertical orientation when contentTop is set to true", async () => { + it('should not reorder content when `contentTop` is true in vertical orientation', async () => { stepper.orientation = 'vertical'; - await elementUpdated(stepper); - stepper.contentTop = true; await elementUpdated(stepper); - for (let i = 0; i < stepper.steps.length; i++) { - const step = stepper.steps[i]; - const stepHeaderContainer = StepperTestFunctions.getElementByPart( - step, - PARTS.headerContainer - ) as HTMLElement; - const stepBody = StepperTestFunctions.getElementByPart( - step, - PARTS.body - ) as HTMLElement; - - const stepHeaderContainerIndex = Array.from( - step.shadowRoot!.children - ).indexOf(stepHeaderContainer); - const stepBodyIndex = Array.from(step.shadowRoot!.children).indexOf( - stepBody + // contentTop has no effect in vertical mode — header always before body + for (const step of stepper.steps) { + const dom = getStepDOM(step); + const children = Array.from(step.renderRoot.children); + expect(children.indexOf(dom.parts.headerContainer)).to.be.lessThan( + children.indexOf(dom.parts.body) ); - - expect(stepHeaderContainerIndex).to.be.lessThan(stepBodyIndex); } }); }); - describe('Keyboard navigation', async () => { + describe('Keyboard navigation', () => { beforeEach(async () => { - stepper = await StepperTestFunctions.createStepperElement(simpleStepper); - eventSpy = spy(stepper, 'emitEvent'); + stepper = await fixture(createStepper()); }); - it('Should navigate to the first/last step on Home/End key press', async () => { - const firstStep = stepper.steps[0]; - const lastStep = stepper.steps[4]; + it('should focus the first/last step header on Home/End key press', async () => { + const firstStepHeader = getStepDOM(stepper.steps[0]).parts.header; + const lastStepHeader = getStepDOM(stepper.steps[4]).parts.header; - lastStep.active = true; - lastStep.header.focus(); - await elementUpdated(stepper); - - expect(lastStep.header).to.equal(lastStep.shadowRoot!.activeElement); - expect(lastStep).to.equal(document.activeElement); + lastStepHeader.focus(); + simulateKeyboard(lastStepHeader, 'Home'); - lastStep.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Home', - bubbles: true, - cancelable: true, - }) + expect(firstStepHeader).to.equal( + stepper.steps[0].shadowRoot!.activeElement ); - expect(firstStep.active).to.be.false; - expect(firstStep.header).to.equal(firstStep.shadowRoot!.activeElement); - expect(firstStep).to.equal(document.activeElement); - - firstStep.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'End', - bubbles: true, - cancelable: true, - }) - ); + firstStepHeader.focus(); + simulateKeyboard(firstStepHeader, 'End'); - expect(lastStep.header).to.equal(lastStep.shadowRoot!.activeElement); - expect(lastStep).to.equal(document.activeElement); + expect(lastStepHeader).to.equal( + stepper.steps[4].shadowRoot!.activeElement + ); }); - it('Should navigate to the first/last step on Home/End key press (RTL)', async () => { - stepper.dir = 'rtl'; - - const firstStep = stepper.steps[0]; - const lastStep = stepper.steps[4]; + it('should activate the focused step on Enter/Space key press', async () => { + const step1Header = getStepDOM(stepper.steps[1]).parts.header; - lastStep.active = true; - lastStep.header.focus(); + step1Header.focus(); + simulateKeyboard(step1Header, 'Enter'); await elementUpdated(stepper); - expect(lastStep.header).to.equal(lastStep.shadowRoot!.activeElement); - expect(lastStep).to.equal(document.activeElement); - - lastStep.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Home', - bubbles: true, - cancelable: true, - }) - ); + expect(stepper.steps[1].active).to.be.true; - expect(firstStep.active).to.be.false; - expect(firstStep.header).to.equal(firstStep.shadowRoot!.activeElement); - expect(firstStep).to.equal(document.activeElement); - - firstStep.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'End', - bubbles: true, - cancelable: true, - }) - ); + const step2Header = getStepDOM(stepper.steps[2]).parts.header; + step2Header.focus(); + simulateKeyboard(step2Header, ' '); + await elementUpdated(stepper); - expect(lastStep.header).to.equal(lastStep.shadowRoot!.activeElement); - expect(lastStep).to.equal(document.activeElement); + expect(stepper.steps[1].active).to.be.false; + expect(stepper.steps[2].active).to.be.true; }); - it('Should activate the currently focused step on Enter/Space key press', () => { - const step1 = stepper.steps[1]; - const step2 = stepper.steps[2]; - - expect(step1.active).to.be.false; - expect(step2.active).to.be.false; - - step1.header.focus(); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); + it('should navigate with ArrowRight/Left in horizontal orientation (LTR)', () => { + const step0Header = getStepDOM(stepper.steps[0]).parts.header; + const step1Header = getStepDOM(stepper.steps[1]).parts.header; - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.active).to.be.true; - - step2.header.focus(); + step0Header.focus(); + simulateKeyboard(step0Header, 'ArrowRight'); + expect(step1Header).to.equal(stepper.steps[1].shadowRoot!.activeElement); - expect(step2.header).to.equal(step2.shadowRoot!.activeElement); - expect(step2).to.equal(document.activeElement); - - step2.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.active).to.be.false; - expect(step2.active).to.be.true; + simulateKeyboard(step1Header, 'ArrowLeft'); + expect(step0Header).to.equal(stepper.steps[0].shadowRoot!.activeElement); }); - it('Should navigate to the next/previous step in horizontal orientation on Arrow Right/Left key press', () => { - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step4 = stepper.steps[4]; - - step0.header.focus(); - - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); - - step0.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); - - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true, - cancelable: true, - }) - ); + it('should wrap around on ArrowRight/Left at the boundary', () => { + const step0Header = getStepDOM(stepper.steps[0]).parts.header; + const step4Header = getStepDOM(stepper.steps[4]).parts.header; - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); + step0Header.focus(); + simulateKeyboard(step0Header, 'ArrowLeft'); + expect(step4Header).to.equal(stepper.steps[4].shadowRoot!.activeElement); - // should navigate to the next accessible step - step4.header.focus(); - step0.disabled = true; - - step4.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); - - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true, - cancelable: true, - }) - ); - - expect(step4.header).to.equal(step4.shadowRoot!.activeElement); - expect(step4).to.equal(document.activeElement); + step4Header.focus(); + simulateKeyboard(step4Header, 'ArrowRight'); + expect(step0Header).to.equal(stepper.steps[0].shadowRoot!.activeElement); + }); - step0.disabled = false; - step0.header.focus(); + it('should skip disabled steps when navigating with Arrow keys', () => { + stepper.steps[1].disabled = true; - step0.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true, - cancelable: true, - }) - ); + const step0Header = getStepDOM(stepper.steps[0]).parts.header; + const step2Header = getStepDOM(stepper.steps[2]).parts.header; - expect(step4.header).to.equal(step4.shadowRoot!.activeElement); - expect(step4).to.equal(document.activeElement); + step0Header.focus(); + simulateKeyboard(step0Header, 'ArrowRight'); + expect(step2Header).to.equal(stepper.steps[2].shadowRoot!.activeElement); }); - it('Should navigate to the next/previous step in horizontal orientation on Arrow Right/Left key press (RTL)', () => { + it('should reverse ArrowRight/Left in horizontal orientation (RTL)', () => { stepper.dir = 'rtl'; - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - const step4 = stepper.steps[4]; - - step0.header.focus(); - - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); - - step0.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); - - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }) - ); - - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); - - // should navigate to the next accessible step - step4.header.focus(); - step0.disabled = true; - - step4.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); - - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }) - ); + const step0Header = getStepDOM(stepper.steps[0]).parts.header; + const step1Header = getStepDOM(stepper.steps[1]).parts.header; - expect(step4.header).to.equal(step4.shadowRoot!.activeElement); - expect(step4).to.equal(document.activeElement); + step0Header.focus(); + simulateKeyboard(step0Header, 'ArrowLeft'); + expect(step1Header).to.equal(stepper.steps[1].shadowRoot!.activeElement); - step0.disabled = false; - step0.header.focus(); - - step0.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }) - ); - - expect(step4.header).to.equal(step4.shadowRoot!.activeElement); - expect(step4).to.equal(document.activeElement); + simulateKeyboard(step1Header, 'ArrowRight'); + expect(step0Header).to.equal(stepper.steps[0].shadowRoot!.activeElement); }); - it('Should navigate to the next/previous step in a vertical orientation on Arrow Down/Up key press', async () => { + it('should navigate with ArrowDown/Up in vertical orientation', async () => { stepper.orientation = 'vertical'; await elementUpdated(stepper); - const step0 = stepper.steps[0]; - const step1 = stepper.steps[1]; - - step0.header.focus(); - - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); - - step0.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowRight', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); - - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowLeft', - bubbles: true, - cancelable: true, - }) - ); - - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); + const step0Header = getStepDOM(stepper.steps[0]).parts.header; + const step1Header = getStepDOM(stepper.steps[1]).parts.header; - step0.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowDown', - bubbles: true, - cancelable: true, - }) - ); - - expect(step1.header).to.equal(step1.shadowRoot!.activeElement); - expect(step1).to.equal(document.activeElement); - - step1.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowUp', - bubbles: true, - cancelable: true, - }) - ); + step0Header.focus(); + simulateKeyboard(step0Header, 'ArrowDown'); + expect(step1Header).to.equal(stepper.steps[1].shadowRoot!.activeElement); - expect(step0.header).to.equal(step0.shadowRoot!.activeElement); - expect(step0).to.equal(document.activeElement); + simulateKeyboard(step1Header, 'ArrowUp'); + expect(step0Header).to.equal(stepper.steps[0].shadowRoot!.activeElement); }); - it('Should not navigate to the next/previous step in horizontal orientation on Arrow Down/Up key press', () => { - const step2 = stepper.steps[1]; + it('should not navigate with ArrowDown/Up in horizontal orientation', () => { + const step1Header = getStepDOM(stepper.steps[1]).parts.header; + step1Header.focus(); - step2.header.focus(); + simulateKeyboard(step1Header, 'ArrowDown'); + expect(step1Header).to.equal(stepper.steps[1].shadowRoot!.activeElement); - expect(step2.header).to.equal(step2.shadowRoot!.activeElement); - expect(step2).to.equal(document.activeElement); - - step2.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowDown', - bubbles: true, - cancelable: true, - }) - ); - - expect(step2.header).to.equal(step2.shadowRoot!.activeElement); - expect(step2).to.equal(document.activeElement); - - step2.header.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowUp', - bubbles: true, - cancelable: true, - }) - ); - - expect(step2.header).to.equal(step2.shadowRoot!.activeElement); - expect(step2).to.equal(document.activeElement); - }); - - it('Should specify tabIndex="0" for the active step header and tabIndex="-1" for the other steps', async () => { - stepper.steps[0].header.focus(); - - expect(stepper.steps[0].header.tabIndex).to.equal(0); - - for (let i = 1; i < stepper.steps.length; i++) { - expect(stepper.steps[i].header.tabIndex).to.equal(-1); - } - - stepper.steps[stepper.steps.length - 1].active = true; - await elementUpdated(stepper); - - expect(stepper.steps[0].header.tabIndex).to.equal(-1); - expect(stepper.steps[stepper.steps.length - 1].header.tabIndex).to.equal( - 0 - ); - - for (let i = 0; i < stepper.steps.length - 1; i++) { - expect(stepper.steps[i].header.tabIndex).to.equal(-1); - } - }); - }); - - describe('Aria', async () => { - beforeEach(async () => { - stepper = await StepperTestFunctions.createStepperElement(simpleStepper); - eventSpy = spy(stepper, 'emitEvent'); - }); - - it('Should render proper role and orientation attributes for the stepper', async () => { - expect(stepper.attributes.getNamedItem('role')?.value).to.equal( - 'tablist' - ); - expect( - stepper.attributes.getNamedItem('aria-orientation')?.value - ).to.equal('horizontal'); - - stepper.orientation = 'vertical'; - await elementUpdated(stepper); - - expect( - stepper.attributes.getNamedItem('aria-orientation')?.value - ).to.equal('vertical'); - }); - - it('Should render proper aria attributes for each step', async () => { - for (let i = 0; i < stepper.steps.length; i++) { - expect( - stepper.steps[i].header.attributes.getNamedItem('role')?.value - ).to.equal('tab'); - expect( - stepper.steps[i].header.attributes.getNamedItem('aria-posinset') - ?.value - ).to.equal((i + 1).toString()); - expect( - stepper.steps[i].header.attributes.getNamedItem('aria-setsize')?.value - ).to.equal(stepper.steps.length.toString()); - expect( - stepper.steps[i].header.attributes.getNamedItem('aria-controls') - ?.value - ).to.equal( - `${stepper.steps[i].header.id.replace('header', 'content')}` - ); - - if (i !== 0) { - expect( - stepper.steps[i].header.attributes.getNamedItem('aria-selected') - ?.value - ).to.equal('false'); - } - - stepper.steps[i].active = true; - await elementUpdated(stepper); - - expect( - stepper.steps[i].header.attributes.getNamedItem('aria-selected') - ?.value - ).to.equal('true'); - } + simulateKeyboard(step1Header, 'ArrowUp'); + expect(step1Header).to.equal(stepper.steps[1].shadowRoot!.activeElement); }); }); }); + +function isStepAccessible(step: IgcStepComponent): boolean { + return !getStepDOM(step).parts.headerContainer.part.contains('disabled'); +} + +function getStepDOM(step: IgcStepComponent) { + const root = step.renderRoot; + + return { + slots: { + get default() { + return root.querySelector('slot:not([name])')!; + }, + get indicator() { + return root.querySelector('slot[name="indicator"]')!; + }, + get title() { + return root.querySelector('slot[name="title"]')!; + }, + get subTitle() { + return root.querySelector('slot[name="subtitle"]')!; + }, + }, + parts: { + get header() { + return root.querySelector('[data-step-header]')!; + }, + get headerContainer() { + return root.querySelector('[part~="header-container"]')!; + }, + get body() { + return root.querySelector('[part~="body"]')!; + }, + get indentation() { + return root.querySelector('[part="indentation"]')!; + }, + get indicator() { + return root.querySelector('[part="indicator"]')!; + }, + get text() { + return root.querySelector('[part~="text"]')!; + }, + get title() { + return root.querySelector('[part="title"]')!; + }, + get subTitle() { + return root.querySelector('[part="subtitle"]')!; + }, + get select() { + return root.querySelector('[part="select"]')!; + }, + get label() { + return root.querySelector('[part="label"]')!; + }, + }, + }; +} + +function createStepper() { + const steps = [1, 2, 3, 4, 5]; + + return html` + + ${steps.map( + (value) => html` + + Step ${value} + STEP ${value} CONTENT + + ` + )} + + `; +} + +function createLinearStepper() { + return html` + + + Step 1 + STEP 1 CONTENT + + + Step 2 + STEP 2 CONTENT + + + + Step 3 + STEP 3 CONTENT + + + `; +} + +function createDisabledStepper() { + return html` + + + Step 1 + STEP 1 CONTENT + + + Step 2 + STEP 2 CONTENT + + + + + + Step 4 + STEP 4 CONTENT + + + `; +} diff --git a/src/components/stepper/stepper.ts b/src/components/stepper/stepper.ts index 52d69ea67..0db5a4418 100644 --- a/src/components/stepper/stepper.ts +++ b/src/components/stepper/stepper.ts @@ -1,12 +1,30 @@ -import { html, LitElement } from 'lit'; -import { property, queryAssignedElements } from 'lit/decorators.js'; - +import { ContextProvider } from '@lit/context'; +import { html, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; import { addThemingController } from '../../theming/theming-controller.js'; -import { watch } from '../common/decorators/watch.js'; +import { addInternalsController } from '../common/controllers/internals.js'; +import { + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + endKey, + homeKey, +} from '../common/controllers/key-bindings.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { isLTR } from '../common/util.js'; +import { + addSafeEventListener, + findElementFromEventPath, + first, + getRoot, + isLTR, + last, + wrap, +} from '../common/util.js'; import type { HorizontalTransitionAnimation, StepperOrientation, @@ -14,505 +32,457 @@ import type { StepperTitlePosition, StepperVerticalAnimation, } from '../types.js'; +import { STEPPER_CONTEXT } from './common/context.js'; +import { createStepperState } from './common/state.js'; +import type { + IgcActiveStepChangedEventArgs, + IgcActiveStepChangingEventArgs, + IgcStepperComponentEventMap, +} from './common/types.js'; import IgcStepComponent from './step.js'; -import type { IgcStepperComponentEventMap } from './stepper.common.js'; import { styles } from './themes/stepper/stepper.base.css.js'; import { styles as bootstrap } from './themes/stepper/stepper.bootstrap.css.js'; import { styles as fluent } from './themes/stepper/stepper.fluent.css.js'; import { styles as indigo } from './themes/stepper/stepper.indigo.css.js'; +const STEPPER_SYNC_PROPERTIES: (keyof IgcStepperComponent)[] = [ + 'orientation', + 'stepType', + 'contentTop', + 'verticalAnimation', + 'horizontalAnimation', + 'animationDuration', + 'titlePosition', +]; + /** - * IgxStepper provides a wizard-like workflow by dividing content into logical steps. + * A stepper component that provides a wizard-like workflow by dividing content into logical steps. * * @remarks - * The stepper component allows the user to navigate between multiple steps. - * It supports horizontal and vertical orientation as well as keyboard navigation and provides API methods to control the active step. + * The stepper component allows the user to navigate between multiple `igc-step` elements. + * It supports horizontal and vertical orientation, linear and non-linear navigation, + * keyboard navigation, and provides API methods to control the active step. + * + * In linear mode, the user can only advance to the next step if the current step is valid + * (not marked as `invalid`). * * @element igc-stepper * - * @slot - Renders the step components inside default slot. + * @slot - Renders `igc-step` components inside the default slot. * - * @fires igcActiveStepChanging - Emitted when the active step is about to change. - * @fires igcActiveStepChanged - Emitted when the active step is changed. + * @fires igcActiveStepChanging - Emitted when the active step is about to change. Cancelable. + * @fires igcActiveStepChanged - Emitted after the active step has changed. + * + * @example + * ```html + * + * + * Step 1 + *

Step 1 content

+ *
+ * + * Step 2 + *

Step 2 content

+ *
+ *
+ * ``` + * + * @example Linear stepper with vertical orientation + * ```html + * + * + * Completed step + *

This step is already complete.

+ *
+ * + * Current step + *

Fill in the details to proceed.

+ *
+ * + * Next step + *

Upcoming content.

+ *
+ *
+ * ``` */ - export default class IgcStepperComponent extends EventEmitterMixin< IgcStepperComponentEventMap, Constructor >(LitElement) { public static readonly tagName = 'igc-stepper'; - protected static styles = styles; + public static styles = styles; /* blazorSuppress */ - public static register() { + public static register(): void { registerComponent(IgcStepperComponent, IgcStepComponent); } - // biome-ignore lint/complexity/noBannedTypes: No easy fix as the callback shapes are wildly different - private readonly keyDownHandlers: Map = new Map( - Object.entries({ - Enter: this.activateStep, - Space: this.activateStep, - SpaceBar: this.activateStep, - ' ': this.activateStep, - ArrowUp: this.onArrowUpKeyDown, - ArrowDown: this.onArrowDownKeyDown, - ArrowLeft: this.onArrowLeftKeyDown, - ArrowRight: this.onArrowRightKeyDown, - Home: this.onHomeKey, - End: this.onEndKey, - }) - ); + //#region Internal state and properties - private activeStep!: IgcStepComponent; - private _shouldUpdateLinearState = false; + private readonly _state = createStepperState(); + + private readonly _contextProvider = new ContextProvider(this, { + context: STEPPER_CONTEXT, + initialValue: { + stepper: this, + state: this._state, + }, + }); + + private readonly _internals = addInternalsController(this, { + initialARIA: { + role: 'tablist', + }, + }); + + private readonly _slots = addSlotController(this, { + slots: setSlots(), + onChange: this._handleSlotChange, + }); + + private get _isHorizontal(): boolean { + return this.orientation === 'horizontal'; + } + + //#endregion + + //#region Public attributes and properties /** Returns all of the stepper's steps. */ - @queryAssignedElements({ selector: 'igc-step' }) - public steps!: Array; + public get steps(): readonly IgcStepComponent[] { + return this._state.steps; + } - /** Gets/Sets the orientation of the stepper. + /** + * The orientation of the stepper. * - * @remarks - * Default value is `horizontal`. + * @attr orientation + * @default 'horizontal' */ @property({ reflect: true }) public orientation: StepperOrientation = 'horizontal'; - /** Get/Set the type of the steps. + /** + * The visual type of the steps. * - * @remarks - * Default value is `full`. + * @attr step-type + * @default 'full' */ @property({ reflect: true, attribute: 'step-type' }) public stepType: StepperStepType = 'full'; /** - * Get/Set whether the stepper is linear. + * Whether the stepper is linear. * * @remarks * If the stepper is in linear mode and if the active step is valid only then the user is able to move forward. + * + * @attr linear + * @default false */ - @property({ type: Boolean }) + @property({ type: Boolean, reflect: true }) public linear = false; /** - * Get/Set whether the content is displayed above the steps. + * Whether the content is displayed above the steps. * - * @remarks - * Default value is `false` and the content is below the steps. + * @attr content-top + * @default false */ - @property({ reflect: true, type: Boolean, attribute: 'content-top' }) + @property({ type: Boolean, reflect: true, attribute: 'content-top' }) public contentTop = false; /** * The animation type when in vertical mode. + * * @attr vertical-animation + * @default 'grow' */ @property({ attribute: 'vertical-animation' }) public verticalAnimation: StepperVerticalAnimation = 'grow'; /** * The animation type when in horizontal mode. + * * @attr horizontal-animation + * @default 'slide' */ @property({ attribute: 'horizontal-animation' }) public horizontalAnimation: HorizontalTransitionAnimation = 'slide'; /** - * The animation duration in either vertical or horizontal mode. + * The animation duration in either vertical or horizontal mode in milliseconds. + * * @attr animation-duration + * @default 320 */ - @property({ attribute: 'animation-duration', type: Number }) + @property({ type: Number, attribute: 'animation-duration' }) public animationDuration = 320; /** - * Get/Set the position of the steps title. + * The position of the steps title. * * @remarks - * The default value is auto. * When the stepper is horizontally orientated the title is positioned below the indicator. - * When the stepper is horizontally orientated the title is positioned on the right side of the indicator. + * When the stepper is vertically orientated the title is positioned on the right side of the indicator. + * + * @attr title-position + * @default 'auto' */ @property({ reflect: false, attribute: 'title-position' }) public titlePosition: StepperTitlePosition = 'auto'; - @watch('orientation', { waitUntilFirstUpdate: true }) - protected orientationChange(): void { - this.setAttribute('aria-orientation', this.orientation); - this.steps.forEach((step: IgcStepComponent) => { - step.orientation = this.orientation; - this.updateAnimation(step); - }); - } + //#endregion - @watch('stepType', { waitUntilFirstUpdate: true }) - protected stepTypeChange(): void { - this.steps.forEach((step: IgcStepComponent) => { - step.stepType = this.stepType; - }); - } + constructor() { + super(); - @watch('titlePosition', { waitUntilFirstUpdate: true }) - protected titlePositionChange(): void { - this.steps.forEach((step: IgcStepComponent) => { - step.titlePosition = this.titlePosition; - }); - } + addSafeEventListener(this, 'click', this._handleInteraction); - @watch('contentTop', { waitUntilFirstUpdate: true }) - protected contentTopChange(): void { - this.steps.forEach((step: IgcStepComponent) => { - step.contentTop = this.contentTop; + addThemingController(this, { + light: { bootstrap, fluent, indigo }, + dark: { bootstrap, fluent, indigo }, }); + + addKeybindings(this, { + skip: this._skipKeyboard, + }) + .set(arrowUp, this._handleArrowUp) + .set(arrowDown, this._handleArrowDown) + .set(arrowLeft, this._handleArrowLeft) + .set(arrowRight, this._handleArrowRight) + .set(homeKey, this._handleHomeKey) + .set(endKey, this._handleEndKey) + .setActivateHandler(this._handleInteraction); } - @watch('linear', { waitUntilFirstUpdate: true }) - protected linearChange(): void { - if (!this.activeStep) { - this._shouldUpdateLinearState = true; - return; + //#region Lifecycle hooks + + protected override update(properties: PropertyValues): void { + this._syncStepperAttributes(properties); + + if (properties.has('orientation')) { + this._internals.setARIA({ ariaOrientation: this.orientation }); } - this.steps.forEach((step: IgcStepComponent) => { - step.linearDisabled = this.linear; - if (step.index <= this.activeStep.index) { - step.visited = true; - } else { - step.visited = false; - } - }); - if (this.linear) { - this.updateStepsLinearDisabled(); + + if (properties.has('linear')) { + this._state.setVisitedState(this.linear); } - } - @watch('verticalAnimation', { waitUntilFirstUpdate: true }) - @watch('horizontalAnimation', { waitUntilFirstUpdate: true }) - protected animationTypeChange() { - this.steps.forEach((step: IgcStepComponent) => { - this.updateAnimation(step); - }); + super.update(properties); } - @watch('animationDuration', { waitUntilFirstUpdate: true }) - protected animationDurationChange() { - this.steps.forEach((step: IgcStepComponent) => { - step.animationDuration = this.animationDuration; - }); - } + //#endregion - constructor() { - super(); + //#region Keyboard navigation handlers - addThemingController(this, { - light: { bootstrap, fluent, indigo }, - dark: { bootstrap, fluent, indigo }, - }); + private _skipKeyboard(_: Element, event: KeyboardEvent): boolean { + return !findElementFromEventPath('[data-step-header]', event); + } - this.addEventListener('stepActiveChanged', (event: any) => { - event.stopPropagation(); - this.activateStep(event.target, event.detail); - }); + private _handleHomeKey(): void { + this._getStepHeader(first(this._state.accessibleSteps))?.focus(); + } - this.addEventListener('stepDisabledInvalidChanged', (event: any) => { - event.stopPropagation(); - if (this.linear) { - this.updateStepsLinearDisabled(); - } - }); + private _handleEndKey(): void { + this._getStepHeader(last(this._state.accessibleSteps))?.focus(); + } - this.addEventListener('stepCompleteChanged', (event: any) => { - event.stopPropagation(); - const nextStep = this.steps[event.target.index + 1]; - if (nextStep) { - nextStep.previousComplete = event.target.complete; - } - }); + private _handleArrowDown(): void { + if (!this._isHorizontal) { + const step = this._getActiveStepComponent(); - this.addEventListener('stepHeaderKeydown', (event: any) => { - event.stopPropagation(); - this.handleKeydown(event.detail.event, event.detail.focusedStep); - }); + if (step) { + this._getStepHeader(this._getNextStep(step))?.focus(); + } + } } - public override connectedCallback(): void { - super.connectedCallback(); - this.setAttribute('role', 'tablist'); - this.setAttribute('aria-orientation', this.orientation); - } + private _handleArrowUp(): void { + if (!this._isHorizontal) { + const step = this._getActiveStepComponent(); - private activateFirstStep() { - const firstEnabledStep = this.steps.find( - (s: IgcStepComponent) => !s.disabled - ); - if (firstEnabledStep) { - this.activateStep(firstEnabledStep, false); + if (step) { + this._getStepHeader(this._getPreviousStep(step))?.focus(); + } } } - private animateSteps( - nextStep: IgcStepComponent, - currentStep: IgcStepComponent - ) { - if (nextStep.index > currentStep.index) { - // Animate steps in ascending/next direction - currentStep.toggleAnimation('out'); - nextStep.toggleAnimation('in'); - } else { - // Animate steps in descending/previous direction - currentStep.toggleAnimation('in', 'reverse'); - nextStep.toggleAnimation('out', 'reverse'); + private _handleArrowLeft(): void { + const step = this._getActiveStepComponent(); + + if (step) { + const next = + isLTR(this) && this._isHorizontal + ? this._getPreviousStep(step) + : this._getNextStep(step); + + this._getStepHeader(next)?.focus(); } } - private async activateStep(step: IgcStepComponent, shouldEmit = true) { - if (step === this.activeStep) { - return; + private _handleArrowRight(): void { + const step = this._getActiveStepComponent(); + + if (step) { + const next = + isLTR(this) && this._isHorizontal + ? this._getNextStep(step) + : this._getPreviousStep(step); + + this._getStepHeader(next)?.focus(); } + } - if (shouldEmit) { - const args = { - detail: { - owner: this, - oldIndex: this.activeStep.index, - newIndex: step.index, - }, - cancelable: true, - }; + //#endregion - this.animateSteps(step, this.activeStep); + //#region Event handlers - const allowed = this.emitEvent('igcActiveStepChanging', args); + private _handleInteraction(event: Event): void { + const step = findElementFromEventPath( + IgcStepComponent.tagName, + event + ); - if (!allowed) { - return; - } - this.changeActiveStep(step); - this.emitEvent('igcActiveStepChanged', { - detail: { owner: this, index: step.index }, - }); - } else { - this.changeActiveStep(step); + if (step && this._state.isAccessible(step)) { + this._activateStep(step); } } - private changeActiveStep(step: IgcStepComponent) { - if (this.activeStep) { - this.activeStep.active = false; - } - step.active = true; - step.visited = true; - this.activeStep = step; + private _handleSlotChange(): void { + this._state.setSteps( + this._slots.getAssignedElements('[default]', { + selector: IgcStepComponent.tagName, + }) + ); + + this._state.stepsChanged(); + this.style.setProperty('--steps-count', this.steps.length.toString()); } - private moveToNextStep(next = true) { - let steps = this.steps; - let activeStepIndex = this.activeStep.index; - if (!next) { - steps = this.steps.reverse(); - activeStepIndex = steps.findIndex( - (step: IgcStepComponent) => step === this.activeStep - ); - } + //#endregion - const nextStep = steps.find( - (step: IgcStepComponent, i: number) => - i > activeStepIndex && step.isAccessible - ); + //#region Internal methods - if (nextStep) { - this.animateSteps(nextStep, this.activeStep); - this.activateStep(nextStep, false); + private _syncStepperAttributes(properties: PropertyValues): void { + if (STEPPER_SYNC_PROPERTIES.some((p) => properties.has(p))) { + this._contextProvider.updateObservers(); } } - private handleKeydown(event: KeyboardEvent, focusedStep: IgcStepComponent) { - const key = event.key.toLowerCase(); + private _animateSteps( + nextStep: IgcStepComponent, + currentStep: IgcStepComponent + ): void { + const steps = this._state.steps; - if (this.keyDownHandlers.has(event.key)) { - event.preventDefault(); - this.keyDownHandlers.get(event.key)?.call(this, focusedStep); - } - if (key === 'tab' && this.orientation === 'vertical') { - return; - } - if (key === 'tab' && this.activeStep.index !== focusedStep.index) { - this.activeStep.header.focus(); + if (steps.indexOf(nextStep) > steps.indexOf(currentStep)) { + // Animate steps in ascending/next direction + currentStep.toggleAnimation('out'); + nextStep.toggleAnimation('in'); + } else { + // Animate steps in descending/previous direction + currentStep.toggleAnimation('in', 'reverse'); + nextStep.toggleAnimation('out', 'reverse'); } } - private onHomeKey() { - this.steps - .filter((step: IgcStepComponent) => step.isAccessible)[0] - ?.header?.focus(); + private _emitChanging(args: IgcActiveStepChangingEventArgs): boolean { + return this.emitEvent('igcActiveStepChanging', { + detail: args, + cancelable: true, + }); } - private onEndKey() { - this.steps - .filter((step: IgcStepComponent) => step.isAccessible) - .pop() - ?.header?.focus(); + private _emitChanged(args: IgcActiveStepChangedEventArgs): void { + this.emitEvent('igcActiveStepChanged', { + detail: args, + }); } - private onArrowDownKeyDown(focusedStep: IgcStepComponent) { - if (this.orientation === 'horizontal') { + private _activateStep(step: IgcStepComponent, shouldEmit = true): void { + if (step === this._state.activeStep) { return; } - this.getNextStep(focusedStep)?.header?.focus(); - } - private onArrowUpKeyDown(focusedStep: IgcStepComponent) { - if (this.orientation === 'horizontal') { + if (!shouldEmit) { + this._state.changeActiveStep(step); return; } - this.getPreviousStep(focusedStep)?.header?.focus(); - } - private onArrowRightKeyDown(focusedStep: IgcStepComponent) { - if (!isLTR(this) && this.orientation === 'horizontal') { - this.getPreviousStep(focusedStep)?.header?.focus(); - } else { - this.getNextStep(focusedStep)?.header?.focus(); - } - } + const steps = this._state.steps; + const activeIndex = steps.indexOf(this._state.activeStep!); + const index = steps.indexOf(step); - private onArrowLeftKeyDown(focusedStep: IgcStepComponent) { - if (!isLTR(this) && this.orientation === 'horizontal') { - this.getNextStep(focusedStep)?.header?.focus(); - } else { - this.getPreviousStep(focusedStep)?.header?.focus(); + const args = { oldIndex: activeIndex, newIndex: index }; + + if (!this._emitChanging(args)) { + return; } - } - private getNextStep( - focusedStep: IgcStepComponent - ): IgcStepComponent | undefined { - if (focusedStep.index === this.steps.length - 1) { - return this.steps.find((step: IgcStepComponent) => step.isAccessible); + if (this._state.activeStep) { + this._animateSteps(step, this._state.activeStep); } - const nextAccessible = this.steps.find( - (step: IgcStepComponent, i: number) => - i > focusedStep.index && step.isAccessible - ); - return nextAccessible - ? nextAccessible - : this.steps.find((step: IgcStepComponent) => step.isAccessible); + this._state.changeActiveStep(step); + this._emitChanged({ index }); } - private getPreviousStep( - focusedStep: IgcStepComponent - ): IgcStepComponent | undefined { - if (focusedStep.index === 0) { - return this.steps - .filter((step: IgcStepComponent) => step.isAccessible) - .pop(); - } + private _moveToNextStep(next = true): void { + const step = this._state.getAdjacentStep(next); - let prevStep: IgcStepComponent | undefined; - for (let i = focusedStep.index - 1; i >= 0; i--) { - const step = this.steps[i]; - if (step.isAccessible) { - prevStep = step; - break; + if (step) { + if (this._state.activeStep) { + this._animateSteps(step, this._state.activeStep); } + this._state.changeActiveStep(step); } - - return prevStep - ? prevStep - : this.steps.filter((step: IgcStepComponent) => step.isAccessible).pop(); } - private updateStepsLinearDisabled(): void { - const firstInvalidStep = this.steps - .filter((step: IgcStepComponent) => !step.disabled && !step.optional) - .find((step: IgcStepComponent) => step.invalid); - if (firstInvalidStep) { - this.steps.forEach((step: IgcStepComponent) => { - if (step.index <= firstInvalidStep.index) { - step.linearDisabled = false; - } else { - step.linearDisabled = true; - } - }); - } else { - this.steps.forEach((step: IgcStepComponent) => { - step.linearDisabled = false; - }); - } + private _getNextStep(step: IgcStepComponent): IgcStepComponent { + const steps = this._state.accessibleSteps; + const next = wrap(0, steps.length - 1, steps.indexOf(step) + 1); + + return steps[next]; } - private updateAnimation(step: IgcStepComponent) { - if (this.orientation === 'horizontal') { - step.animation = this.horizontalAnimation; - } + private _getPreviousStep(step: IgcStepComponent): IgcStepComponent { + const steps = this._state.accessibleSteps; + const previous = wrap(0, steps.length - 1, steps.indexOf(step) - 1); - if (this.orientation === 'vertical') { - step.animation = this.verticalAnimation; - } + return steps[previous]; } - private syncProperties(): void { - this.steps.forEach((step: IgcStepComponent, index: number) => { - step.orientation = this.orientation; - step.stepType = this.stepType; - step.titlePosition = this.titlePosition; - step.contentTop = this.contentTop; - step.index = index; - step.active = this.activeStep === step; - step.header?.setAttribute('aria-posinset', (index + 1).toString()); - step.header?.setAttribute('aria-setsize', this.steps.length.toString()); - step.header?.setAttribute('id', `igc-step-header-${index}`); - step.header?.setAttribute('aria-controls', `igc-step-content-${index}`); - if (index > 0) { - step.previousComplete = this.steps[index - 1].complete; - } - step.animationDuration = this.animationDuration; - this.updateAnimation(step); - }); + private _getActiveStepComponent(): IgcStepComponent | null { + const active = getRoot(this).activeElement; + return active ? active.closest(IgcStepComponent.tagName) : null; } - private stepsChanged(): void { - this.style.setProperty('--steps-count', this.steps.length.toString()); + private _getStepHeader(step?: IgcStepComponent): HTMLElement | null { + return step?.renderRoot.querySelector('[data-step-header]') ?? null; + } - const lastActiveStep = this.steps - .reverse() - .find((step: IgcStepComponent) => step.active); - if (!lastActiveStep) { - // initially when there isn't a predefined active step or when the active step is removed - this.activateFirstStep(); - } else { - // activate the last step marked as active - this.activateStep(lastActiveStep, false); - } + //#endregion - this.syncProperties(); - if (this._shouldUpdateLinearState) { - this.linearChange(); - this._shouldUpdateLinearState = false; - } - if (this.linear) { - this.updateStepsLinearDisabled(); - } - } + //#region Public API /** Activates the step at a given index. */ - public navigateTo(index: number) { - const step = this.steps[index]; - if (!step) { - return; + public navigateTo(index: number): void { + const step = this._state.steps[index]; + + if (step) { + this._activateStep(step, false); } - this.activateStep(step, false); } /** Activates the next enabled step. */ public next(): void { - this.moveToNextStep(); + this._moveToNextStep(); } /** Activates the previous enabled step. */ public prev(): void { - this.moveToNextStep(false); + this._moveToNextStep(false); } /** @@ -522,14 +492,13 @@ export default class IgcStepperComponent extends EventEmitterMixin< * The steps' content will not be automatically reset. */ public reset(): void { - this.steps.forEach((step) => { - step.visited = false; - }); - this.activateFirstStep(); + this._state.reset(); } + //#endregion + protected override render() { - return html``; + return html``; } } diff --git a/src/index.ts b/src/index.ts index f540feac9..1206966d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,7 +145,7 @@ export type { IgcSelectComponentEventMap } from './components/select/select.js'; export type { IgcSliderComponentEventMap } from './components/slider/slider.js'; export type { IgcRangeSliderComponentEventMap } from './components/slider/range-slider.js'; export type { IgcSnackbarComponentEventMap } from './components/snackbar/snackbar.js'; -export type { IgcStepperComponentEventMap } from './components/stepper/stepper.common.js'; +export type { IgcStepperComponentEventMap } from './components/stepper/common/types.js'; export type { IgcTabsComponentEventMap } from './components/tabs/tabs.js'; export type { IgcTextareaComponentEventMap } from './components/textarea/textarea.js'; export type { IgcTileComponentEventMap } from './components/tile-manager/tile.js'; @@ -176,7 +176,7 @@ export type { IgcRangeSliderValueEventArgs } from './components/slider/range-sli export type { IgcActiveStepChangingEventArgs, IgcActiveStepChangedEventArgs, -} from './components/stepper/stepper.common.js'; +} from './components/stepper/common/types.js'; export type { IgcTreeSelectionEventArgs } from './components/tree/tree.common.js'; export type { ComboItemTemplate, diff --git a/stories/stepper.stories.ts b/stories/stepper.stories.ts index 3e85cd4f9..4314b9f57 100644 --- a/stories/stepper.stories.ts +++ b/stories/stepper.stories.ts @@ -8,6 +8,7 @@ import { IgcStepperComponent, defineComponents, } from 'igniteui-webcomponents'; +import { disableStoryControls } from './story.js'; defineComponents(IgcStepperComponent, IgcButtonComponent, IgcInputComponent); @@ -19,7 +20,7 @@ const metadata: Meta = { docs: { description: { component: - 'IgxStepper provides a wizard-like workflow by dividing content into logical steps.', + 'A stepper component that provides a wizard-like workflow by dividing content into logical steps.', }, }, actions: { handles: ['igcActiveStepChanging', 'igcActiveStepChanged'] }, @@ -27,27 +28,27 @@ const metadata: Meta = { argTypes: { orientation: { type: '"horizontal" | "vertical"', - description: 'Gets/Sets the orientation of the stepper.', + description: 'The orientation of the stepper.', options: ['horizontal', 'vertical'], control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'horizontal' } }, }, stepType: { type: '"full" | "indicator" | "title"', - description: 'Get/Set the type of the steps.', + description: 'The visual type of the steps.', options: ['full', 'indicator', 'title'], control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'full' } }, }, linear: { type: 'boolean', - description: 'Get/Set whether the stepper is linear.', + description: 'Whether the stepper is linear.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, contentTop: { type: 'boolean', - description: 'Get/Set whether the content is displayed above the steps.', + description: 'Whether the content is displayed above the steps.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -68,13 +69,13 @@ const metadata: Meta = { animationDuration: { type: 'number', description: - 'The animation duration in either vertical or horizontal mode.', + 'The animation duration in either vertical or horizontal mode in milliseconds.', control: 'number', table: { defaultValue: { summary: '320' } }, }, titlePosition: { type: '"auto" | "bottom" | "top" | "end" | "start"', - description: 'Get/Set the position of the steps title.', + description: 'The position of the steps title.', options: ['auto', 'bottom', 'top', 'end', 'start'], control: { type: 'select' }, table: { defaultValue: { summary: 'auto' } }, @@ -95,126 +96,332 @@ const metadata: Meta = { export default metadata; interface IgcStepperArgs { - /** Gets/Sets the orientation of the stepper. */ + /** The orientation of the stepper. */ orientation: 'horizontal' | 'vertical'; - /** Get/Set the type of the steps. */ + /** The visual type of the steps. */ stepType: 'full' | 'indicator' | 'title'; - /** Get/Set whether the stepper is linear. */ + /** Whether the stepper is linear. */ linear: boolean; - /** Get/Set whether the content is displayed above the steps. */ + /** Whether the content is displayed above the steps. */ contentTop: boolean; /** The animation type when in vertical mode. */ verticalAnimation: 'grow' | 'fade' | 'none'; /** The animation type when in horizontal mode. */ horizontalAnimation: 'slide' | 'fade' | 'none'; - /** The animation duration in either vertical or horizontal mode. */ + /** The animation duration in either vertical or horizontal mode in milliseconds. */ animationDuration: number; - /** Get/Set the position of the steps title. */ + /** The position of the steps title. */ titlePosition: 'auto' | 'bottom' | 'top' | 'end' | 'start'; } type Story = StoryObj; // endregion -const BasicTemplate = ({ - orientation, - stepType, - titlePosition, - linear, - contentTop, - animationDuration, - verticalAnimation, - horizontalAnimation, -}: IgcStepperArgs) => { - document.addEventListener('DOMContentLoaded', () => { - const stepper = document.getElementById('stepper') as IgcStepperComponent; - - stepper.addEventListener('igcInput', () => { - checkValidity(); - }); - stepper.addEventListener('igcChange', () => { - checkValidity(); - }); - }); - - function checkValidity() { - const stepper = document.getElementById('stepper') as IgcStepperComponent; - const activeStep = stepper.steps.find( - (step) => step.active - ) as IgcStepComponent; - const form = activeStep!.querySelector('form') as HTMLFormElement; - const isFormInvalid = !form.checkValidity(); - - if (activeStep!.optional) { - return; - } - - if (stepper.linear) { - activeStep!.invalid = isFormInvalid; - } - } - return html` +export const Basic: Story = { + render: (args) => html` - - Step1 - Required -
- -
-
- - - Step 2 - Required -
- -
+ + Personal Info + Enter your name +

Please provide your personal information to get started.

+
+ + + Address + Where do you live? +

Enter your shipping address for delivery.

+
+ + + Payment + Billing details +

Add your preferred payment method.

+
+ + + Confirmation + Review your order +

Please review all information before submitting.

+
+
+ `, +}; + +export const VerticalOrientation: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + + Account Setup + Create your login credentials +

Choose a username and a strong password for your account.

+
+ + + Profile Details + Tell us about yourself +

Fill in your profile details so others can learn more about you.

+
+ + + Preferences + Customize your experience +

Select your notification preferences and display settings.

+
+ + + Finish + You're all set! +

Your account is ready. Click finish to start using the app.

+
+
+ `, +}; + +export const StepStates: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + + Completed + This step is done +

This step has been completed successfully.

+
+ + + Invalid + Has validation errors +

This step has validation errors that need to be resolved.

- Step 3 - (Optional) - Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur - soluta nulla asperiores, officia ullam recusandae voluptatem omnis - perferendis vitae non magni magnam praesentium placeat nemo quas - repudiandae. Nisi, quo ex! + Optional + Can be skipped +

This step is optional and can be skipped.

- Step 4 - (disabled) -
- Tabbable content -
- Lorem ipsum dolor sit amet consectetur adipisicing elit. Consequatur - soluta nulla asperiores, officia ullam recusandae voluptatem omnis - perferendis vitae non magni magnam praesentium placeat nemo quas - repudiandae. Nisi, quo ex! -
-
+ Disabled + Not accessible +

This step is disabled and cannot be interacted with.

+
+ + + Default + Normal step +

A regular step with no special state.

+
+
+ `, +}; + +export const LinearMode: Story = { + argTypes: disableStoryControls(metadata), + render: () => { + function checkValidity(event: Event) { + const stepper = event.currentTarget as IgcStepperComponent; + const activeStep = stepper.steps.find( + (step) => step.active + ) as IgcStepComponent; + const form = activeStep?.querySelector('form'); + + if (!form || activeStep?.optional) { + return; + } + + activeStep.invalid = !form.checkValidity(); + } + + return html` + + + First Name + Required +
+ +

Enter your first name to proceed

+
+
+
+ + + Last Name + Required +
+ +

Enter your last name to proceed

+
+
+
+ + + Nickname + Optional +
+ +
+
+ + + Done + Review +

All steps completed. Review your information.

+
+
+ `; + }, +}; + +export const ContentTop: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + + Step 1 +

Content displayed above the step headers.

+
+ + + Step 2 +

The content area appears at the top of the stepper.

+
+ + + Step 3 +

+ This layout is useful when the headers should anchor to the bottom. +

+
+
+ `, +}; + +export const StepTypes: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +

Full (indicator + title)

+ + + Step 1 + Subtitle +

Full step type shows both indicator and title.

+
+ + Step 2 + Subtitle +

This is the active step.

+
+ + Step 3 + Subtitle +

Upcoming step.

+
+
+ +

Indicator only

+ + + Step 1 +

Only the step indicator is visible in the header.

+
+ + Step 2 +

This is the active step.

+
+ + Step 3 +

Upcoming step.

+
+
+ +

Title only

+ + + Step 1 +

Only the step title is visible in the header.

+
+ + Step 2 +

This is the active step.

+
+ + Step 3 +

Upcoming step.

- `; + `, }; -export const Basic: Story = BasicTemplate.bind({}); +export const ProgrammaticNavigation: Story = { + argTypes: disableStoryControls(metadata), + render: () => { + function navigate(action: 'prev' | 'next' | 'reset') { + const stepper = document.querySelector( + '#programmatic-stepper' + ) as IgcStepperComponent; + + switch (action) { + case 'prev': + stepper.prev(); + break; + case 'next': + stepper.next(); + break; + case 'reset': + stepper.reset(); + break; + } + } + + return html` +
+ navigate('prev')}>Previous + navigate('next')}>Next + navigate('reset')}>Reset +
+ + + + Step 1 +

+ Use the buttons above to navigate between steps programmatically. +

+
+ + + Step 2 +

+ The next() and prev() methods activate + adjacent steps. +

+
+ + + Step 3 +

The reset() method returns to the first step.

+
+ + + Step 4 +

+ This is the last step. Press "Next" to cycle back or "Reset" to + start over. +

+
+
+ `; + }, +};