diff --git a/src/elements/core/styles/a11y.scss b/src/elements/core/styles/a11y.scss index 83d56dfca4..c0de4a554a 100644 --- a/src/elements/core/styles/a11y.scss +++ b/src/elements/core/styles/a11y.scss @@ -3,3 +3,13 @@ .sbb-screen-reader-only { @include a11y.screen-reader-only; } + +.sbb-focus-outline:focus-visible { + @include a11y.focus-outline; +} + +.sbb-focus-outline-dark:focus-visible { + @include a11y.focus-outline; + + --sbb-focus-outline-color: var(--sbb-focus-outline-color-dark); +} diff --git a/src/elements/stepper.ts b/src/elements/stepper.ts new file mode 100644 index 0000000000..a69c378f22 --- /dev/null +++ b/src/elements/stepper.ts @@ -0,0 +1,3 @@ +export * from './stepper/step.js'; +export * from './stepper/step-label.js'; +export * from './stepper/stepper.js'; diff --git a/src/elements/stepper/step-label.ts b/src/elements/stepper/step-label.ts new file mode 100644 index 0000000000..dbf3dcde65 --- /dev/null +++ b/src/elements/stepper/step-label.ts @@ -0,0 +1 @@ +export * from './step-label/step-label.js'; diff --git a/src/elements/stepper/step-label/__snapshots__/step-label.spec.snap.js b/src/elements/stepper/step-label/__snapshots__/step-label.spec.snap.js new file mode 100644 index 0000000000..e07063b681 --- /dev/null +++ b/src/elements/stepper/step-label/__snapshots__/step-label.spec.snap.js @@ -0,0 +1,132 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-step-label renders DOM"] = +` + Label + +`; +/* end snapshot sbb-step-label renders DOM */ + +snapshots["sbb-step-label renders Shadow DOM"] = +`
+ + + + + + + + +
+`; +/* end snapshot sbb-step-label renders Shadow DOM */ + +snapshots["sbb-step-label renders with icon DOM"] = +` + Label + +`; +/* end snapshot sbb-step-label renders with icon DOM */ + +snapshots["sbb-step-label renders with icon Shadow DOM"] = +`
+ + + + + + + + + +
+`; +/* end snapshot sbb-step-label renders with icon Shadow DOM */ + +snapshots["sbb-step-label renders disabled DOM"] = +` + Label + +`; +/* end snapshot sbb-step-label renders disabled DOM */ + +snapshots["sbb-step-label renders disabled Shadow DOM"] = +`
+ + + + + + + + +
+`; +/* end snapshot sbb-step-label renders disabled Shadow DOM */ + +snapshots["sbb-step-label A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "", + "children": [ + { + "role": "tab", + "name": "Label" + } + ] +} +

+`; +/* end snapshot sbb-step-label A11y tree Chrome */ + +snapshots["sbb-step-label A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "", + "children": [ + { + "role": "tab", + "name": "Label" + } + ] +} +

+`; +/* end snapshot sbb-step-label A11y tree Firefox */ + diff --git a/src/elements/stepper/step-label/readme.md b/src/elements/stepper/step-label/readme.md new file mode 100644 index 0000000000..fe1a65a842 --- /dev/null +++ b/src/elements/stepper/step-label/readme.md @@ -0,0 +1,54 @@ +Use the `sbb-step-label` with the `sbb-stepper` to display a step label. + +```html +Step label +``` + +## Slots + +It has an implicit slot named `step-label`. + +## States + +The component can be displayed in `disabled` state using the self-named property. + +```html +Step label +``` + +## Style + +If it is used in an `sbb-stepper` and no `icon-name` is specified, it displays a counter in the label prefix to keep track of the step number. + +```html + +Step label + + +Step label +``` + +## Accessibility + +The accessibility properties `aria-controls`, `aria-setsize`, `aria-posinset` are set automatically. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ---------- | ----------- | ------- | ------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `disabled` | `disabled` | public | `boolean` | `false` | Whether the component is disabled. | +| `form` | `form` | public | `string \| undefined` | | The
element to associate the button with. | +| `iconName` | `icon-name` | public | `string \| undefined` | | The icon name we want to use, choose from the small icon variants from the ui-icons category from here https://icons.app.sbb.ch. | +| `name` | `name` | public | `string` | | The name of the button element. | +| `step` | - | public | `SbbStepElement \| null` | `null` | The step controlled by the label. | +| `type` | `type` | public | `SbbButtonType` | `'button'` | The type attribute to use for the button. | +| `value` | `value` | public | `string` | | The value of the button element. | + +## Slots + +| Name | Description | +| ------ | ------------------------------------------------ | +| | Use the unnamed slot to provide a label. | +| `icon` | Use this to display an icon in the label bubble. | diff --git a/src/elements/stepper/step-label/step-label.scss b/src/elements/stepper/step-label/step-label.scss new file mode 100644 index 0000000000..111b7a6514 --- /dev/null +++ b/src/elements/stepper/step-label/step-label.scss @@ -0,0 +1,153 @@ +@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-step-label-color: var(--sbb-color-iron); + --sbb-step-label-animation-duration: var( + --sbb-disable-animation-zero-time, + var(--sbb-animation-duration-2x) + ); + --sbb-step-label-prefix-size: var(--sbb-size-element-xxs); + --sbb-step-label-prefix-border-style: solid; + --sbb-step-label-prefix-border-color: var(--sbb-color-cloud); + --sbb-step-label-prefix-background-color: var(--sbb-color-white); + + position: relative; + min-width: 0; + max-width: fit-content; + + &::before { + @include sbb.text-xxs--regular; + @include sbb.absolute-center-x-y; + + cursor: var(--sbb-step-label-cursor); + color: var(--sbb-step-label-color); + + // The `--sbb-font-size-text-l` is beign used here to align the bubble's inner text to + // the label text which includes the `sbb.text-l--bold` mixin. + inset-block-start: calc( + var(--sbb-font-size-text-l) * (var(--sbb-typo-line-height-body-text) / 2) + + (var(--sbb-border-width-1x) / 2) + ); + inset-inline-start: calc(var(--sbb-step-label-prefix-size) / 2); + line-height: 1; + z-index: 1; + transform: translate( + -50%, + calc(-50% + var(--sbb-step-label-translate-y-content-hover, #{sbb.px-to-rem-build(0)})) + ); + transition: transform var(--sbb-step-label-animation-duration) var(--sbb-animation-easing); + } + + @include sbb.if-forced-colors { + --sbb-step-label-color: ButtonText; + --sbb-step-label-prefix-border-color: ButtonText; + } +} + +:host([data-selected]) { + @include sbb.text-xxs--bold; + + --sbb-step-label-color: var(--sbb-color-charcoal); + + @include sbb.if-forced-colors { + --sbb-step-label-color: Highlight !important; + } +} + +:host([disabled]) { + --sbb-step-label-color: var(--sbb-color-granite); + --sbb-step-label-prefix-border-style: dashed; + + @include sbb.if-forced-colors { + --sbb-step-label-color: GrayText !important; + } +} + +:host(:hover:not([disabled])) { + @include sbb.hover-mq($hover: true) { + --sbb-step-label-cursor: pointer; + --sbb-step-label-prefix-background-color: var(--sbb-color-milk); + --sbb-step-label-translate-y-content-hover: #{sbb.px-to-rem-build(-1)}; + --sbb-step-label-prefix-size-grow-hover: calc(var(--sbb-border-width-2x) * -1); + } +} + +// Hide focus outline when focus origin is mouse or touch. This is being used as a workaround in various components. +:host(:focus-visible:not([data-focus-origin='mouse'], [data-focus-origin='touch'])) { + @include sbb.focus-outline; + + border-radius: var(--sbb-border-radius-1x); +} + +:host([data-orientation='vertical']) { + transition: margin var(--sbb-stepper-animation-duration) var(--sbb-animation-easing); +} + +:host([data-orientation='vertical']:not(:first-of-type)) { + margin-block-start: var(--sbb-spacing-fixed-6x); +} + +:host([data-selected][data-orientation='vertical']) { + margin-block-end: var(--sbb-spacing-fixed-8x); +} + +.sbb-step-label { + @include sbb.text-l--bold; + + cursor: var(--sbb-step-label-cursor); + position: relative; + display: flex; + gap: var(--sbb-spacing-fixed-4x); + color: var(--sbb-step-label-color); +} + +.sbb-step-label__prefix { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: var(--sbb-step-label-prefix-size); + height: var(--sbb-step-label-prefix-size); + inset-block-start: calc( + 1em * (var(--sbb-typo-line-height-body-text) / 2) + (var(--sbb-border-width-1x) / 2) - + (var(--sbb-step-label-prefix-size) / 2) + ); + + &::before { + content: ''; + position: absolute; + inset: calc(var(--sbb-step-label-prefix-size-grow-hover, #{sbb.px-to-rem-build(0)})); + border-radius: var(--sbb-border-radius-infinity); + border: var(--sbb-border-width-1x) var(--sbb-step-label-prefix-border-style) + var(--sbb-step-label-prefix-border-color); + background-color: var(--sbb-step-label-prefix-background-color); + transition: { + duration: var(--sbb-step-label-animation-duration); + timing-function: var(--sbb-animation-easing); + property: background-color, inset; + } + } +} + +.sbb-step-label__text { + :host([data-orientation='horizontal']) & { + @include sbb.ellipsis; + } +} + +::slotted(sbb-icon), +sbb-icon { + z-index: 1; + background-color: var(--sbb-step-label-prefix-background-color); + border-radius: var(--sbb-border-radius-infinity); + transform: translateY(var(--sbb-step-label-translate-y-content-hover, #{sbb.px-to-rem-build(0)})); + transition: { + duration: var(--sbb-step-label-animation-duration); + timing-function: var(--sbb-animation-easing); + property: background-color, transform; + } +} diff --git a/src/elements/stepper/step-label/step-label.spec.ts b/src/elements/stepper/step-label/step-label.spec.ts new file mode 100644 index 0000000000..e6d03e99e8 --- /dev/null +++ b/src/elements/stepper/step-label/step-label.spec.ts @@ -0,0 +1,56 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbStepLabelElement } from './step-label.js'; + +import './step-label.js'; + +describe('sbb-step-label', () => { + let root: SbbStepLabelElement; + + describe('renders', async () => { + beforeEach(async () => { + root = await fixture(html`Label`); + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('renders with icon', async () => { + beforeEach(async () => { + root = await fixture(html`Label`); + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + describe('renders disabled', async () => { + beforeEach(async () => { + root = await fixture(html`Label`); + }); + + it('DOM', async () => { + await expect(root).dom.to.be.equalSnapshot(); + }); + + it('Shadow DOM', async () => { + await expect(root).shadowDom.to.be.equalSnapshot(); + }); + }); + + testA11yTreeSnapshot(html`Label`); +}); diff --git a/src/elements/stepper/step-label/step-label.ssr.spec.ts b/src/elements/stepper/step-label/step-label.ssr.spec.ts new file mode 100644 index 0000000000..1c183b322e --- /dev/null +++ b/src/elements/stepper/step-label/step-label.ssr.spec.ts @@ -0,0 +1,20 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbStepLabelElement } from './step-label.js'; + +describe(`sbb-step-label ${fixture.name}`, () => { + let root: SbbStepLabelElement; + + beforeEach(async () => { + root = await fixture(html`Label`, { + modules: ['./step-label.js'], + }); + }); + + it('renders', () => { + assert.instanceOf(root, SbbStepLabelElement); + }); +}); diff --git a/src/elements/stepper/step-label/step-label.stories.ts b/src/elements/stepper/step-label/step-label.stories.ts new file mode 100644 index 0000000000..9c707f6584 --- /dev/null +++ b/src/elements/stepper/step-label/step-label.stories.ts @@ -0,0 +1,94 @@ +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 { sbbSpread } from '../../../storybook/helpers/spread.js'; + +import readme from './readme.md?raw'; +import './step-label.js'; + +const iconName: InputType = { + control: { + type: 'text', + }, +}; + +const disabled: InputType = { + control: { + type: 'boolean', + }, +}; + +const defaultArgTypes: ArgTypes = { + disabled, + 'icon-name': iconName, +}; + +const defaultArgs: Args = { + disabled: false, + 'icon-name': 'tick-small', +}; + +const Template = (args: Args): TemplateResult => + html`Label`; + +const LongLabelTemplate = (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.`; + +const SlottedIconTemplate = (args: Args): TemplateResult => + html` + + Label + `; + +export const Default: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs }, +}; + +export const Selected: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'data-selected': true }, +}; + +export const LongLabelVertical: StoryObj = { + render: LongLabelTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'data-selected': true }, +}; + +export const LongLabelHorizontal: StoryObj = { + render: LongLabelTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'data-selected': true, 'data-orientation': 'horizontal' }, +}; + +export const Disabled: StoryObj = { + render: Template, + argTypes: defaultArgTypes, + args: { ...defaultArgs, disabled: true }, +}; + +export const SlottedIcon: StoryObj = { + render: SlottedIconTemplate, + argTypes: defaultArgTypes, + args: { ...defaultArgs, 'icon-name': undefined }, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-stepper/sbb-step-label', +}; + +export default meta; diff --git a/src/elements/stepper/step-label/step-label.ts b/src/elements/stepper/step-label/step-label.ts new file mode 100644 index 0000000000..668dfb8c33 --- /dev/null +++ b/src/elements/stepper/step-label/step-label.ts @@ -0,0 +1,126 @@ +import { type CSSResultGroup, html, type TemplateResult, type PropertyValues } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { SbbButtonBaseElement } from '../../core/base-elements.js'; +import { SbbConnectedAbortController } from '../../core/controllers.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { SbbDisabledMixin } from '../../core/mixins.js'; +import { SbbIconNameMixin } from '../../icon.js'; +import type { SbbStepElement } from '../step.js'; +import type { SbbStepperElement } from '../stepper.js'; + +import style from './step-label.scss?lit&inline'; + +let nextId = 0; + +/** + * Combined with a `sbb-stepper`, it displays a step's label. + * + * @slot - Use the unnamed slot to provide a label. + * @slot icon - Use this to display an icon in the label bubble. + */ +@customElement('sbb-step-label') +@hostAttributes({ + slot: 'step-label', + tabindex: '-1', + role: 'tab', +}) +export class SbbStepLabelElement extends SbbIconNameMixin(SbbDisabledMixin(SbbButtonBaseElement)) { + public static override styles: CSSResultGroup = style; + + /** @internal */ + private readonly _internals: ElementInternals = this.attachInternals(); + + /** The step controlled by the label. */ + public get step(): SbbStepElement | null { + return this._step; + } + + /** + * Selects and configures the step label. + * @internal + */ + public select(): void { + this.tabIndex = 0; + this._internals.ariaSelected = 'true'; + this.toggleAttribute('data-selected', true); + } + + /** + * Deselects and configures the step label. + * @internal + */ + public deselect(): void { + this.tabIndex = -1; + this._internals.ariaSelected = 'false'; + this.toggleAttribute('data-selected', false); + } + + /** + * Configures the step label. + * @internal + */ + public configure(posInSet: number, setSize: number, stepperLoaded: boolean): void { + if (stepperLoaded) { + this._step = this._getStep(); + } + this._internals.ariaPosInSet = `${posInSet}`; + this._internals.ariaSetSize = `${setSize}`; + } + + private _abort = new SbbConnectedAbortController(this); + private _stepper: SbbStepperElement | null = null; + private _step: SbbStepElement | null = null; + + private _getStep(): SbbStepElement | null { + let nextSibling = this.nextElementSibling; + while (nextSibling && nextSibling.localName !== 'sbb-step') { + nextSibling = nextSibling.nextElementSibling; + } + return nextSibling as SbbStepElement; + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.id = this.id || `sbb-step-label-${nextId++}`; + this._internals.ariaSelected = 'false'; + this._stepper = this.closest('sbb-stepper'); + this._step = this._getStep(); + // The `data-disabled` attribute is used to preserve the initial disabled state of + // step labels in case of switching from linear to non-linear mode. + this.toggleAttribute('data-disabled', this.hasAttribute('disabled')); + this.addEventListener( + 'click', + () => { + if (this._stepper && this._step) { + this._stepper.selected = this._step; + } + }, + { signal }, + ); + } + + protected override firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + if (this.step) { + this.setAttribute('aria-controls', this.step.id); + } + } + + protected override render(): TemplateResult { + return html` +
+ ${this.renderIconSlot()} + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'sbb-step-label': SbbStepLabelElement; + } +} diff --git a/src/elements/stepper/step.ts b/src/elements/stepper/step.ts new file mode 100644 index 0000000000..228e839434 --- /dev/null +++ b/src/elements/stepper/step.ts @@ -0,0 +1 @@ +export * from './step/step.js'; diff --git a/src/elements/stepper/step/__snapshots__/step.spec.snap.js b/src/elements/stepper/step/__snapshots__/step.spec.snap.js new file mode 100644 index 0000000000..fca8e7ea22 --- /dev/null +++ b/src/elements/stepper/step/__snapshots__/step.spec.snap.js @@ -0,0 +1,44 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["sbb-step renders - DOM"] = +` + Step content + +`; +/* end snapshot sbb-step renders - DOM */ + +snapshots["sbb-step renders - Shadow DOM"] = +`
+
+ + +
+
+`; +/* end snapshot sbb-step renders - Shadow DOM */ + +snapshots["sbb-step A11y tree Chrome"] = +`

+ { + "role": "WebArea", + "name": "" +} +

+`; +/* end snapshot sbb-step A11y tree Chrome */ + +snapshots["sbb-step A11y tree Firefox"] = +`

+ { + "role": "document", + "name": "" +} +

+`; +/* end snapshot sbb-step A11y tree Firefox */ + diff --git a/src/elements/stepper/step/readme.md b/src/elements/stepper/step/readme.md new file mode 100644 index 0000000000..952a9cbf43 --- /dev/null +++ b/src/elements/stepper/step/readme.md @@ -0,0 +1,46 @@ +Use the `sbb-step` with the `sbb-stepper` to display a step content. + +```html +Step content +``` + +## Slots + +It has an implicit slot named `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 content

+ Button +
+``` + +The aria attribute `aria-labelledby` is set automatically. + + + +## Properties + +| Name | Attribute | Privacy | Type | Default | Description | +| ------- | --------- | ------- | ----------------------------- | ------- | ---------------------- | +| `label` | - | public | `SbbStepLabelElement \| null` | `null` | The label of the step. | + +## Events + +| Name | Type | Description | Inherited From | +| ---------- | ------------------------------------------ | --------------------------------------------------------- | -------------- | +| `validate` | `CustomEvent` | Emits whenever step switch is triggered. Can be canceled. | | + +## Slots + +| Name | Description | +| ---- | ---------------------------------------- | +| | Use the unnamed slot to provide content. | diff --git a/src/elements/stepper/step/step.scss b/src/elements/stepper/step/step.scss new file mode 100644 index 0000000000..7c5cb84b0f --- /dev/null +++ b/src/elements/stepper/step/step.scss @@ -0,0 +1,71 @@ +@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-step-position: initial; + --sbb-step-inset-block-start: unset; + --sbb-step-opacity: 0; + --sbb-step-visibility: hidden; + --sbb-step-height: 0; + --sbb-step-animation-duration: var( + --sbb-disable-animation-zero-time, + var(--sbb-animation-duration-2x) + ); + --sbb-step-animation-delay: 0; + --sbb-step-color: var(--sbb-color-iron); + + display: contents; +} + +:host([data-selected]) { + --sbb-step-opacity: 1; + --sbb-step-visibility: visible; + --sbb-step-height: fit-content; + --sbb-step-animation-duration: var(--sbb-animation-duration-4x); + --sbb-step-animation-delay: var(--sbb-step-animation-duration); +} + +:host([data-orientation='horizontal']) { + --sbb-step-position: absolute; + --sbb-step-inset-block-start: 0; +} + +.sbb-step--wrapper { + :host([data-orientation='vertical']) & { + margin-inline-start: var(--sbb-spacing-fixed-4x); + opacity: 0; + height: 0; + transition: + height var(--sbb-stepper-animation-duration) var(--sbb-animation-easing), + opacity var(--sbb-step-animation-duration) var(--sbb-animation-easing); + } + + :host([data-selected][data-orientation='vertical']) & { + opacity: 1; + height: var(--sbb-stepper-content-height); + transition: + height var(--sbb-stepper-animation-duration) var(--sbb-animation-easing), + opacity var(--sbb-step-animation-duration) var(--sbb-stepper-animation-duration) + var(--sbb-animation-easing); + } +} + +.sbb-step { + @include sbb.text-m--regular; + + position: var(--sbb-step-position); + width: 100%; + inset-block-start: var(--sbb-step-inset-block-start); + opacity: var(--sbb-step-opacity); + visibility: var(--sbb-step-visibility); + height: var(--sbb-step-height); + color: var(--sbb-step-color); + transition: { + property: opacity, visibility; + duration: var(--sbb-step-animation-duration); + delay: var(--sbb-step-animation-delay); + timing-function: var(--sbb-animation-easing); + } +} diff --git a/src/elements/stepper/step/step.spec.ts b/src/elements/stepper/step/step.spec.ts new file mode 100644 index 0000000000..ef3740060f --- /dev/null +++ b/src/elements/stepper/step/step.spec.ts @@ -0,0 +1,25 @@ +import { expect } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import { fixture, testA11yTreeSnapshot } from '../../core/testing/private.js'; + +import type { SbbStepElement } from './step.js'; +import './step.js'; + +describe('sbb-step', () => { + let element: SbbStepElement; + + beforeEach(async () => { + element = await fixture(html`Step content`); + }); + + 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/step/step.ssr.spec.ts b/src/elements/stepper/step/step.ssr.spec.ts new file mode 100644 index 0000000000..ccdae1293d --- /dev/null +++ b/src/elements/stepper/step/step.ssr.spec.ts @@ -0,0 +1,18 @@ +import { assert } from '@open-wc/testing'; +import { html } from 'lit'; + +import { fixture } from '../../core/testing/private.js'; + +import { SbbStepElement } from './step.js'; + +describe(`sbb-step ${fixture.name}`, () => { + let root: SbbStepElement; + + beforeEach(async () => { + root = await fixture(html`Step`, { modules: ['./step.js'] }); + }); + + it('renders', () => { + assert.instanceOf(root, SbbStepElement); + }); +}); diff --git a/src/elements/stepper/step/step.stories.ts b/src/elements/stepper/step/step.stories.ts new file mode 100644 index 0000000000..9485eba7bb --- /dev/null +++ b/src/elements/stepper/step/step.stories.ts @@ -0,0 +1,25 @@ +import { withActions } from '@storybook/addon-actions/decorator'; +import type { Decorator, Meta, StoryObj } from '@storybook/web-components'; +import { html, type TemplateResult } from 'lit'; + +import readme from './readme.md?raw'; +import './step.js'; + +const Template = (): TemplateResult => + html`Step content.`; + +export const Default: StoryObj = { + render: Template, +}; + +const meta: Meta = { + decorators: [withActions as Decorator], + parameters: { + docs: { + extractComponentDescription: () => readme, + }, + }, + title: 'elements/sbb-stepper/sbb-step', +}; + +export default meta; diff --git a/src/elements/stepper/step/step.ts b/src/elements/stepper/step/step.ts new file mode 100644 index 0000000000..6ec7b88428 --- /dev/null +++ b/src/elements/stepper/step/step.ts @@ -0,0 +1,184 @@ +import { + type CSSResultGroup, + html, + LitElement, + type TemplateResult, + type PropertyValues, +} from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; + +import { SbbConnectedAbortController } from '../../core/controllers.js'; +import { hostAttributes } from '../../core/decorators.js'; +import { EventEmitter } from '../../core/eventing.js'; +import { AgnosticResizeObserver } from '../../core/observers.js'; +import type { SbbStepLabelElement } from '../step-label.js'; +import type { SbbStepperElement } from '../stepper.js'; + +import style from './step.scss?lit&inline'; + +let nextId = 0; + +export type SbbStepValidateEventDetails = { + currentIndex?: number; + currentStep?: SbbStepElement; + nextIndex?: number; + nextStep?: SbbStepElement; +}; + +/** + * Combined with a `sbb-stepper`, it displays a step's content. + * + * @slot - Use the unnamed slot to provide content. + * @event {CustomEvent} validate - Emits whenever step switch is triggered. Can be canceled. + */ +@customElement('sbb-step') +@hostAttributes({ + slot: 'step', + role: 'tabpanel', +}) +export class SbbStepElement extends LitElement { + public static override styles: CSSResultGroup = style; + public static readonly events = { + validate: 'validate', + } as const; + + /** Emits whenever step switch is triggered. */ + private _validate: EventEmitter = new EventEmitter( + this, + SbbStepElement.events.validate, + ); + + private _loaded: boolean = false; + private _abort = new SbbConnectedAbortController(this); + private _stepper: SbbStepperElement | null = null; + private _label: SbbStepLabelElement | null = null; + private _stepResizeObserver = new AgnosticResizeObserver((entries) => + this._onStepElementResize(entries), + ); + + /** The label of the step. */ + public get label(): SbbStepLabelElement | null { + return this._label; + } + + /** + * Selects and configures the step. + * @internal + */ + public select(): void { + if (!this._loaded || !this.label) { + return; + } + this.toggleAttribute('data-selected', true); + this.label.select(); + } + + /** + * Deselects and configures the step. + * @internal + */ + public deselect(): void { + if (!this.label) { + return; + } + this.toggleAttribute('data-selected', false); + this.label.deselect(); + } + + /** + * Emits a validate event whenever step switch is triggered. + * @internal + */ + public validate(eventData: SbbStepValidateEventDetails): boolean { + return !!this._validate.emit(eventData); + } + + /** + * Configures the step. + * @internal + */ + public configure(stepperLoaded: boolean): void { + if (stepperLoaded) { + this._label = this._getStepLabel(); + } + if (this.label) { + this.setAttribute('aria-labelledby', this.label.id); + } + } + + /** Watches for clicked elements with `sbb-stepper-next` or `sbb-stepper-previous` attributes. */ + private _handleClick(event: Event): void { + const composedPathElements = event + .composedPath() + .filter((el) => el instanceof window.HTMLElement); + if (composedPathElements.some((el) => this._isGoNextElement(el as HTMLElement))) { + this._stepper?.next(); + } else if (composedPathElements.some((el) => this._isGoPreviousElement(el as HTMLElement))) { + this._stepper?.previous(); + } + } + + private _isGoNextElement(element: HTMLElement): boolean { + return element.hasAttribute('sbb-stepper-next') && !element.hasAttribute('disabled'); + } + + private _isGoPreviousElement(element: HTMLElement): boolean { + return element.hasAttribute('sbb-stepper-previous') && !element.hasAttribute('disabled'); + } + + private _onStepElementResize(entries: ResizeObserverEntry[]): void { + if (!this.hasAttribute('data-selected')) { + return; + } + const contentHeight = Math.floor(entries[0].contentRect.height); + this._stepper?.style?.setProperty('--sbb-stepper-content-height', `${contentHeight}px`); + } + + private _getStepLabel(): SbbStepLabelElement | null { + let previousSibling = this.previousElementSibling; + while (previousSibling && previousSibling.localName !== 'sbb-step-label') { + previousSibling = previousSibling.previousElementSibling; + } + return previousSibling as SbbStepLabelElement; + } + + public override connectedCallback(): void { + super.connectedCallback(); + const signal = this._abort.signal; + this.id = this.id || `sbb-step-${nextId++}`; + this.addEventListener('click', (e) => this._handleClick(e), { signal }); + this._stepper = this.closest('sbb-stepper'); + this._label = this._getStepLabel(); + } + + protected override firstUpdated(changedProperties: PropertyValues): 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 + + + Step label 1 + Step content 1: ... + + Step label 2 + Step content 2: ... + + +``` + +### 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.
+ +
+ + Step 2 + +
Step two content.
+ + 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` +
+ + Step 1 + + Step one content. + + + + Step 2 + Step two content. + + Step 3 + + Step three content. + + + +
+ `); + + 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` +
{ + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + for (const [name, value] of formData) { + document.querySelector(`.text-block-${name}`)!.textContent = value.toString(); + } + }} + @reset=${() => { + // This is needed to focus and trigger again the error on the first field + // when getting back to it after resetting the stepper. + setTimeout(() => + document.querySelector('input[name="name"]')?.dispatchEvent(new Event('input')), + ); + }} + > + + Step 1 + { + if (e.detail.currentStep.querySelector('sbb-form-field').hasAttribute('data-invalid')) { + e.preventDefault(); + } + }} + > +
+ + + { + const input = event.currentTarget as HTMLInputElement; + if (input.value !== '') { + sbbFormError.remove(); + input.classList.remove('sbb-invalid'); + } else { + input.closest('sbb-form-field')!.append(sbbFormError); + input.classList.add('sbb-invalid'); + } + }} + required + placeholder="Your name" + name="name" + value="Christina Müller" + /> + +
+ Next +
+ + Step 2 + +
+ + + + +
+ Back + Next +
+ + + + Step 3 + + +
+ + + + +
+ Back + Next +
+ + Step 4 + +
+ You are now done. +
+ Back + Submit + document.querySelector('sbb-stepper')?.reset()} + >Reset +
+
+
+ + 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(); + } + }} + > +
+
{ + // This is needed to focus and trigger again the error on the first field + // when getting back to it after resetting the stepper. + setTimeout(() => + document.querySelector('input[name="name"]')?.dispatchEvent(new Event('input')), + ); + }} + > + + + { + const input = event.currentTarget as HTMLInputElement; + if (input.value !== '') { + sbbFormError.remove(); + input.classList.remove('sbb-invalid'); + } else { + input.closest('sbb-form-field')!.append(sbbFormError); + input.classList.add('sbb-invalid'); + } + }} + required + placeholder="Your name" + name="name" + value="Christina Müller" + /> + +
+
+ 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" ] } ]