From 42537e134ba61df9c905f99c5c3dfd881f6a77ca Mon Sep 17 00:00:00 2001 From: leagrdv Date: Thu, 23 Oct 2025 08:48:26 +0200 Subject: [PATCH 1/6] feat(components): `post-stepper` and `post-stepper-item` components --- .changeset/empty-coins-dig.md | 7 + .../src/app/routes/home/home.component.html | 15 + .../src/app/routes/home/home.component.ts | 7 +- packages/components/cypress/e2e/stepper.cy.ts | 188 +++++++++ .../cypress/fixtures/post-stepper.test.html | 22 + packages/components/src/components.d.ts | 60 +++ .../post-stepper-item/post-stepper-item.scss | 300 ++++++++++++++ .../post-stepper-item/post-stepper-item.tsx | 25 ++ .../components/post-stepper-item/readme.md | 10 + .../components/post-stepper/post-stepper.scss | 20 + .../components/post-stepper/post-stepper.tsx | 122 ++++++ .../src/components/post-stepper/readme.md | 20 + .../components/stepper/stepper.docs.mdx | 37 +- .../stepper/stepper.snapshot.stories.ts | 12 +- .../components/stepper/stepper.stories.ts | 160 +++----- .../migrationv9-10.component.ts | 17 + .../src/stories/misc/migration-guide/types.ts | 1 + .../nextjs-integration/src/app/ssr/page.tsx | 15 + packages/styles/src/components/_index.scss | 1 - packages/styles/src/components/stepper.scss | 377 ------------------ .../src/variables/components/_stepper.scss | 42 -- 21 files changed, 887 insertions(+), 571 deletions(-) create mode 100644 .changeset/empty-coins-dig.md create mode 100644 packages/components/cypress/e2e/stepper.cy.ts create mode 100644 packages/components/cypress/fixtures/post-stepper.test.html create mode 100644 packages/components/src/components/post-stepper-item/post-stepper-item.scss create mode 100644 packages/components/src/components/post-stepper-item/post-stepper-item.tsx create mode 100644 packages/components/src/components/post-stepper-item/readme.md create mode 100644 packages/components/src/components/post-stepper/post-stepper.scss create mode 100644 packages/components/src/components/post-stepper/post-stepper.tsx create mode 100644 packages/components/src/components/post-stepper/readme.md delete mode 100644 packages/styles/src/components/stepper.scss delete mode 100644 packages/styles/src/variables/components/_stepper.scss diff --git a/.changeset/empty-coins-dig.md b/.changeset/empty-coins-dig.md new file mode 100644 index 0000000000..bb8bcdae36 --- /dev/null +++ b/.changeset/empty-coins-dig.md @@ -0,0 +1,7 @@ +--- +'@swisspost/design-system-styles': major +'@swisspost/design-system-components': minor +'@swisspost/design-system-documentation': patch +--- + +Removed the HTML stepper component and replaced it with new `post-stepper` and `post-stepper-item` web components. diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html index fda23b2e7a..1d1b3b80f4 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.html @@ -104,3 +104,18 @@

Post Tooltip

+ +
+

Post Stepper

+ + Step 1 + Step 2 + Step 3 + Step 4 + +
diff --git a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts index a59c219b13..e9b0397b26 100644 --- a/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts +++ b/packages/components-angular/projects/consumer-app/src/app/routes/home/home.component.ts @@ -16,6 +16,8 @@ import { PostTabHeader, PostTabPanel, PostTooltipTrigger, + PostStepper, + PostStepperItem, } from 'components'; @Component({ @@ -39,9 +41,10 @@ import { PostTabHeader, PostTabPanel, PostTooltipTrigger, - ] + PostStepper, + PostStepperItem, + ], }) - export class HomeComponent { isCollapsed = false; } diff --git a/packages/components/cypress/e2e/stepper.cy.ts b/packages/components/cypress/e2e/stepper.cy.ts new file mode 100644 index 0000000000..44576c6681 --- /dev/null +++ b/packages/components/cypress/e2e/stepper.cy.ts @@ -0,0 +1,188 @@ +describe('stepper', { baseUrl: null }, () => { + beforeEach(() => { + cy.visit('./cypress/fixtures/post-stepper.test.html'); + }); + + afterEach(() => { + cy.reload(); + }); + + it('should render the post-stepper component', () => { + cy.get('post-stepper').should('exist'); + }); + + // Errors + + it('should log an error if the current label is missing', () => { + cy.window().then(win => { + cy.spy(win.console, 'error').as('consoleError'); + }); + cy.get('post-stepper').invoke('attr', 'current-label', null); + cy.get('@consoleError').should('be.called'); + }); + + it('should log an error if the completed label is missing', () => { + cy.window().then(win => { + cy.spy(win.console, 'error').as('consoleError'); + }); + cy.get('post-stepper').invoke('attr', 'completed-label', null); + cy.get('@consoleError').should('be.called'); + }); + + it('should log an error if the mobile step label is missing', () => { + cy.window().then(win => { + cy.spy(win.console, 'error').as('consoleError'); + }); + cy.get('post-stepper').invoke('attr', 'step-label', null); + cy.get('@consoleError').should('be.called'); + }); + + // Dynamic classes + + it('should set all inactive classes when current-index is negative', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', -1) + .wait(100) + .find('post-stepper-item') + .then($items => { + expect($items.filter('.stepper-item-inactive').length).to.equal(5); + }); + }); + + it('should set first step as active when current-index is 0', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 0) + .wait(100) + .find('post-stepper-item') + .then($items => { + expect($items[0]).to.have.class('stepper-item-current'); + expect($items.filter('.stepper-item-inactive').length).to.equal(4); + }); + }); + + it('should set third step as active when current-index is 2', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 2) + .wait(100) + .find('post-stepper-item') + .then($items => { + expect($items[2]).to.have.class('stepper-item-current'); + expect($items.filter('.stepper-item-completed').length).to.equal(2); + expect($items.filter('.stepper-item-inactive').length).to.equal(2); + }); + }); + + it('should set all steps as complete but the last when current-index is last element', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 4) + .wait(100) + .find('post-stepper-item') + .then($items => { + expect($items[4]).to.have.class('stepper-item-current'); + expect($items.filter('.stepper-item-completed').length).to.equal(4); + expect($items.filter('.stepper-item-inactive').length).to.equal(0); + }); + }); + + it('should set all steps as complete when current-index is bigger than the steps length', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 5) + .wait(100) + .find('post-stepper-item') + .then($items => { + expect($items.filter('.stepper-item-current').length).to.equal(0); + expect($items.filter('.stepper-item-completed').length).to.equal(5); + expect($items.filter('.stepper-item-inactive').length).to.equal(0); + }); + }); + + // Accessibility and labels + + it('should set aria-current="step" on the current step', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 1) + .wait(100) + .find('post-stepper-item') + .eq(1) + .should('have.attr', 'aria-current', 'step'); + }); + + it('should set aria-live="polite" on the current step', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 1) + .wait(100) + .find('post-stepper-item') + .eq(1) + .should('have.attr', 'aria-live', 'polite'); + }); + + it('should not have aria-live="polite" on future step', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 1) + .wait(100) + .find('post-stepper-item') + .eq(2) + .should('not.have.attr', 'aria-live'); + }); + + it('should set current label on the current step', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 1) + .wait(100) + .find('post-stepper-item') + .eq(1) + .find('.step-hidden-label') + .should('have.text', 'Current step:'); + }); + + it('should set completed label on completed step', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 1) + .wait(100) + .find('post-stepper-item') + .eq(0) + .find('.step-hidden-label') + .should('have.text', 'Completed step:'); + }); + + it('should set correct mobile label on current step', () => { + cy.get('post-stepper') + .invoke('attr', 'current-index', 1) + .wait(100) + .find('post-stepper-item') + .eq(1) + .find('.step-mobile-label') + .should('have.text', 'Step 2:'); + }); + + // Dynamically added/removed steps + + it('should add correct class when a new step is added dynamically', () => { + cy.get('post-stepper').find('post-stepper-item').should('have.length', 5); + cy.get('post-stepper').then($stepper => { + $stepper[0].appendChild(document.createElement('post-stepper-item')); + cy.get('post-stepper') + .wait(100) + .find('post-stepper-item') + .should('have.length', 6) + .last() + .should('have.class', 'stepper-item-inactive'); + }); + }); + + it('should throw an error if there is only one step', () => { + cy.window().then(win => { + cy.spy(win.console, 'error').as('consoleError'); + }); + cy.get('post-stepper').find('post-stepper-item').should('have.length', 5); + + cy.get('post-stepper').then($stepper => { + const stepper = $stepper[0]; + const allButFirstStep = stepper.querySelectorAll('post-stepper-item:not(:first-child)'); + allButFirstStep.forEach(step => step.remove()); + }); + + cy.get('post-stepper').find('post-stepper-item').should('have.length', 1); + cy.get('@consoleError').should('be.called'); + }); +}); diff --git a/packages/components/cypress/fixtures/post-stepper.test.html b/packages/components/cypress/fixtures/post-stepper.test.html new file mode 100644 index 0000000000..bdeb7afa53 --- /dev/null +++ b/packages/components/cypress/fixtures/post-stepper.test.html @@ -0,0 +1,22 @@ + + + + + + Document + + + + + + Step 1 + Step 2 + Step 3 + Step 4 + Step 5 + + + diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index 451fbe8d48..9908421d3b 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -461,6 +461,27 @@ export namespace Components { */ "stars": number; } + interface PostStepper { + /** + * "Completed step" label for accessibility + */ + "completedLabel": string; + /** + * Defines the currently active step + * @default -1 + */ + "currentIndex": number; + /** + * "Current step" label for accessibility + */ + "currentLabel": string; + /** + * "Step" label for mobile view + */ + "stepLabel": string; + } + interface PostStepperItem { + } interface PostTabHeader { /** * The name of the panel controlled by the tab header. @@ -851,6 +872,18 @@ declare global { prototype: HTMLPostRatingElement; new (): HTMLPostRatingElement; }; + interface HTMLPostStepperElement extends Components.PostStepper, HTMLStencilElement { + } + var HTMLPostStepperElement: { + prototype: HTMLPostStepperElement; + new (): HTMLPostStepperElement; + }; + interface HTMLPostStepperItemElement extends Components.PostStepperItem, HTMLStencilElement { + } + var HTMLPostStepperItemElement: { + prototype: HTMLPostStepperItemElement; + new (): HTMLPostStepperItemElement; + }; interface HTMLPostTabHeaderElement extends Components.PostTabHeader, HTMLStencilElement { } var HTMLPostTabHeaderElement: { @@ -928,6 +961,8 @@ declare global { "post-popover": HTMLPostPopoverElement; "post-popovercontainer": HTMLPostPopovercontainerElement; "post-rating": HTMLPostRatingElement; + "post-stepper": HTMLPostStepperElement; + "post-stepper-item": HTMLPostStepperItemElement; "post-tab-header": HTMLPostTabHeaderElement; "post-tab-panel": HTMLPostTabPanelElement; "post-tabs": HTMLPostTabsElement; @@ -1323,6 +1358,27 @@ declare namespace LocalJSX { */ "stars"?: number; } + interface PostStepper { + /** + * "Completed step" label for accessibility + */ + "completedLabel": string; + /** + * Defines the currently active step + * @default -1 + */ + "currentIndex"?: number; + /** + * "Current step" label for accessibility + */ + "currentLabel": string; + /** + * "Step" label for mobile view + */ + "stepLabel": string; + } + interface PostStepperItem { + } interface PostTabHeader { /** * The name of the panel controlled by the tab header. @@ -1419,6 +1475,8 @@ declare namespace LocalJSX { "post-popover": PostPopover; "post-popovercontainer": PostPopovercontainer; "post-rating": PostRating; + "post-stepper": PostStepper; + "post-stepper-item": PostStepperItem; "post-tab-header": PostTabHeader; "post-tab-panel": PostTabPanel; "post-tabs": PostTabs; @@ -1466,6 +1524,8 @@ declare module "@stencil/core" { "post-popover": LocalJSX.PostPopover & JSXBase.HTMLAttributes; "post-popovercontainer": LocalJSX.PostPopovercontainer & JSXBase.HTMLAttributes; "post-rating": LocalJSX.PostRating & JSXBase.HTMLAttributes; + "post-stepper": LocalJSX.PostStepper & JSXBase.HTMLAttributes; + "post-stepper-item": LocalJSX.PostStepperItem & JSXBase.HTMLAttributes; "post-tab-header": LocalJSX.PostTabHeader & JSXBase.HTMLAttributes; "post-tab-panel": LocalJSX.PostTabPanel & JSXBase.HTMLAttributes; "post-tabs": LocalJSX.PostTabs & JSXBase.HTMLAttributes; diff --git a/packages/components/src/components/post-stepper-item/post-stepper-item.scss b/packages/components/src/components/post-stepper-item/post-stepper-item.scss new file mode 100644 index 0000000000..2ae14e1620 --- /dev/null +++ b/packages/components/src/components/post-stepper-item/post-stepper-item.scss @@ -0,0 +1,300 @@ +@use '@swisspost/design-system-styles/mixins/icons'; +@use '@swisspost/design-system-styles/mixins/utilities'; +@use '@swisspost/design-system-styles/mixins/media'; +@use '@swisspost/design-system-styles/functions/tokens'; +@use '@swisspost/design-system-styles/tokens/components'; +@use '@swisspost/design-system-styles/tokens/elements'; + +tokens.$default-map: components.$post-stepper; + +post-stepper-item { + grid-row: 1; + position: relative; + padding-inline-start: 0; + + &:not(:first-child) { + padding-inline-start: calc(#{tokens.get('stepper-indicator')} / 2); + } + + &:not(:last-child) { + padding-inline-end: calc(#{tokens.get('stepper-indicator')} / 2); + grid-column: span 2; + } + + // progress bar + &::before, + &::after { + content: ''; + display: block; + position: absolute; + inset-block-start: calc((#{tokens.get('stepper-indicator')} - 2px) / 2); + height: 2px; + background-color: tokens.get('stepper-connector-next'); + inset-inline: 0; + } + + &:not(:first-child, :last-child) { + &::before { + inset-inline-end: 50%; + } + + &::after { + inset-inline-start: 50%; + } + } + + &.stepper-item-current:not(:last-child)::after, + &.stepper-item-current:first-child::before, + &.stepper-item-current + *:last-child::after, + &.stepper-item-current + *::before { + background-color: tokens.get('stepper-connector-active'); + } + + &.stepper-item-current:not(:first-child)::before, + &.stepper-item-current:last-child::after, + &.stepper-item-completed::after, + &.stepper-item-completed::before, + &.stepper-item-completed + *::before { + background-color: tokens.get('stepper-connector-completed'); + z-index: 1; + } +} + +.stepper-item-content { + // stylelint-disable-next-line value-no-vendor-prefix + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + position: relative; + z-index: 2; + overflow: hidden; + text-decoration: none; + color: tokens.get('stepper-text-disabled-fg'); + width: fit-content; + word-break: break-word; + font-size: tokens.get('stepper-step-label-font-size'); + + post-stepper-item:not(:first-child, :last-child) > & { + margin-inline: auto; + text-align: center; + } + + post-stepper-item:last-child > & { + margin-inline-start: auto; + margin-inline-end: calc(40px / -2); // negative margin matching the container padding + text-align: end; + } + + // Completed steps + .stepper-item-completed > & { + color: tokens.get('stepper-text-enabled-fg'); + } + + // Current step + .stepper-item-current > & { + font-weight: tokens.get('stepper-step-label-selected-font-weight'); + color: tokens.get('stepper-text-enabled-fg'); + + &::before { + font-weight: tokens.get('body-font-weight', elements.$post-body); + color: tokens.get('stepper-enabled-fg'); + } + } +} + +// step indicator +.stepper-item-content::before { + counter-increment: step-index; + content: counter(step-index); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + height: tokens.get('stepper-indicator'); + width: tokens.get('stepper-indicator'); + box-sizing: border-box; + margin-block-end: tokens.get('stepper-gap-text-start'); + color: tokens.get('stepper-enabled-fg'); + background-color: tokens.get('stepper-enabled-bg'); + border: tokens.get('stepper-border-width') solid tokens.get('stepper-enabled-stroke'); + border-radius: tokens.get('stepper-border-radius-round'); + text-indent: initial; + + post-stepper-item:not(:first-child, :last-child) > & { + margin-inline: auto; + } + + post-stepper-item:last-child > & { + margin-inline-start: auto; + } + + // Completed steps + .stepper-item-completed > & { + color: transparent; // Hides the number on completed step + background-color: tokens.get('stepper-completed-bg'); + border-color: tokens.get('stepper-completed-stroke'); + } + + // Future steps + .stepper-item-inactive > & { + color: tokens.get('stepper-disabled-fg'); + background-color: tokens.get('stepper-disabled-bg'); + border-color: tokens.get('stepper-disabled-stroke'); + } +} + +// check icon +.stepper-item-content::after { + display: block; + position: absolute; + top: calc((#{tokens.get('stepper-indicator')} - 24px) / 2); + z-index: 1; + height: 24px; + width: 24px; + color: tokens.get('stepper-enabled-fg'); + + post-stepper-item:first-child > & { + left: calc((#{tokens.get('stepper-indicator')} - 24px) / 2); + } + + post-stepper-item:not(:first-child, :last-child) > & { + left: calc(50% - (24px / 2)); + } + + post-stepper-item:last-child > & { + right: calc((#{tokens.get('stepper-indicator')} - 24px) / 2); + } + + // show only for completed steps + .stepper-item-completed > & { + content: ''; + } + + @include icons.post-icon( + $name: 'checkmark', + $color: tokens.get('stepper-enabled-fg'), + $width: 1.5rem, + $height: 1.5rem + ); +} + +@include media.min(md) { + .step-mobile-label { + display: none; + } +} + +// smaller screens +@include media.max(md) { + post-stepper-item:first-child, + post-stepper-item:last-child { + padding-inline: 0; + } + + .stepper-item-current { + // using "display: contents" on the stepper-item and stepper-item-content so that label, indicator and progress bar can be directly placed in the grid + display: contents; + + > .stepper-item-content { + display: contents; + } + + // progress bar + &::before { + grid-row: -1; + margin-block-start: calc((#{tokens.get('stepper-indicator')} - 2px) / 2); + position: static; + } + + &:not(:last-child) > .stepper-item-content::before { + grid-row: -1; + } + + &:not(:first-child, :last-child)::after { + inset-inline-start: 0; + } + } + + .stepper-item-inactive, + .stepper-item-completed { + grid-row: -1; + justify-self: stretch; + + // hide completed and future step labels + > .stepper-item-content { + -webkit-line-clamp: initial; + line-height: 0; + text-indent: 100%; + } + } + + .stepper-item-content { + white-space: nowrap; + width: 100%; + } + + // step indicator + .stepper-item-content::before { + .stepper-item-current:first-child > & { + order: -1; + } + + .stepper-item-current:not(:first-child, :last-child) > & { + margin-inline-start: 0; + transform: translateX(-50%); + } + + .stepper-item-current:last-child > & { + position: absolute; + z-index: 2; + inset-block-start: 0; + inset-inline-end: 0; + } + } +} + +@include utilities.high-contrast-mode { + post-stepper-item { + &::before, + &::after { + background-color: CanvasText !important; + } + + &.stepper-item-current:not(:first-child)::before, + &.stepper-item-current:last-child::after, + &.stepper-item-completed::after, + &.stepper-item-completed::before, + &.stepper-item-completed + *::before { + background-color: Highlight !important; + } + } + + .stepper-item-content { + &::before { + forced-color-adjust: none; + } + + .stepper-item-current > &::before { + background-color: Canvas; + color: CanvasText; + } + + .stepper-item-completed > &::before, + .stepper-item-inactive > &::before { + border-color: Canvas; + background-color: CanvasText; + } + + .stepper-item-inactive > &::before { + color: Canvas; + } + + &::after { + color: Canvas; + } + } +} + +.visually-hidden { + @include utilities.visuallyhidden(); +} diff --git a/packages/components/src/components/post-stepper-item/post-stepper-item.tsx b/packages/components/src/components/post-stepper-item/post-stepper-item.tsx new file mode 100644 index 0000000000..6cc73a311e --- /dev/null +++ b/packages/components/src/components/post-stepper-item/post-stepper-item.tsx @@ -0,0 +1,25 @@ +import { Component, Element, h, Host } from '@stencil/core'; +import { version } from '@root/package.json'; + +@Component({ + tag: 'post-stepper-item', + styleUrl: 'post-stepper-item.scss', + shadow: false, +}) +export class PostStepperItem { + @Element() host: HTMLPostStepperItemElement; + + render() { + return ( + + + + + + + + + + ); + } +} diff --git a/packages/components/src/components/post-stepper-item/readme.md b/packages/components/src/components/post-stepper-item/readme.md new file mode 100644 index 0000000000..24cb0f8522 --- /dev/null +++ b/packages/components/src/components/post-stepper-item/readme.md @@ -0,0 +1,10 @@ +# post-stepper + + + + + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/components/src/components/post-stepper/post-stepper.scss b/packages/components/src/components/post-stepper/post-stepper.scss new file mode 100644 index 0000000000..275a01258f --- /dev/null +++ b/packages/components/src/components/post-stepper/post-stepper.scss @@ -0,0 +1,20 @@ +@use '@swisspost/design-system-styles/mixins/utilities'; + +ol { + @include utilities.reset-list; + // start a counter for the step numbers + counter-reset: step-index; + + display: grid; + position: relative; + overflow: hidden; + + // the first column is half a step wide to make sure the separators are the same size even on small screens + grid-template-columns: calc(40px / 2); + + // all other columns are 1 fraction of the available space ase we don't know the number of steps + grid-auto-columns: minmax(0, 1fr); + + // we use a padding and negative margin on the last step for the same reason we need the first column + padding-inline-end: calc(40px / 2); +} diff --git a/packages/components/src/components/post-stepper/post-stepper.tsx b/packages/components/src/components/post-stepper/post-stepper.tsx new file mode 100644 index 0000000000..719a16d895 --- /dev/null +++ b/packages/components/src/components/post-stepper/post-stepper.tsx @@ -0,0 +1,122 @@ +import { Component, Element, h, Host, Prop, Watch } from '@stencil/core'; +import { version } from '@root/package.json'; +import { checkRequiredAndType } from '@/utils'; + +@Component({ + tag: 'post-stepper', + styleUrl: 'post-stepper.scss', + shadow: true, +}) +export class PostStepper { + @Element() host: HTMLPostStepperElement; + + private stepItems: NodeListOf; + + /** + * "Current step" label for accessibility + */ + @Prop() currentLabel!: string; + + @Watch('currentLabel') + validateCurrentLabel() { + checkRequiredAndType(this, 'currentLabel', 'string'); + } + + /** + * "Completed step" label for accessibility + */ + @Prop() completedLabel!: string; + + @Watch('completedLabel') + validateCompletedLabel() { + checkRequiredAndType(this, 'completedLabel', 'string'); + } + + /** + * "Step" label for mobile view + */ + @Prop() stepLabel!: string; + + @Watch('stepLabel') + validateStepLabel() { + checkRequiredAndType(this, 'stepLabel', 'string'); + } + + /** + * Defines the currently active step + */ + @Prop() currentIndex: number = -1; + + @Watch('currentIndex') + validateCurrentIndex() { + checkRequiredAndType(this, 'currentIndex', 'number'); + this.updateSteps(); + } + + componentDidLoad() { + this.validateStepLabel(); + this.validateCompletedLabel(); + this.validateCurrentLabel(); + + // Wait for slotchange + setTimeout(() => { + this.validateCurrentIndex(); + }); + } + + private updateSteps() { + this.stepItems = this.host.querySelectorAll('post-stepper-item'); + + if (!this.stepItems || this.stepItems.length < 2) { + console.error('The post-stepper component should have at least two steps.'); + return; + } + + this.stepItems.forEach((el, i) => { + if (this.stepLabel) { + el.querySelector('.step-mobile-label').textContent = `${this.stepLabel} ${i + 1}:`; + } + + // Update "post-stepper-item" classes to show correct status + el.classList.toggle('stepper-item-completed', this.currentIndex > i); + el.classList.toggle('stepper-item-current', this.currentIndex === i); + el.classList.toggle('stepper-item-inactive', this.currentIndex < i); + + // Update mobile label to show "Step N: ..." on mobile + const mobileLabel = el.querySelector('.step-mobile-label'); + if (mobileLabel && this.stepLabel) { + mobileLabel.textContent = `${this.stepLabel} ${i + 1}:`; + } + + // Update accessibility label depending on status (Completed/Current/-) + const hiddenLabel = el.querySelector('.step-hidden-label'); + if (hiddenLabel) { + hiddenLabel.textContent = + this.currentIndex > i + ? `${this.completedLabel}:` + : this.currentIndex === i + ? `${this.currentLabel}:` + : ''; + } + + // Update accessibility aria attributes + if (this.currentIndex === i) { + el.setAttribute('aria-current', 'step'); + el.setAttribute('aria-live', 'polite'); + } else { + el.removeAttribute('aria-current'); + el.removeAttribute('aria-live'); + } + }); + } + + render() { + return ( + +
    + this.updateSteps()}> +
+
+ ); + } +} diff --git a/packages/components/src/components/post-stepper/readme.md b/packages/components/src/components/post-stepper/readme.md new file mode 100644 index 0000000000..f41344c0ec --- /dev/null +++ b/packages/components/src/components/post-stepper/readme.md @@ -0,0 +1,20 @@ +# post-stepper + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ----------------------------- | ----------------- | ---------------------------------------- | -------- | ----------- | +| `completedLabel` _(required)_ | `completed-label` | "Completed step" label for accessibility | `string` | `undefined` | +| `currentIndex` | `current-index` | Defines the currently active step | `number` | `-1` | +| `currentLabel` _(required)_ | `current-label` | "Current step" label for accessibility | `string` | `undefined` | +| `stepLabel` _(required)_ | `step-label` | "Step" label for mobile | `string` | `undefined` | + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/documentation/src/stories/components/stepper/stepper.docs.mdx b/packages/documentation/src/stories/components/stepper/stepper.docs.mdx index 21a2c4f0bc..852f1b55f9 100644 --- a/packages/documentation/src/stories/components/stepper/stepper.docs.mdx +++ b/packages/documentation/src/stories/components/stepper/stepper.docs.mdx @@ -1,5 +1,4 @@ import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; -import StylesPackageImport from '@/shared/styles-package-import.mdx'; import * as StepperStories from './stepper.stories'; @@ -7,44 +6,16 @@ import * as StepperStories from './stepper.stories';
# Stepper - + +
- The stepped progression component provides an interactive visual overview of a process. It shows - at a glance the amount of steps a user is required to go through, and serves as a guide for each - step, indicating its status. + A stepper guides users through any linear, multistep process by showing the user their completed, + current, and future steps in an interactive manner.
- - - -## Examples - -### Navigational Stepper - -You can use a stepper to allow users to navigate to specific steps. -To do so, use `a` elements for the `.stepper-link` of the steps you want to be navigable, and use `span` for the steps you want not to be navigable. -Then, wrap the stepper inside a `