From 027958bd84d8e713e56c93d2473caf80923d395d Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Thu, 9 Apr 2026 22:09:00 +0200 Subject: [PATCH] refactor(wizard): use relative units for wizard sizing --- api-goldens/element-ng/wizard/index.api.md | 1 + ...l-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- .../wizard/si-wizard.component.scss | 22 +++--- .../wizard/si-wizard.component.spec.ts | 5 +- .../element-ng/wizard/si-wizard.component.ts | 67 +++++++++++++++++-- 6 files changed, 82 insertions(+), 21 deletions(-) diff --git a/api-goldens/element-ng/wizard/index.api.md b/api-goldens/element-ng/wizard/index.api.md index 249bd40d28..d6bce879d4 100644 --- a/api-goldens/element-ng/wizard/index.api.md +++ b/api-goldens/element-ng/wizard/index.api.md @@ -11,6 +11,7 @@ import { TranslatableString } from '@siemens/element-translate-ng/translate'; // @public (undocumented) export class SiWizardComponent { + constructor(); back(delta?: number): void; readonly backText: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; readonly cancelText: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-dark-linux.png index c6016031d3..ac52cace2a 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b1403b915c8d19c4d787ca3d3ecf35c1c81756dd0f69a817818f4fd112b8449 -size 13253 +oid sha256:f156c4702ac48c54c18bca11836f89f3b19bbe9ea9c7a8397e3a50ad9f8e21aa +size 14329 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-light-linux.png index 340a4c7c03..9b4383f76b 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-wizard--si-wizard-dynamical-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bb73a4373c1587ace22e5ee7a65bf6c3862305e26e2a027ce811426f8ce02a3 -size 13365 +oid sha256:c543ee769338968a0fb28080c20cef6ccdb0f8a3f5d97f47e627d2d4a01062c7 +size 14420 diff --git a/projects/element-ng/wizard/si-wizard.component.scss b/projects/element-ng/wizard/si-wizard.component.scss index 0384bd18c2..19d886460c 100644 --- a/projects/element-ng/wizard/si-wizard.component.scss +++ b/projects/element-ng/wizard/si-wizard.component.scss @@ -58,7 +58,7 @@ $wizard-vertical-divider-border-color: variables.$element-ui-4; max-inline-size: $wizard-vertical-max-inline-size; .step { - grid-template-columns: 24px 1fr; + grid-template-columns: 1.5rem 1fr; grid-template-rows: auto auto auto; align-items: center; @@ -169,7 +169,7 @@ $wizard-vertical-divider-border-color: variables.$element-ui-4; &:focus-visible { .step-icon { @include variables.make-outline-focus-inside(); - border-radius: 12px; + border-radius: 0.75rem; &.number-step { outline: none; @@ -270,8 +270,8 @@ $wizard-vertical-divider-border-color: variables.$element-ui-4; } .wizard-btn-container { - max-inline-size: 50px; - min-inline-size: 40px; + min-inline-size: 8ch; + flex-shrink: 1; text-align: center; cursor: pointer; @@ -289,9 +289,9 @@ $wizard-vertical-divider-border-color: variables.$element-ui-4; } .circle { - min-inline-size: 18px; - min-block-size: 18px; - border-radius: 9px; + min-inline-size: 1.125rem; + min-block-size: 1.125rem; + border-radius: 0.75rem; border-width: 1px; border-style: solid; border-color: currentColor; @@ -299,8 +299,9 @@ $wizard-vertical-divider-border-color: variables.$element-ui-4; } .number-step { - min-inline-size: 24px; - min-block-size: 24px; + min-inline-size: 1.5rem; + min-block-size: 1.5rem; + text-align: center; } .wizard-footer { @@ -318,7 +319,8 @@ $wizard-vertical-divider-border-color: variables.$element-ui-4; margin-inline-start: auto; } } -@container (max-width: 400px) { + +@container (max-width: 25em) { .wizard-footer-inner { flex-direction: column; align-items: stretch; diff --git a/projects/element-ng/wizard/si-wizard.component.spec.ts b/projects/element-ng/wizard/si-wizard.component.spec.ts index 2207424a8c..eb2f480995 100644 --- a/projects/element-ng/wizard/si-wizard.component.spec.ts +++ b/projects/element-ng/wizard/si-wizard.component.spec.ts @@ -61,11 +61,12 @@ describe('SiWizardComponent', () => { let element: HTMLElement; beforeEach(async () => { + await TestBed.compileComponents(); fixture = TestBed.createComponent(TestHostComponent); hostComponent = fixture.componentInstance; component = fixture.componentInstance.wizard(); element = fixture.nativeElement.querySelector('si-wizard'); - fixture.detectChanges(); + await fixture.whenStable(); }); it('stepCount should match number of steps', () => { @@ -272,7 +273,7 @@ describe('SiWizardComponent', () => { it('should calculate visible items', async () => { hostComponent.generateSteps(10); await fixture.whenStable(); - expect(element.querySelectorAll('.container-steps .step').length).toBe(7); + expect(element.querySelectorAll('.container-steps .step').length).toBeGreaterThanOrEqual(7); element.querySelector('.next')!.click(); }); diff --git a/projects/element-ng/wizard/si-wizard.component.ts b/projects/element-ng/wizard/si-wizard.component.ts index 378fff29e1..4c978a31b2 100644 --- a/projects/element-ng/wizard/si-wizard.component.ts +++ b/projects/element-ng/wizard/si-wizard.component.ts @@ -10,6 +10,7 @@ import { computed, contentChildren, ElementRef, + inject, input, linkedSignal, output, @@ -17,6 +18,7 @@ import { untracked, viewChild } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { elementCancel, elementChecked, @@ -27,10 +29,15 @@ import { elementRight4, elementWarningFilled } from '@siemens/element-icons'; -import { WebComponentContentChildren } from '@siemens/element-ng/common'; +import { TextMeasureService, WebComponentContentChildren } from '@siemens/element-ng/common'; import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; import { SiResizeObserverDirective } from '@siemens/element-ng/resize-observer'; -import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; +import { + injectSiTranslateService, + SiTranslatePipe, + t +} from '@siemens/element-translate-ng/translate'; +import { switchMap } from 'rxjs'; import { SiWizardStepComponent } from './si-wizard-step.component'; @@ -61,6 +68,16 @@ interface StepMetadata { } }) export class SiWizardComponent { + /** em-based multipliers relative to the step title font-size. */ + private static readonly minStepWidthEm = 6; + private static readonly maxStepWidthEm = 14; + private static readonly defaultStepWidthEm = 11; + private static readonly fallbackFontSize = 14; + /** Fixed horizontal padding of the step title (px-6 = 2 × 16px). */ + private static readonly stepPadding = 32; + private readonly translateService = injectSiTranslateService(); + private readonly textMeasureService = inject(TextMeasureService); + protected readonly containerSteps = viewChild>('containerSteps'); /** @@ -219,7 +236,10 @@ export class SiWizardComponent { protected readonly showCompletionPage = signal(false); /** The list of visible steps. */ protected readonly activeSteps = computed(() => this.computeVisibleSteps()); - + private readonly headingKeys = computed(() => this.steps().map(s => s.heading())); + private readonly maxStepWidth = signal( + SiWizardComponent.defaultStepWidthEm * SiWizardComponent.fallbackFontSize + ); private readonly _index = linkedSignal(() => { const currentStep = this._currentStep(); const currentStepIndex = currentStep ? this.steps().indexOf(currentStep) : 0; @@ -288,6 +308,17 @@ export class SiWizardComponent { }); }); + constructor() { + toObservable(this.headingKeys) + .pipe( + switchMap(keys => this.translateService.translateAsync(keys)), + takeUntilDestroyed() + ) + .subscribe(translations => + this.maxStepWidth.set(this.measureMaxTextWidth(Object.values(translations))) + ); + } + protected activateStep(event: Event, stepIndex: number): void { event.preventDefault(); if (this.stepsMetadata()[stepIndex].canActivate) { @@ -374,6 +405,30 @@ export class SiWizardComponent { this._index.set(this.steps().indexOf(step)); } + private measureMaxTextWidth(texts: string[]): number { + const titleEl = + this.containerSteps()?.nativeElement.querySelector('.title') ?? undefined; + const fontSize = titleEl + ? parseFloat(getComputedStyle(titleEl).fontSize) + : SiWizardComponent.fallbackFontSize; + const defaultWidth = SiWizardComponent.defaultStepWidthEm * fontSize; + + if (texts.length === 0) { + return defaultWidth; + } + + const minWidth = SiWizardComponent.minStepWidthEm * fontSize; + const maxWidth = SiWizardComponent.maxStepWidthEm * fontSize; + + // Only take texts into account which aren't much shorter than the longest text. + const maxCharLength = Math.max(...texts.map(text => text.length)); + const candidates = texts.filter(text => text.length >= maxCharLength * 0.8); + const maxTextWidth = Math.max( + ...candidates.map(text => this.textMeasureService.measureText(text, titleEl)) + ); + return Math.min(Math.max(maxTextWidth + SiWizardComponent.stepPadding, minWidth), maxWidth); + } + protected updateVisibleSteps(): void { const newVisibleSteps = this.calculateVisibleStepCount(); if (newVisibleSteps !== this.visibleSteps()) { @@ -392,10 +447,12 @@ export class SiWizardComponent { containerSteps.nativeElement.clientHeight - parseInt(computedStyle.paddingBlockStart) - parseInt(computedStyle.paddingBlockEnd); - return Math.max(Math.floor(clientHeight / 48), 1); + const stepEl = containerSteps.nativeElement.querySelector('.step'); + const stepHeight = stepEl?.getBoundingClientRect().height ?? 48; + return Math.max(Math.floor(clientHeight / stepHeight), 1); } else { const clientWidth = containerSteps.nativeElement.clientWidth; - return Math.max(Math.floor(clientWidth / 150), 1); + return Math.max(Math.floor(clientWidth / this.maxStepWidth()), 1); } }