): void {
+ super.firstUpdated(changedProperties);
+ this._loaded = true;
+ }
+
+ public override disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this._stepResizeObserver.disconnect();
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+
+
step && this._stepResizeObserver.observe(step as HTMLElement))}
+ >
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'sbb-step': SbbStepElement;
+ }
+}
diff --git a/src/elements/stepper/stepper.ts b/src/elements/stepper/stepper.ts
new file mode 100644
index 0000000000..5faae91efd
--- /dev/null
+++ b/src/elements/stepper/stepper.ts
@@ -0,0 +1 @@
+export * from './stepper/stepper.js';
diff --git a/src/elements/stepper/stepper/__snapshots__/stepper.spec.snap.js b/src/elements/stepper/stepper/__snapshots__/stepper.spec.snap.js
new file mode 100644
index 0000000000..2863b0d5c3
--- /dev/null
+++ b/src/elements/stepper/stepper/__snapshots__/stepper.spec.snap.js
@@ -0,0 +1,186 @@
+/* @web/test-runner snapshot v1 */
+export const snapshots = {};
+
+snapshots["sbb-stepper renders - DOM"] =
+`
+
+ Test step label 1
+
+
+ Test step content 1
+
+
+ Test step label 2
+
+
+ Test step content 2
+
+
+ Test step label 3
+
+
+ Test step content 3
+
+
+ Test step label 4
+
+
+`;
+/* end snapshot sbb-stepper renders - DOM */
+
+snapshots["sbb-stepper renders - Shadow DOM"] =
+`
+`;
+/* end snapshot sbb-stepper renders - Shadow DOM */
+
+snapshots["sbb-stepper A11y tree Chrome"] =
+`
+ {
+ "role": "WebArea",
+ "name": "",
+ "children": [
+ {
+ "role": "tab",
+ "name": "Test step label 1",
+ "selected": true
+ },
+ {
+ "role": "tab",
+ "name": "Test step label 2"
+ },
+ {
+ "role": "tab",
+ "name": "Test step label 3"
+ },
+ {
+ "role": "tab",
+ "name": "Test step label 4"
+ },
+ {
+ "role": "text",
+ "name": "Test step content 1"
+ }
+ ]
+}
+
+`;
+/* end snapshot sbb-stepper A11y tree Chrome */
+
+snapshots["sbb-stepper A11y tree Firefox"] =
+`
+ {
+ "role": "document",
+ "name": "",
+ "children": [
+ {
+ "role": "tab",
+ "name": "1 Test step label 1",
+ "selected": true
+ },
+ {
+ "role": "tab",
+ "name": "2 Test step label 2"
+ },
+ {
+ "role": "tab",
+ "name": "3 Test step label 3"
+ },
+ {
+ "role": "tab",
+ "name": "4 Test step label 4"
+ },
+ {
+ "role": "text leaf",
+ "name": "Test step content 1"
+ },
+ {
+ "role": "tabpanel",
+ "name": "2 Test step label 2"
+ },
+ {
+ "role": "tabpanel",
+ "name": "3 Test step label 3"
+ }
+ ]
+}
+
+`;
+/* end snapshot sbb-stepper A11y tree Firefox */
+
diff --git a/src/elements/stepper/stepper/readme.md b/src/elements/stepper/stepper/readme.md
new file mode 100644
index 0000000000..42ea3659cd
--- /dev/null
+++ b/src/elements/stepper/stepper/readme.md
@@ -0,0 +1,129 @@
+The `sbb-stepper` is a component that visually guides a user through a sequential, multi-step process. It breaks down complex forms, flows, or other linear interactions into smaller, easier-to-follow steps. The current step is highlighted, and a progress bar connects the steps to visually represent progress.
+
+Use it with [sbb-step-label](/docs/elements-sbb-stepper-sbb-step-label--docs) and [sbb-step](/docs/elements-sbb-stepper-sbb-step--docs).
+
+```html
+
+ Step label 1
+ Step content 1
+
+ Step label 2
+ Step content 2
+
+```
+
+## Interactions
+
+There are two attributes to support navigation between different steps that can be used on elements inside an `sbb-step` to select the next or the previous step when clicked: `sbb-stepper-next` and `sbb-stepper-previous`.
+
+### Linear stepper
+
+The `linear` property can be set to create a linear stepper that requires the user to complete previous steps before proceeding to following steps.
+
+```html
+
+ Step label 1
+ Step content 1
+
+ Step label 2
+ Step content 2
+
+ Step label 3
+ Step content 3
+
+```
+
+## Forms
+
+There are two possible approaches. One is using a single form for the stepper, and the other is using a different form for each step.
+
+### Single form
+
+```html
+
+```
+
+### Multiple forms
+
+```html
+
+ Step label 1
+
+
+
+
+ Step label 2
+
+
+
+
+```
+
+Calling the `reset()` method on the `sbb-stepper` will reset the wrapping `form` or, if they are present, every `form` in each step; then it will select the first step.
+
+## Events
+
+Whenever a step switch is triggered, a `validate` event is emitted and can be canceled to prevent the step change.
+
+## Accessibility
+
+Whenever textual content is provided, please also set the attribute `tabindex=‘0’` on the text tag, so that it can be reached and announced by screen-readers. Also remember to use the classes `.sbb-focus-outline` and `.sbb-focus-outline-dark` to correctly style the outline.
+
+```html
+
+ Step label 1
+
+ Step content 1
+ Button
+
+
+ Step label 2
+
+ Step content 2
+ Button
+
+
+```
+
+Use an `aria-label` attribute to describe the purpose of the stepper. The `sbb-stepper` also sets other attributes on the steps and the step labels like `aria-setsize`, `aria-posinset`, `aria-controls`, `aria-labelledby`. If important content needs to be announced when a step is changed, use the `aria-live=‘polite’` attribute.
+
+
+
+## Properties
+
+| Name | Attribute | Privacy | Type | Default | Description |
+| ---------------- | ----------------- | ------- | -------------------------------- | -------------- | --------------------------------------------------------------------------------- |
+| `horizontalFrom` | `horizontal-from` | public | `SbbHorizontalFrom \| undefined` | | Overrides the behaviour of `orientation` property. |
+| `linear` | `linear` | public | `boolean` | `false` | If set to true, only the current and previous labels can be clicked and selected. |
+| `orientation` | `orientation` | public | `SbbOrientation` | `'horizontal'` | Steps orientation, either horizontal or vertical. |
+| `selected` | - | public | `SbbStepElement \| undefined` | | The currently selected step. |
+| `selectedIndex` | `selected-index` | public | `number \| undefined` | | The currently selected step index. |
+| `steps` | - | public | `SbbStepElement[]` | | The steps of the stepper. |
+
+## Methods
+
+| Name | Privacy | Description | Parameters | Return | Inherited From |
+| ---------- | ------- | ---------------------------------------------------------------------------------- | ---------- | ------ | -------------- |
+| `next` | public | Selects the next step. | | `void` | |
+| `previous` | public | Selects the previous step. | | `void` | |
+| `reset` | public | Resets the form in which the stepper is nested or every form of each step, if any. | | `void` | |
+
+## Slots
+
+| Name | Description |
+| ------------ | ------------------------------------------------------------------------------------------ |
+| | Provide a `sbb-expansion-panel-header` and a `sbb-expansion-panel-content` to the stepper. |
+| `step` | Use this slot to provide an `sbb-step`. |
+| `step-label` | Use this slot to provide an `sbb-step-label`. |
diff --git a/src/elements/stepper/stepper/stepper.e2e.ts b/src/elements/stepper/stepper/stepper.e2e.ts
new file mode 100644
index 0000000000..d8ce0de1f0
--- /dev/null
+++ b/src/elements/stepper/stepper/stepper.e2e.ts
@@ -0,0 +1,399 @@
+import { assert, expect } from '@open-wc/testing';
+import { sendKeys } from '@web/test-runner-commands';
+import { html } from 'lit/static-html.js';
+
+import { fixture } from '../../core/testing/private.js';
+import { EventSpy, waitForCondition, waitForLitRender } from '../../core/testing.js';
+import { SbbStepElement } from '../step/step.js';
+import type { SbbStepLabelElement } from '../step-label.js';
+
+import { SbbStepperElement } from './stepper.js';
+import '../step-label.js';
+import '../step.js';
+
+describe('sbb-stepper', () => {
+ let element: SbbStepperElement;
+
+ beforeEach(async () => {
+ element = await fixture(html`
+
+ Step 1
+
+ Step one content.
+ Next
+
+
+ Step 2
+
+ Step two content.
+ Next
+ Back
+
+
+ Step 3
+
+ Step three content.
+ Back
+
+
+ Step 4
+ Step four content.
+
+ `);
+ });
+
+ it('renders', async () => {
+ assert.instanceOf(element, SbbStepperElement);
+ });
+
+ it('selects the first step by default', async () => {
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+
+ await waitForLitRender(element);
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ });
+
+ it('selects the correct step on label click and emits validate event', async () => {
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ stepLabelThree.focus();
+ stepLabelThree.click();
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelThree).to.have.attribute('data-selected');
+ expect(stepLabelThree.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).to.be.equal(stepLabelThree.id);
+ });
+
+ it('selects the correct step via `selected` and emits validate event', async () => {
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ element.selected = stepLabelThree.step!;
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelThree).to.have.attribute('data-selected');
+ expect(stepLabelThree.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).not.to.be.equal(stepLabelThree.id);
+ });
+
+ it('selects the correct step via `selectedIndex` and emits validate event', async () => {
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ element.selectedIndex = 2;
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelThree).to.have.attribute('data-selected');
+ expect(stepLabelThree.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).not.to.be.equal(stepLabelThree.id);
+ });
+
+ it('selects the next step on [sbb-stepper-next] click and emits validate event', async () => {
+ const stepperNext = element.querySelector('[sbb-stepper-next]')!;
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ stepperNext.focus();
+ stepperNext.click();
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelTwo).to.have.attribute('data-selected');
+ expect(stepLabelTwo.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).to.be.equal(stepLabelTwo.id);
+ });
+
+ it('selects the previous step on [sbb-stepper-previous] click', async () => {
+ const stepperNext = element.querySelector('[sbb-stepper-next]')!;
+ const stepperPrevious = element.querySelector('[sbb-stepper-previous]')!;
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ stepperNext.focus();
+ stepperNext.click();
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelTwo).to.have.attribute('data-selected');
+ expect(stepLabelTwo.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).to.be.equal(stepLabelTwo.id);
+
+ stepperPrevious.click();
+ await waitForLitRender(element);
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).to.be.equal(stepLabelOne.id);
+ });
+
+ it('selects only the next step via [sbb-stepper-next] click in linear mode and emits validate event', async () => {
+ const stepperNext = element.querySelector('[sbb-stepper-next]')!;
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ element.linear = true;
+
+ stepLabelThree.click();
+ await waitForLitRender(element);
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+
+ stepLabelTwo.click();
+ await waitForLitRender(element);
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+
+ stepperNext.click();
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelTwo).to.have.attribute('data-selected');
+ expect(stepLabelTwo.step).to.have.attribute('data-selected');
+ });
+
+ it('does not switch to the next step if the validate is prevented', async () => {
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+ const validate = new EventSpy(SbbStepElement.events.validate);
+
+ element.addEventListener(SbbStepElement.events.validate, (ev) => ev.preventDefault());
+
+ stepLabelThree.click();
+ await waitForLitRender(element);
+
+ await waitForCondition(() => validate.events.length === 1);
+ expect(validate.count).to.be.equal(1);
+ expect(stepLabelThree).not.to.have.attribute('data-selected');
+ expect(stepLabelThree.step).not.to.have.attribute('data-selected');
+ });
+
+ it('resets the single form wrapping the stepper and returns to the first step', async () => {
+ element = await fixture(html`
+
+ `);
+
+ const stepper = element.querySelector('sbb-stepper')!;
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+ const stepInputOne = element.querySelector('input[name="first-input"]')!;
+ const stepInputTwo = element.querySelector('input[name="second-input"]')!;
+
+ element.selectedIndex = 2;
+
+ // If the focus is inside the stepper, the focus is set to the first step label after the reset.
+ stepLabelTwo.focus();
+
+ stepInputOne.value = 'First value';
+ stepInputTwo.value = 'Second value';
+
+ stepper.reset();
+ await waitForLitRender(element);
+
+ expect(stepInputOne.value).to.be.equal('');
+ expect(stepInputTwo.value).to.be.equal('');
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).to.be.equal(stepLabelOne.id);
+ });
+
+ it('resets the form for each step and returns to the first step', async () => {
+ element = await fixture(html`
+
+ Step 1
+
+ Step one content.
+
+
+
+ Step 2
+ Step two content.
+
+ Step 3
+
+ Step three content.
+
+
+
+ `);
+
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+ const stepInputOne = element.querySelector('input[name="first-input"]')!;
+ const stepInputTwo = element.querySelector('input[name="second-input"]')!;
+
+ element.selectedIndex = 2;
+
+ // If the focus is not inside the stepper, the focus is not set to the first step label after the reset.
+ document.body.focus();
+
+ stepInputOne.value = 'First value';
+ stepInputTwo.value = 'Second value';
+
+ element.reset();
+ await waitForLitRender(element);
+
+ expect(stepInputOne.value).to.be.equal('');
+ expect(stepInputTwo.value).to.be.equal('');
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+ expect(document.activeElement!.id).not.to.be.equal(stepLabelOne.id);
+ });
+
+ it('focuses the correct element in the step content', async () => {
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+
+ await sendKeys({ down: 'Tab' });
+ expect(document.activeElement!.id).to.be.equal(stepLabelOne.id);
+
+ await sendKeys({ down: 'Tab' });
+ expect(document.activeElement!.id).to.be.equal('step-one-content');
+ });
+
+ it('sets the correct aria-labelledby attributes', async () => {
+ const steps: SbbStepElement[] = Array.from(
+ element.querySelectorAll('sbb-step'),
+ );
+ steps.forEach((step: SbbStepElement) =>
+ expect(step).to.have.attribute('aria-labelledby', step.label!.id),
+ );
+ });
+
+ it('selects step on right arrow key pressed', async () => {
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+
+ await sendKeys({ down: 'Tab' });
+ await sendKeys({ down: 'ArrowRight' });
+
+ expect(stepLabelTwo).to.have.attribute('data-selected');
+ expect(stepLabelTwo.step).to.have.attribute('data-selected');
+ });
+
+ it('selects step on left arrow key pressed', async () => {
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+
+ await sendKeys({ down: 'Tab' });
+ await sendKeys({ down: 'ArrowRight' });
+
+ expect(stepLabelTwo).to.have.attribute('data-selected');
+ expect(stepLabelTwo.step).to.have.attribute('data-selected');
+
+ await sendKeys({ down: 'ArrowLeft' });
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+ });
+
+ it('wraps around on arrow key navigation', async () => {
+ const stepLabelOne = element.querySelector(
+ 'sbb-step-label:nth-of-type(1)',
+ )!;
+ const stepLabelTwo = element.querySelector(
+ 'sbb-step-label:nth-of-type(2)',
+ )!;
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+
+ await sendKeys({ down: 'Tab' });
+ await sendKeys({ down: 'ArrowRight' });
+
+ expect(stepLabelTwo).to.have.attribute('data-selected');
+ expect(stepLabelTwo.step).to.have.attribute('data-selected');
+
+ await sendKeys({ down: 'ArrowRight' });
+
+ expect(stepLabelThree).to.have.attribute('data-selected');
+ expect(stepLabelThree.step).to.have.attribute('data-selected');
+
+ await sendKeys({ down: 'ArrowRight' });
+
+ expect(stepLabelOne).to.have.attribute('data-selected');
+ expect(stepLabelOne.step).to.have.attribute('data-selected');
+ });
+
+ it('wraps around on arrow left arrow key navigation', async () => {
+ const stepLabelThree = element.querySelector(
+ 'sbb-step-label:nth-of-type(3)',
+ )!;
+
+ await sendKeys({ down: 'Tab' });
+ await sendKeys({ down: 'ArrowLeft' });
+
+ expect(stepLabelThree).to.have.attribute('data-selected');
+ expect(stepLabelThree.step).to.have.attribute('data-selected');
+ });
+});
diff --git a/src/elements/stepper/stepper/stepper.scss b/src/elements/stepper/stepper/stepper.scss
new file mode 100644
index 0000000000..3a7968320c
--- /dev/null
+++ b/src/elements/stepper/stepper/stepper.scss
@@ -0,0 +1,90 @@
+@use '../../core/styles' as sbb;
+
+// Box-sizing rules contained in typography are not traversing Shadow DOM boundaries. We need to include box-sizing mixin in every component.
+@include sbb.box-sizing;
+
+:host {
+ --sbb-stepper-orientation: row;
+ --sbb-stepper-border-width: var(--sbb-border-width-1x);
+ --sbb-stepper-marker-size: 0;
+ --sbb-stepper-marker-width: var(--sbb-border-width-3x);
+ --sbb-stepper-animation-duration: var(
+ --sbb-disable-animation-zero-time,
+ var(--sbb-animation-duration-6x)
+ );
+ --sbb-stepper-marker-color: var(--sbb-color-charcoal);
+
+ display: block;
+ position: relative;
+ counter-reset: step-label;
+
+ @include sbb.if-forced-colors {
+ --sbb-stepper-marker-color: ButtonText;
+ }
+}
+
+:host([data-disable-animation]) {
+ @include sbb.disable-animation;
+}
+
+:host([orientation='vertical']) {
+ --sbb-stepper-orientation: column;
+}
+
+.sbb-stepper {
+ width: 100%;
+}
+
+.sbb-stepper__labels {
+ display: flex;
+ flex-direction: var(--sbb-stepper-orientation);
+ position: relative;
+ justify-content: space-between;
+ margin-block-end: var(--sbb-spacing-responsive-m);
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset-inline-start: calc(var(--sbb-stepper-border-width) * -1);
+ background-color: var(--sbb-stepper-marker-color);
+ }
+
+ :host([orientation='horizontal']) & {
+ gap: var(--sbb-spacing-fixed-4x);
+ padding-block-end: var(--sbb-spacing-fixed-4x);
+ border-block-end: var(--sbb-stepper-border-width) solid var(--sbb-color-cloud);
+
+ &::before {
+ inset-block-end: calc(var(--sbb-stepper-border-width) * -1);
+ height: var(--sbb-stepper-marker-width);
+ width: var(--sbb-stepper-marker-size);
+ transition: width var(--sbb-stepper-animation-duration) var(--sbb-animation-easing);
+ }
+ }
+
+ :host([orientation='vertical']) & {
+ padding-inline-start: var(--sbb-spacing-fixed-4x);
+ border-inline-start: var(--sbb-stepper-border-width) solid var(--sbb-color-cloud);
+
+ &::before {
+ inset-block-start: 0;
+ width: var(--sbb-stepper-marker-width);
+ height: var(--sbb-stepper-marker-size);
+ transition: height var(--sbb-stepper-animation-duration) var(--sbb-animation-easing);
+ }
+ }
+}
+
+.sbb-stepper__steps {
+ position: relative;
+
+ :host([orientation='horizontal']) & {
+ height: var(--sbb-stepper-content-height);
+ transition: height var(--sbb-stepper-animation-duration) var(--sbb-animation-easing);
+ }
+}
+
+::slotted(sbb-step-label)::before {
+ content: counter(step-label);
+ counter-increment: step-label;
+}
diff --git a/src/elements/stepper/stepper/stepper.spec.ts b/src/elements/stepper/stepper/stepper.spec.ts
new file mode 100644
index 0000000000..3ede42d357
--- /dev/null
+++ b/src/elements/stepper/stepper/stepper.spec.ts
@@ -0,0 +1,39 @@
+import { expect } from '@open-wc/testing';
+import { html } from 'lit/static-html.js';
+
+import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js';
+import { waitForLitRender } from '../../core/testing.js';
+
+import type { SbbStepperElement } from './stepper.js';
+import './stepper.js';
+import '../step.js';
+import '../step-label.js';
+
+describe('sbb-stepper', () => {
+ let element: SbbStepperElement;
+
+ beforeEach(async () => {
+ element = await fixture(html`
+
+ Test step label 1
+ Test step content 1
+ Test step label 2
+ Test step content 2
+ Test step label 3
+ Test step content 3
+ Test step label 4
+
+ `);
+ await waitForLitRender(element);
+ });
+
+ it('renders - DOM', async () => {
+ await expect(element).dom.to.be.equalSnapshot();
+ });
+
+ it('renders - Shadow DOM', async () => {
+ await expect(element).shadowDom.to.be.equalSnapshot();
+ });
+
+ testA11yTreeSnapshot();
+});
diff --git a/src/elements/stepper/stepper/stepper.ssr.spec.ts b/src/elements/stepper/stepper/stepper.ssr.spec.ts
new file mode 100644
index 0000000000..f94a120344
--- /dev/null
+++ b/src/elements/stepper/stepper/stepper.ssr.spec.ts
@@ -0,0 +1,34 @@
+import { assert } from '@open-wc/testing';
+import { html } from 'lit';
+
+import { fixture } from '../../core/testing/private.js';
+
+import { SbbStepperElement } from './stepper.js';
+
+import '../step.js';
+import '../step-label.js';
+
+describe(`sbb-stepper ${fixture.name}`, () => {
+ let root: SbbStepperElement;
+
+ beforeEach(async () => {
+ root = await fixture(
+ html`
+
+ Test step label 1
+ Test step content 1
+ Test step label 2
+ Test step content 2
+ Test step label 3
+ Test step content 3
+ Test step label 4
+
+ `,
+ { modules: ['./stepper.js', '../step.js', '../step-label.js'] },
+ );
+ });
+
+ it('renders', () => {
+ assert.instanceOf(root, SbbStepperElement);
+ });
+});
diff --git a/src/elements/stepper/stepper/stepper.stories.ts b/src/elements/stepper/stepper/stepper.stories.ts
new file mode 100644
index 0000000000..0b704fc90b
--- /dev/null
+++ b/src/elements/stepper/stepper/stepper.stories.ts
@@ -0,0 +1,499 @@
+import { withActions } from '@storybook/addon-actions/decorator';
+import type { InputType } from '@storybook/types';
+import type { Args, ArgTypes, Decorator, Meta, StoryObj } from '@storybook/web-components';
+import { html, type TemplateResult } from 'lit';
+import { styleMap } from 'lit/directives/style-map.js';
+
+import { sbbSpread } from '../../../storybook/helpers/spread.js';
+import type { SbbFormErrorElement } from '../../form-error.js';
+import { SbbStepElement } from '../step.js';
+
+import readme from './readme.md?raw';
+
+import './stepper.js';
+import '../step-label.js';
+import '../../link/block-link-button.js';
+import '../../button/button.js';
+import '../../button/secondary-button.js';
+import '../../form-field.js';
+import '../../form-error.js';
+import '../../card.js';
+
+const linear: InputType = {
+ control: {
+ type: 'boolean',
+ },
+};
+
+const orientation: InputType = {
+ control: {
+ type: 'inline-radio',
+ },
+ options: ['horizontal', 'vertical'],
+};
+
+const horizontalFrom: InputType = {
+ control: {
+ type: 'select',
+ },
+ options: ['unset', 'zero', 'micro', 'small', 'medium', 'large', 'wide', 'ultra'],
+};
+
+const defaultArgTypes: ArgTypes = {
+ linear,
+ orientation,
+ 'horizontal-from': horizontalFrom,
+};
+
+const defaultArgs: Args = {
+ linear: false,
+ orientation: 'horizontal',
+ 'horizontal-from': 'unset',
+};
+
+const codeStyle: Args = {
+ padding: 'var(--sbb-spacing-fixed-1x) var(--sbb-spacing-fixed-2x)',
+ borderRadius: 'var(--sbb-border-radius-4x)',
+ backgroundColor: 'var(--sbb-color-smoke-alpha-20)',
+};
+
+const textBlock = (): TemplateResult => html`
+
+ Page content: lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
+ tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
+
+`;
+
+const WithSingleFormTemplate = (args: Args): TemplateResult => {
+ document.querySelector('sbb-stepper')?.reset();
+ document.querySelector('sbb-form-error')?.remove();
+ const sbbFormError: SbbFormErrorElement = document.createElement('sbb-form-error');
+ sbbFormError.setAttribute('slot', 'error');
+ sbbFormError.textContent = 'This is a required field.';
+
+ return html`
+
+
+ Hi
! 👋 Your
+ lucky number is
+
🍀 and your
+ favourite animal is
+
.
+
+ `;
+};
+
+const WithMultipleFormsTemplate = (args: Args): TemplateResult => {
+ document.querySelector('sbb-stepper')?.reset();
+ document.querySelector('sbb-form-error')?.remove();
+ const sbbFormError: SbbFormErrorElement = document.createElement('sbb-form-error');
+ sbbFormError.setAttribute('slot', 'error');
+ sbbFormError.textContent = 'This is a required field.';
+
+ return html`
+
+ Step 1
+ {
+ if (e.detail.currentStep.querySelector('sbb-form-field').hasAttribute('data-invalid')) {
+ e.preventDefault();
+ }
+ }}
+ >
+
+
+
+ Next
+
+
+ Step 2
+
+
+
+
+ Back
+ Next
+
+
+ Step 3
+
+
+
+
+ Back
+ Next
+
+
+ Step 4
+
+
+ You are now done.
+
+ Back
+ Submit
+ document.querySelector('sbb-stepper')?.reset()}
+ >Reset
+
+
+ ${textBlock()}
+ `;
+};
+
+const Template = ({ disabled, ...args }: Args): TemplateResult => html`
+
+ Step 1
+
+
+ First step content: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+ eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero
+ eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata
+ sanctus est Lorem ipsum dolor sit amet.
+
+ Next
+
+
+ Step 2
+
+
+ Second step content: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
+ nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At
+ vero eos et accusam et justo duo dolores et ea rebum.
+
+ Back
+ Next
+
+
+ Step 3
+
+
+ Third step content: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+ eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
+
+ Back
+ Next
+
+
+ Step 4
+
+
+ Forth step content: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+ eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero
+ eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata
+ sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing
+ elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed
+ diam voluptua.
+
+ Back
+ Submit
+
+
+ ${textBlock()}
+`;
+
+const LongLabelsTemplate = (args: Args): TemplateResult => html`
+
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
+ invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
+
+
+ First step content.
+
+ Next
+
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
+ tempor.
+
+
+ Second step content.
+
+ Back
+ Next
+
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
+ invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
+
+
+ Third step content.
+
+ Back
+ Next
+
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
+ tempor.
+
+
+ Forth step content.
+
+ Back
+ Submit
+
+
+ ${textBlock()}
+`;
+
+export const WithSingleForm: StoryObj = {
+ render: WithSingleFormTemplate,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs },
+};
+
+export const WithMultipleForms: StoryObj = {
+ render: WithMultipleFormsTemplate,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs },
+};
+
+export const Default: StoryObj = {
+ render: Template,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs },
+};
+
+export const Linear: StoryObj = {
+ render: Template,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs, linear: true },
+};
+
+export const WithDisabledStep: StoryObj = {
+ render: Template,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs, disabled: true },
+};
+
+export const Vertical: StoryObj = {
+ render: Template,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs, orientation: orientation.options![1] },
+};
+
+export const HorizontalFromSmall: StoryObj = {
+ render: Template,
+ argTypes: defaultArgTypes,
+ args: {
+ ...defaultArgs,
+ orientation: orientation.options![1],
+ 'horizontal-from': horizontalFrom.options![3],
+ },
+};
+
+export const LongLabels: StoryObj = {
+ render: LongLabelsTemplate,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs },
+};
+
+export const LongLabelsVertical: StoryObj = {
+ render: LongLabelsTemplate,
+ argTypes: defaultArgTypes,
+ args: { ...defaultArgs, orientation: orientation.options![1] },
+};
+
+const meta: Meta = {
+ decorators: [withActions as Decorator],
+ parameters: {
+ actions: {
+ handles: [SbbStepElement.events.validate],
+ },
+ docs: {
+ extractComponentDescription: () => readme,
+ },
+ },
+ title: 'elements/sbb-stepper/sbb-stepper',
+};
+
+export default meta;
diff --git a/src/elements/stepper/stepper/stepper.ts b/src/elements/stepper/stepper/stepper.ts
new file mode 100644
index 0000000000..2cb67b3176
--- /dev/null
+++ b/src/elements/stepper/stepper/stepper.ts
@@ -0,0 +1,313 @@
+import {
+ type CSSResultGroup,
+ html,
+ LitElement,
+ type TemplateResult,
+ type PropertyValues,
+} from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+
+import { getNextElementIndex, isArrowKeyPressed } from '../../core/a11y.js';
+import { SbbConnectedAbortController } from '../../core/controllers.js';
+import { breakpoints, isBreakpoint } from '../../core/dom.js';
+import type { SbbHorizontalFrom, SbbOrientation } from '../../core/interfaces.js';
+import type { SbbStepElement, SbbStepValidateEventDetails } from '../step.js';
+
+import style from './stepper.scss?lit&inline';
+
+const DEBOUNCE_TIME = 150;
+
+/**
+ * Provides a structured, step-by-step workflow for user interactions.
+ * @slot - Provide a `sbb-expansion-panel-header` and a `sbb-expansion-panel-content` to the stepper.
+ * @slot step-label - Use this slot to provide an `sbb-step-label`.
+ * @slot step - Use this slot to provide an `sbb-step`.
+ */
+@customElement('sbb-stepper')
+export class SbbStepperElement extends LitElement {
+ public static override styles: CSSResultGroup = style;
+
+ /** If set to true, only the current and previous labels can be clicked and selected. */
+ @property({ type: Boolean }) public linear = false;
+
+ /** Overrides the behaviour of `orientation` property. */
+ @property({ attribute: 'horizontal-from', reflect: true })
+ public get horizontalFrom(): SbbHorizontalFrom | undefined {
+ return this._horizontalFrom;
+ }
+ public set horizontalFrom(value: SbbHorizontalFrom) {
+ this._horizontalFrom = breakpoints.includes(value) ? value : undefined;
+ if (this._horizontalFrom && this._loaded) {
+ this._checkOrientation();
+ }
+ }
+ private _horizontalFrom?: SbbHorizontalFrom | undefined;
+
+ /** Steps orientation, either horizontal or vertical. */
+ @property({ reflect: true })
+ public orientation: SbbOrientation = 'horizontal';
+
+ /** The currently selected step. */
+ @property({ attribute: false })
+ public set selected(step: SbbStepElement) {
+ if (this._loaded) {
+ this._select(step);
+ }
+ }
+ public get selected(): SbbStepElement | undefined {
+ return this.querySelector?.('sbb-step[data-selected]') ?? undefined;
+ }
+
+ /** The currently selected step index. */
+ @property({ attribute: 'selected-index', type: Number })
+ public set selectedIndex(index: number) {
+ if (this._loaded) {
+ this._select(this.steps[index]);
+ }
+ }
+ public get selectedIndex(): number | undefined {
+ return this.selected ? this.steps.indexOf(this.selected) : undefined;
+ }
+
+ /** The steps of the stepper. */
+ public get steps(): SbbStepElement[] {
+ return Array.from(this.querySelectorAll?.('sbb-step') ?? []);
+ }
+
+ private get _enabledSteps(): SbbStepElement[] {
+ return this.steps.filter((s) => !s.label?.hasAttribute('disabled'));
+ }
+
+ /** Selects the next step. */
+ public next(): void {
+ if (this.selectedIndex !== undefined) {
+ this._select(this.steps[this.selectedIndex + 1]);
+ }
+ }
+
+ /** Selects the previous step. */
+ public previous(): void {
+ if (this.selectedIndex !== undefined) {
+ this._select(this.steps[this.selectedIndex - 1]);
+ }
+ }
+
+ /** Resets the form in which the stepper is nested or every form of each step, if any. */
+ public reset(): void {
+ const closestForm = this.closest('form');
+ if (closestForm) {
+ closestForm.reset();
+ } else {
+ this.querySelectorAll('form').forEach((form) => form.reset());
+ }
+ this.selectedIndex = 0;
+ // In case the focus is currently inside the stepper, we reset the focus to the first/selected step label.
+ if (document.activeElement?.closest('sbb-stepper') === this) {
+ this.selected?.label?.focus();
+ }
+ }
+
+ private _loaded: boolean = false;
+ private _abort = new SbbConnectedAbortController(this);
+ private _resizeObserverTimeout: ReturnType | null = null;
+
+ private _isValidStep(step: SbbStepElement): boolean {
+ if (!step || (!this.linear && step.label?.hasAttribute('disabled'))) {
+ return false;
+ }
+
+ if (this.linear && !this.selected) {
+ return step === this.steps[0];
+ }
+
+ if (this.linear && this.selectedIndex !== undefined) {
+ const index = this.steps.indexOf(step);
+ return index < this.selectedIndex || index === this.selectedIndex + 1;
+ }
+
+ return true;
+ }
+
+ private _select(step: SbbStepElement): void {
+ if (!this._isValidStep(step)) {
+ return;
+ }
+ const validatePayload: SbbStepValidateEventDetails = {
+ currentIndex: this.selectedIndex,
+ currentStep: this.selected,
+ nextIndex: this.selectedIndex !== undefined ? this.selectedIndex + 1 : undefined,
+ nextStep: this.selectedIndex !== undefined ? this.steps[this.selectedIndex + 1] : undefined,
+ };
+ if (this.selected && !this.selected.validate(validatePayload)) {
+ return;
+ }
+ const current = this.selected;
+ current?.deselect();
+ step.select();
+ this._setMarkerSize();
+ this._configureLinearMode();
+ // In case the focus is currently inside the stepper, we focus the selected step label.
+ if (document.activeElement?.closest('sbb-stepper') === this) {
+ this.selected?.label?.focus();
+ }
+ }
+
+ private _setMarkerSize(): void {
+ if (
+ !this._loaded ||
+ !this.selected ||
+ this.selectedIndex === undefined ||
+ !this.selected.label
+ ) {
+ return;
+ }
+ const offset =
+ this.orientation === 'horizontal'
+ ? this.selected.label.offsetLeft + this.selected.label.offsetWidth
+ : this._calculateLabelOffsetTop();
+
+ this.style.setProperty('--sbb-stepper-marker-size', `${offset}px`);
+ }
+
+ private _calculateLabelOffsetTop(): number | undefined {
+ if (this.selectedIndex === undefined) {
+ return;
+ }
+ let offset = 0;
+ for (const step of this.steps) {
+ if (step === this.selected) {
+ break;
+ }
+ offset = step.label!.offsetHeight + offset;
+ }
+ return (
+ offset +
+ this.selected!.label!.offsetHeight! +
+ parseFloat(getComputedStyle(this).getPropertyValue('--sbb-spacing-fixed-6x')) *
+ 16 *
+ this.selectedIndex
+ );
+ }
+
+ private _configure(): void {
+ const steps = this.steps;
+ steps.forEach((s) => s.configure(this._loaded));
+ steps
+ .filter((s) => s.label)
+ .map((s) => s.label!)
+ .forEach((label, i, array) => {
+ label.configure(i + 1, array.length, this._loaded);
+ });
+ this._select(this.selected || this._enabledSteps[0]);
+ }
+
+ private _updateLabels(): void {
+ this.steps.forEach((step) => {
+ step.slot = this.orientation === 'horizontal' ? 'step' : 'step-label';
+ step.setAttribute('data-orientation', this.orientation);
+ step.label?.setAttribute('data-orientation', this.orientation);
+ });
+ }
+
+ private _checkOrientation(): void {
+ if (this.horizontalFrom) {
+ this.orientation = isBreakpoint(this.horizontalFrom) ? 'horizontal' : 'vertical';
+ this._updateLabels();
+ }
+ // The timeout is needed to make sure that the marker takes the correct step-label size.
+ setTimeout(() => this._setMarkerSize(), 0);
+ }
+
+ private _onStepperResize(): void {
+ this._checkOrientation();
+ clearTimeout(this._resizeObserverTimeout!);
+ this.toggleAttribute('data-disable-animation', true);
+
+ // Disable the animation when resizing to avoid strange transition effects.
+ this._resizeObserverTimeout = setTimeout(
+ () => this.toggleAttribute('data-disable-animation', false),
+ DEBOUNCE_TIME,
+ );
+ }
+
+ private _configureLinearMode(): void {
+ this.steps.forEach((step, index) => {
+ step.label?.toggleAttribute(
+ 'disabled',
+ (this.linear && index > this.selectedIndex!) ||
+ (!this.linear && step.label.hasAttribute('data-disabled')),
+ );
+ });
+ }
+
+ public override connectedCallback(): void {
+ super.connectedCallback();
+ const signal = this._abort.signal;
+ this.addEventListener('keydown', (e) => this._handleKeyDown(e), { signal });
+ window.addEventListener('resize', () => this._onStepperResize(), {
+ signal,
+ passive: true,
+ });
+ this.toggleAttribute('data-disable-animation', !this._loaded);
+ }
+
+ protected override async firstUpdated(changedProperties: PropertyValues): Promise {
+ super.firstUpdated(changedProperties);
+ await this.updateComplete;
+ this._loaded = true;
+ this.selectedIndex = !this.linear ? Number(this.getAttribute('selected-index')) || 0 : 0;
+ this._checkOrientation();
+ // Remove [data-disable-animation] after component init
+ setTimeout(() => this.toggleAttribute('data-disable-animation', false), DEBOUNCE_TIME);
+ }
+
+ protected override willUpdate(changedProperties: PropertyValues): void {
+ super.willUpdate(changedProperties);
+ if (changedProperties.has('orientation') && !this.horizontalFrom) {
+ this._updateLabels();
+ this._setMarkerSize();
+ }
+ if (changedProperties.has('linear') && this._loaded) {
+ this._configureLinearMode();
+ }
+ }
+
+ private _handleKeyDown(evt: KeyboardEvent): void {
+ const enabledSteps: SbbStepElement[] = this._enabledSteps;
+
+ if (
+ !enabledSteps ||
+ // don't trap nested handling
+ ((evt.target as HTMLElement) !== this && (evt.target as HTMLElement).parentElement !== this)
+ ) {
+ return;
+ }
+
+ if (isArrowKeyPressed(evt)) {
+ const current: number = enabledSteps.indexOf(this.selected!);
+ const nextIndex: number = getNextElementIndex(evt, current, enabledSteps.length);
+ this._select(enabledSteps[nextIndex]);
+ evt.preventDefault();
+ }
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'sbb-stepper': SbbStepperElement;
+ }
+}
diff --git a/src/elements/tabs/tab-title/tab-title.ts b/src/elements/tabs/tab-title/tab-title.ts
index 6a760895ca..6b3c2adf15 100644
--- a/src/elements/tabs/tab-title/tab-title.ts
+++ b/src/elements/tabs/tab-title/tab-title.ts
@@ -11,7 +11,7 @@ import type { SbbTitleLevel } from '../../title.js';
import style from './tab-title.scss?lit&inline';
/**
- * Combined with a `sbb-rab-group`, it displays a tab's title.
+ * Combined with a `sbb-tab-group`, it displays a tab's title.
*
* @slot - Use the unnamed slot to add content to the tab title.
* @slot icon - Use this slot to display an icon to the left of the title, by providing the `sbb-icon` component.
diff --git a/tools/docs/docs_generate.ts b/tools/docs/docs_generate.ts
index 74919416ff..edd3c75881 100644
--- a/tools/docs/docs_generate.ts
+++ b/tools/docs/docs_generate.ts
@@ -107,7 +107,7 @@ async function updateComponentReadme(
// Remove the title
newDocs.replace(/^# class: `.*`\n/m, '');
- updateFieldsTable(newDocs, sections, manifest.attributes!);
+ updateFieldsTable(newDocs, sections, manifest.attributes ?? []);
newDocs = new MagicString(newDocs.toString());
// Unescape `
diff --git a/tsconfig.json b/tsconfig.json
index 4bcfa71524..e411d42a8e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -62,7 +62,9 @@
"sbb-navigation-section-close",
"sbb-overlay-close",
"sbb-popover-close",
- "sbb-toast-close"
+ "sbb-toast-close",
+ "sbb-stepper-next",
+ "sbb-stepper-previous"
]
}
]