From 3bfc1f32a330f4ddb9b49e13df65287e09181855 Mon Sep 17 00:00:00 2001 From: chintankavathia Date: Wed, 7 Jan 2026 18:05:34 +0530 Subject: [PATCH 01/18] fix(filtered-search): omit daterange specific properties from datepickerConfig BREAKING CHANGE: `CriterionDefinition.datepickerConfig` type narrowed The type of `datepickerConfig` in `CriterionDefinition` (filtered-search) changed from `DatepickerInputConfig` to `Omit`. The properties `enableDateRange` and `enableTwoMonthDateRange ` are no longer accepted in `datepickerConfig` when used with filtered-search criteria, as they had no effect in that context. Migration: Remove any `enableDateRange` and `enableTwoMonthDateRange` properties from your `datepickerConfig` objects passed to `CriterionDefinition`. --- api-goldens/element-ng/filtered-search/index.api.md | 2 +- projects/element-ng/filtered-search/si-filtered-search.model.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api-goldens/element-ng/filtered-search/index.api.md b/api-goldens/element-ng/filtered-search/index.api.md index 6474fa691b..55268f3f9f 100644 --- a/api-goldens/element-ng/filtered-search/index.api.md +++ b/api-goldens/element-ng/filtered-search/index.api.md @@ -15,7 +15,7 @@ import { TranslatableString } from '@siemens/element-translate-ng/translate'; // @public export interface CriterionDefinition { - datepickerConfig?: DatepickerInputConfig; + datepickerConfig?: Omit; label?: TranslatableString; multiSelect?: boolean; name: string; diff --git a/projects/element-ng/filtered-search/si-filtered-search.model.ts b/projects/element-ng/filtered-search/si-filtered-search.model.ts index ed8b15d8fb..ca8c6c3f89 100644 --- a/projects/element-ng/filtered-search/si-filtered-search.model.ts +++ b/projects/element-ng/filtered-search/si-filtered-search.model.ts @@ -58,7 +58,7 @@ export interface CriterionDefinition { /** * Optional configuration object for the datepicker. */ - datepickerConfig?: DatepickerInputConfig; + datepickerConfig?: Omit; /** * Limit criterion options to the predefined ones. */ From 9513442e86269e49e21df503b4fbc9ce8d4e85a1 Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Sat, 9 May 2026 14:41:50 +0200 Subject: [PATCH 02/18] fix(theme): ensure the input line-height is consistent between browsers fixes #2031 --- .../src/styles/bootstrap/_variables.scss | 11 +++++------ .../src/styles/variables/_typography.scss | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/projects/element-theme/src/styles/bootstrap/_variables.scss b/projects/element-theme/src/styles/bootstrap/_variables.scss index 0fd0c91032..ea3cb58b50 100644 --- a/projects/element-theme/src/styles/bootstrap/_variables.scss +++ b/projects/element-theme/src/styles/bootstrap/_variables.scss @@ -331,7 +331,9 @@ $input-padding-x: (map.get(spacers.$spacers, 4) - 1px); // 8px including 1px bor $input-font-family: null !default; $input-font-size: $font-size-base; $input-font-weight: $font-weight-base; -$input-line-height: $line-height-base; +// Use an explicit rem value instead of a unitless ratio to prevent cross-browser +// rounding differences (Safari rounds 14 × 1.1428... to 15px, Chrome to 16px). See #2031 +$input-line-height: typography.$si-line-height-body-rem !default; $input-bg: semantic-tokens.$element-base-1 !default; $input-disabled-bg: semantic-tokens.$element-base-1 !default; @@ -353,13 +355,10 @@ $input-plaintext-color: $body-color !default; $input-height-border: $input-border-width * 2 !default; -$input-height: functions.add( - $input-line-height * 1em, - ($input-padding-y + $input-border-width) * 2 -) !default; +$input-height: functions.add(1lh, ($input-padding-y + $input-border-width) * 2) !default; // don't derive from $input-height as it generates a nested calc() expression -$input-height-inner: functions.add($input-line-height * 1em, $input-padding-y * 2) !default; +$input-height-inner: functions.add($input-line-height, $input-padding-y * 2) !default; // Wrap in parenthesis: `*` binds tighter than `-`, so without them only the border term gets multiplied. // DEPRECATED: Calculate it yourself using $input-height-inner $input-height-inner-half: calc((#{$input-height-inner}) * 0.5) !default; diff --git a/projects/element-theme/src/styles/variables/_typography.scss b/projects/element-theme/src/styles/variables/_typography.scss index 54f88ee611..478a302c00 100644 --- a/projects/element-theme/src/styles/variables/_typography.scss +++ b/projects/element-theme/src/styles/variables/_typography.scss @@ -64,6 +64,7 @@ $si-font-weight-body-lg: $si-font-weight-normal; $si-font-size-body: px-to-rem(14px); $si-line-height-body: px-to-ratio($si-font-size-body, 16px); +$si-line-height-body-rem: px-to-rem(16px); $si-font-weight-body: $si-font-weight-normal; $si-font-size-caption: px-to-rem(12px); From 3dc17b3556f1a0e56fd8cab39d29c3d6328a7cfa Mon Sep 17 00:00:00 2001 From: Daniel Ritz Date: Wed, 6 May 2026 18:23:26 +0200 Subject: [PATCH 03/18] fixup(theme): make circle-status size icon size + padding --- .../circle-status/si-circle-status.component.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/projects/element-ng/circle-status/si-circle-status.component.scss b/projects/element-ng/circle-status/si-circle-status.component.scss index 0c9bba1f54..13fcf9f262 100644 --- a/projects/element-ng/circle-status/si-circle-status.component.scss +++ b/projects/element-ng/circle-status/si-circle-status.component.scss @@ -1,9 +1,12 @@ +@use 'sass:map'; @use '@siemens/element-theme/src/styles/variables'; -$si-circle-status-size-regular: 2.5rem; +$si-circle-status-size-regular: calc(variables.$si-icon-font-size + 20px); $si-circle-status-indicator-size-regular: variables.$si-icon-font-size; $si-circle-status-bullet-size-regular: 0.625rem; -$si-circle-status-size-small: 2rem; +$si-circle-status-size-small: calc( + variables.$si-icon-font-size + 2 * map.get(variables.$spacers, 3) +); $si-circle-status-indicator-size-small: 1rem; $si-circle-status-bullet-size-small: 0.5rem; From bafb93efefa3e0258dbc0876f54a24cf31315008 Mon Sep 17 00:00:00 2001 From: Daniel Ritz Date: Wed, 6 May 2026 18:37:07 +0200 Subject: [PATCH 04/18] fixup(theme): make avatar size icon size + padding --- .../si-header-account-item.component.scss | 5 +++-- .../avatar/si-avatar.component.scss | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/projects/element-ng/application-header/si-header-account-item.component.scss b/projects/element-ng/application-header/si-header-account-item.component.scss index ae8d48e9a3..08e4c9b89c 100644 --- a/projects/element-ng/application-header/si-header-account-item.component.scss +++ b/projects/element-ng/application-header/si-header-account-item.component.scss @@ -1,6 +1,7 @@ @use 'sass:map'; @use '@siemens/element-theme/src/styles/variables/spacers'; -si-avatar { - --avatar-size: calc(1.5rem + 2 * #{map.get(spacers.$spacers, 2)}); +si-avatar.small { + // makes the avatar slightly larger with increased font size, aligning with the icons + --avatar-size: calc(1.5rem + #{2 * map.get(spacers.$spacers, 2)}); } diff --git a/projects/element-ng/avatar/si-avatar.component.scss b/projects/element-ng/avatar/si-avatar.component.scss index a3556a8b8e..be91859123 100644 --- a/projects/element-ng/avatar/si-avatar.component.scss +++ b/projects/element-ng/avatar/si-avatar.component.scss @@ -1,7 +1,8 @@ +@use 'sass:map'; @use '@siemens/element-theme/src/styles/variables'; :host { - --avatar-size: 2.5rem; + --avatar-size: calc(#{variables.$si-icon-font-size} + 20px); --indicator-size: #{variables.$si-icon-font-size}; --background: #{variables.$element-base-0}; --indicator-offset-x: -0.625rem; @@ -29,23 +30,29 @@ } &.tiny { - --avatar-size: 1.5rem; + --avatar-size: calc(#{variables.$si-icon-font-size} + #{2 * map.get(variables.$spacers, 1)}); } &.xsmall { - --avatar-size: 1.75rem; + --avatar-size: calc(#{variables.$si-icon-font-size} + #{2 * map.get(variables.$spacers, 2)}); } &.small { - --avatar-size: 2rem; + --avatar-size: calc(#{variables.$si-icon-font-size} + #{2 * map.get(variables.$spacers, 3)}); } &.large { - --avatar-size: 3.5rem; + --avatar-size: calc( + #{variables.$si-icon-font-size} + #{2 * + (map.get(variables.$spacers, 6) + map.get(variables.$spacers, 1))} + ); } &.xlarge { - --avatar-size: 5rem; + --avatar-size: calc( + #{variables.$si-icon-font-size} + #{2 * + (map.get(variables.$spacers, 8) + map.get(variables.$spacers, 3))} + ); } } From ff4f7ab2e7b6f6dd9be11e7d88e8020fdb61b646 Mon Sep 17 00:00:00 2001 From: Daniel Ritz Date: Wed, 6 May 2026 18:38:44 +0200 Subject: [PATCH 05/18] fix(theme): make .badge-text center aligned --- .../element-theme/src/styles/bootstrap/mixins/_badge-text.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/element-theme/src/styles/bootstrap/mixins/_badge-text.scss b/projects/element-theme/src/styles/bootstrap/mixins/_badge-text.scss index 2c59c8a66b..130010d3d6 100644 --- a/projects/element-theme/src/styles/bootstrap/mixins/_badge-text.scss +++ b/projects/element-theme/src/styles/bootstrap/mixins/_badge-text.scss @@ -13,4 +13,5 @@ padding-inline: map.get(spacers.$spacers, 2); font-family: variables.$font-family-sans-serif; font-weight: typography.$si-font-weight-semibold; + text-align: center; } From 776017be7329b8654fadc0453ab2a3677e6039a7 Mon Sep 17 00:00:00 2001 From: Daniel Ritz Date: Wed, 6 May 2026 19:03:48 +0200 Subject: [PATCH 06/18] fix(side-panel): fix position of legacy status action dot with increased RFS --- .../side-panel/si-side-panel-content.component.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/projects/element-ng/side-panel/si-side-panel-content.component.scss b/projects/element-ng/side-panel/si-side-panel-content.component.scss index 9b1fb67056..2c66230096 100644 --- a/projects/element-ng/side-panel/si-side-panel-content.component.scss +++ b/projects/element-ng/side-panel/si-side-panel-content.component.scss @@ -53,6 +53,11 @@ background: all-variables.$element-base-1-hover; } + &.dot-outer { + inline-size: 100%; + justify-content: center; + } + .icon { padding: map.get(all-variables.$spacers, 1); } From 270a66db2dfa7f841ed06634f125553a26a1ce22 Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Tue, 12 May 2026 11:10:25 +0200 Subject: [PATCH 07/18] refactor(tree): tree state indicator grow with root font size --- .../si-tree-view-item/si-tree-view-item.component.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.scss b/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.scss index 4122c3dac6..af99cb9c41 100644 --- a/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.scss +++ b/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.scss @@ -172,8 +172,8 @@ .si-tree-view-state-indicator { margin-block: 0; margin-inline: calc(var(--si-tree-view-padding-base-horizontal) * 0.75); - min-inline-size: map.get(variables.$spacers, 3); - block-size: map.get(variables.$spacers, 3); + min-inline-size: 0.375rem; + block-size: 0.375rem; border-radius: 50%; &.si-tree-view-state-indicator-endmost { From c0a61eca3100ab95a4443927b0534647907ada73 Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Tue, 12 May 2026 16:00:42 +0200 Subject: [PATCH 08/18] refactor(datatable): migrate to component bindings --- .../si-datatable-interaction.directive.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/projects/element-ng/datatable/si-datatable-interaction.directive.ts b/projects/element-ng/datatable/si-datatable-interaction.directive.ts index 5bde23cde6..2ff4ff91d1 100644 --- a/projects/element-ng/datatable/si-datatable-interaction.directive.ts +++ b/projects/element-ng/datatable/si-datatable-interaction.directive.ts @@ -6,8 +6,6 @@ import { booleanAttribute, Directive, ElementRef, - HostBinding, - HostListener, inject, input, isSignal, @@ -26,6 +24,13 @@ const unwrapSignalOrValue = (valueOrSignal: T | Signal): T => { @Directive({ selector: 'ngx-datatable[siDatatableInteraction]', + host: { + tabindex: '0', + '(keydown)': 'onKeydown($event)', + '(mousedown)': 'onMousedown($event)', + '(mouseup)': 'onMouseup($event)', + '(focusin)': 'onFocusin($event)' + }, exportAs: 'si-datatable-interaction' }) export class SiDatatableInteractionDirective implements OnDestroy, OnInit { @@ -38,8 +43,6 @@ export class SiDatatableInteractionDirective implements OnDestroy, OnInit { */ readonly datatableInteractionAutoSelect = input(false, { transform: booleanAttribute }); - @HostBinding('attr.tabindex') protected tabIndex = '0'; - private element: HTMLElement = inject(ElementRef).nativeElement; private tableBody?: HTMLElement; @@ -47,7 +50,6 @@ export class SiDatatableInteractionDirective implements OnDestroy, OnInit { private isMousedown = false; - @HostListener('keydown', ['$event']) protected onKeydown(event: KeyboardEvent): void { if (event.key === 'ArrowDown') { const first = @@ -74,17 +76,14 @@ export class SiDatatableInteractionDirective implements OnDestroy, OnInit { } } - @HostListener('mousedown', ['$event']) protected onMousedown(event: MouseEvent): void { this.isMousedown = true; } - @HostListener('mouseup', ['$event']) protected onMouseup(event: MouseEvent): void { this.isMousedown = false; } - @HostListener('focusin', ['$event']) protected onFocusin(event: FocusEvent): void { const target = event.target as HTMLElement; if (!target) { From 844f678abe5b352fcc7c2cb61e9e85ab00b42aae Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Wed, 6 May 2026 18:28:36 +0200 Subject: [PATCH 09/18] refactor(slider): use relative units for si-slider --- .../slider/si-slider.component.scss | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/projects/element-ng/slider/si-slider.component.scss b/projects/element-ng/slider/si-slider.component.scss index 72cd35baea..399fcbf80f 100644 --- a/projects/element-ng/slider/si-slider.component.scss +++ b/projects/element-ng/slider/si-slider.component.scss @@ -1,11 +1,12 @@ +@use 'sass:map'; @use '@siemens/element-theme/src/styles/variables'; -$button-width: 32px; -$button-height: 32px; +$button-size: calc(1lh + 2 * map.get(variables.$spacers, 4)); $button-gap: 4px; $track-height: 4px; -$thumb-size: 24px; +$thumb-size: 1.5rem; +$thumb-handle-size: calc($thumb-size + 2 * #{map.get(variables.$spacers, 4)}); :host { display: flex; @@ -13,22 +14,26 @@ $thumb-size: 24px; } .slider-container { - display: flex; - align-items: flex-end; + display: grid; + grid-template: auto $button-size / auto 1fr auto; + column-gap: $button-gap; } .decrement-button { - margin-inline-end: $button-gap; + grid-column: 1; + grid-row: 2; + align-self: center; } .increment-button { - margin-inline-start: $button-gap; + grid-column: 3; + grid-row: 2; + align-self: center; } .slider-wrapper { - position: relative; - flex: 1 1 0; - min-inline-size: 0; + grid-column: 2; + grid-row: 1 / -1; display: flex; flex-direction: column; } @@ -53,7 +58,7 @@ $thumb-size: 24px; .range-indicator-wrapper { margin-block: 0; - margin-inline: calc($button-width + $button-gap); + margin-inline: calc($button-size + $button-gap); .range-indicator { display: inline-flex; @@ -70,7 +75,7 @@ $thumb-size: 24px; .slider { position: relative; inline-size: 100%; - block-size: $button-height; + block-size: $button-size; cursor: pointer; &.dragging { @@ -108,10 +113,10 @@ $thumb-size: 24px; justify-content: center; position: absolute; inset-block-start: 50%; - margin-block-start: -20px; - margin-inline: -20px; - inline-size: 40px; - block-size: 40px; + margin-block-start: calc($thumb-handle-size / -2); + margin-inline: calc($thumb-handle-size / -2); + inline-size: $thumb-handle-size; + block-size: $thumb-handle-size; :host.disabled & { pointer-events: none; From 7655f664f75fcaae6e0b109e16e0d800def6238f Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Tue, 12 May 2026 10:47:51 +0200 Subject: [PATCH 10/18] refactor(select): prevent search icon from overlapping --- .../select-list/si-select-list-has-filter.component.html | 2 +- .../select-list/si-select-list-has-filter.component.scss | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/projects/element-ng/select/select-list/si-select-list-has-filter.component.html b/projects/element-ng/select/select-list/si-select-list-has-filter.component.html index e0e9b40660..c0a4ffa494 100644 --- a/projects/element-ng/select/select-list/si-select-list-has-filter.component.html +++ b/projects/element-ng/select/select-list/si-select-list-has-filter.component.html @@ -4,7 +4,7 @@ #filter #siAutocomplete="siAutocomplete" siAutocomplete - class="form-control ps-9 border-0 rounded-2 bg-base-0" + class="form-control icon-start border-0 rounded-2 bg-base-0" [placeholder]="filterPlaceholder() | translate" [id]="baseId() + 'filter-input'" [attr.aria-labelledby]="baseId() + '-aria-label' + ' ' + labelledby()" diff --git a/projects/element-ng/select/select-list/si-select-list-has-filter.component.scss b/projects/element-ng/select/select-list/si-select-list-has-filter.component.scss index 872b763815..c05ad08699 100644 --- a/projects/element-ng/select/select-list/si-select-list-has-filter.component.scss +++ b/projects/element-ng/select/select-list/si-select-list-has-filter.component.scss @@ -1,3 +1,4 @@ +@use 'sass:map'; @use '@siemens/element-theme/src/styles/variables'; .si-select-filtered-items { @@ -10,3 +11,7 @@ si-loading-spinner { --loading-spinner-size: #{variables.$si-icon-font-size}; } + +.icon-start { + padding-inline-start: calc(#{variables.$si-icon-font-size} + #{map.get(variables.$spacers, 5)}); +} From 8da23b46641d45fae23cb147a0ef7ea0e4203312 Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Tue, 12 May 2026 19:42:57 +0200 Subject: [PATCH 11/18] refactor(header-dropdown): migrate to component bindings --- .../si-header-dropdown-item.component.ts | 6 +-- .../si-header-dropdown-trigger.directive.ts | 18 ++++----- .../si-header-dropdown.component.ts | 38 +++---------------- 3 files changed, 18 insertions(+), 44 deletions(-) diff --git a/projects/element-ng/header-dropdown/si-header-dropdown-item.component.ts b/projects/element-ng/header-dropdown/si-header-dropdown-item.component.ts index 22b673da99..62f7e13469 100644 --- a/projects/element-ng/header-dropdown/si-header-dropdown-item.component.ts +++ b/projects/element-ng/header-dropdown/si-header-dropdown-item.component.ts @@ -2,7 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { ChangeDetectionStrategy, Component, HostListener, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { elementDown2, elementOk, elementRecordFilled } from '@siemens/element-icons'; import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; @@ -19,7 +19,8 @@ import { SI_HEADER_WITH_DROPDOWNS } from './si-header.model'; styleUrl: './si-header-dropdown-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: 'dropdown-item focus-inside' + class: 'dropdown-item focus-inside', + '(click)': 'click()' } }) export class SiHeaderDropdownItemComponent { @@ -43,7 +44,6 @@ export class SiHeaderDropdownItemComponent { protected readonly parentTrigger = inject(SiHeaderDropdownTriggerDirective, { skipSelf: true }); protected readonly navbar = inject(SI_HEADER_WITH_DROPDOWNS, { optional: true }); - @HostListener('click') protected click(): void { if (!this.ownTrigger) { this.parentTrigger.close({ all: true }); diff --git a/projects/element-ng/header-dropdown/si-header-dropdown-trigger.directive.ts b/projects/element-ng/header-dropdown/si-header-dropdown-trigger.directive.ts index e4f09f7fea..bebc1d810f 100644 --- a/projects/element-ng/header-dropdown/si-header-dropdown-trigger.directive.ts +++ b/projects/element-ng/header-dropdown/si-header-dropdown-trigger.directive.ts @@ -10,8 +10,6 @@ import { Directive, ElementRef, EmbeddedViewRef, - HostBinding, - HostListener, inject, Injector, input, @@ -41,7 +39,12 @@ class SiHeaderAnchorComponent { @Directive({ selector: '[siHeaderDropdownTriggerFor]', host: { - class: 'dropdown-toggle' + class: 'dropdown-toggle', + '[id]': 'id', + '[class.show]': '_isOpen', + '[attr.aria-expanded]': '_isOpen', + '[attr.aria-controls]': 'ariaControls', + '(click)': 'click()' }, exportAs: 'siHeaderDropdownTrigger' }) @@ -83,15 +86,13 @@ export class SiHeaderDropdownTriggerDirective implements OnChanges, OnInit, OnDe private destroying = false; /** @internal */ - @HostBinding('id') readonly id = - `si-navbar-dropdown-trigger-${SiHeaderDropdownTriggerDirective.idCounter++}`; + readonly id = `si-navbar-dropdown-trigger-${SiHeaderDropdownTriggerDirective.idCounter++}`; // eslint-disable-next-line @typescript-eslint/naming-convention - @HostBinding('class.show') @HostBinding('attr.aria-expanded') protected _isOpen = false; + protected _isOpen = false; /** @internal */ - @HostBinding('attr.aria-controls') readonly ariaControls = - `si-navbar-dropdown-${SiHeaderDropdownTriggerDirective.idCounter}`; + readonly ariaControls = `si-navbar-dropdown-${SiHeaderDropdownTriggerDirective.idCounter}`; private headerAnchorComponentRef?: ComponentRef; @@ -190,7 +191,6 @@ export class SiHeaderDropdownTriggerDirective implements OnChanges, OnInit, OnDe } } - @HostListener('click') protected click(): void { if (this._isOpen) { this.close(); diff --git a/projects/element-ng/header-dropdown/si-header-dropdown.component.ts b/projects/element-ng/header-dropdown/si-header-dropdown.component.ts index 3a3f28ba57..d31f55e153 100644 --- a/projects/element-ng/header-dropdown/si-header-dropdown.component.ts +++ b/projects/element-ng/header-dropdown/si-header-dropdown.component.ts @@ -3,15 +3,7 @@ * SPDX-License-Identifier: MIT */ import { A11yModule, CdkTrapFocus } from '@angular/cdk/a11y'; -import { - ChangeDetectionStrategy, - Component, - HostBinding, - HostListener, - inject, - viewChild, - DOCUMENT -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, viewChild, DOCUMENT } from '@angular/core'; import { SiHeaderDropdownTriggerDirective } from './si-header-dropdown-trigger.directive'; import { SI_HEADER_DROPDOWN_OPTIONS } from './si-header.model'; @@ -30,7 +22,11 @@ import { SI_HEADER_DROPDOWN_OPTIONS } from './si-header.model'; class: 'dropdown-menu position-static', role: 'group', '[id]': 'trigger.ariaControls', - '[attr.aria-labelledby]': 'trigger.id' + '[attr.aria-labelledby]': 'trigger.id', + '[class.show]': 'trigger.isOpen', + '[class.header-dropdown-overlay]': 'trigger.isOverlay', + '[class.sub-menu]': 'trigger.level > 1', + '(keydown.escape)': 'trigger.close()' } }) export class SiHeaderDropdownComponent { @@ -62,22 +58,6 @@ export class SiHeaderDropdownComponent { }); } - /** @internal */ - @HostBinding('class.show') - protected get show(): boolean { - return this.trigger.isOpen; - } - - /** @internal */ - @HostBinding('class.header-dropdown-overlay') protected get overlay(): boolean { - return this.trigger.isOverlay; - } - - /** @internal */ - @HostBinding('class.sub-menu') protected get submenu(): boolean { - return this.trigger.level > 1; - } - /** @internal */ protected get trapFocus(): boolean { return ( @@ -85,10 +65,4 @@ export class SiHeaderDropdownComponent { (!this.options?.disableRootFocusTrapForInlineMode && this.trigger.level === 1) ); } - - /** @internal */ - @HostListener('keydown.escape') - protected escape(): void { - this.trigger?.close(); - } } From baeada78825527da64fff235cc2ca9be3a21d8a7 Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Wed, 13 May 2026 06:59:18 +0200 Subject: [PATCH 12/18] refactor(menu): migrate to component bindings --- projects/element-ng/menu/si-menu-bar.directive.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/element-ng/menu/si-menu-bar.directive.ts b/projects/element-ng/menu/si-menu-bar.directive.ts index e29e4ccd2e..5ef44651d7 100644 --- a/projects/element-ng/menu/si-menu-bar.directive.ts +++ b/projects/element-ng/menu/si-menu-bar.directive.ts @@ -3,21 +3,21 @@ * SPDX-License-Identifier: MIT */ import { CdkMenuBar, CdkTargetMenuAim } from '@angular/cdk/menu'; -import { Directive, HostBinding, inject, input } from '@angular/core'; +import { Directive, inject, input } from '@angular/core'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector selector: 'si-menu-bar', host: { class: 'd-inline-flex', - style: 'gap: 1px' + style: 'gap: 1px', + '[tabindex]': 'tabIndex' }, hostDirectives: [CdkMenuBar, CdkTargetMenuAim] }) export class SiMenuBarDirective { private menuBar = inject(CdkMenuBar, { self: true }); - @HostBinding('tabindex') protected get tabIndex(): 0 | -1 | null { return this.disabled() ? -1 : this.menuBar._getTabIndex(); } From b682c4f194382aeb6105cae001cf36d04a79a008 Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Wed, 13 May 2026 07:09:14 +0200 Subject: [PATCH 13/18] refactor(navbar-vertical): migrate to component bindings --- .../si-navbar-vertical-group-trigger.directive.ts | 6 +++--- .../si-navbar-vertical-group.component.ts | 7 ++++--- .../si-navbar-vertical-item.component.ts | 15 ++++----------- .../si-navbar-vertical.component.ts | 6 +----- 4 files changed, 12 insertions(+), 22 deletions(-) diff --git a/projects/element-ng/navbar-vertical/si-navbar-vertical-group-trigger.directive.ts b/projects/element-ng/navbar-vertical/si-navbar-vertical-group-trigger.directive.ts index 280da80a6a..8c4fd6cea6 100644 --- a/projects/element-ng/navbar-vertical/si-navbar-vertical-group-trigger.directive.ts +++ b/projects/element-ng/navbar-vertical/si-navbar-vertical-group-trigger.directive.ts @@ -10,7 +10,6 @@ import { computed, Directive, EmbeddedViewRef, - HostListener, inject, Injector, input, @@ -47,7 +46,8 @@ class SiNavbarFlyoutAnchorComponent { '[id]': 'id', '[class.show]': 'expanded()', '[attr.aria-controls]': 'groupId', - '[attr.aria-expanded]': 'expanded()' + '[attr.aria-expanded]': 'expanded()', + '(click)': 'triggered()' } }) export class SiNavbarVerticalGroupTriggerDirective implements OnInit { @@ -111,7 +111,7 @@ export class SiNavbarVerticalGroupTriggerDirective implements OnInit { } } - @HostListener('click') protected triggered(): void { + protected triggered(): void { if (this.navbar.collapsed()) { this.toggleFlyout(); } else { diff --git a/projects/element-ng/navbar-vertical/si-navbar-vertical-group.component.ts b/projects/element-ng/navbar-vertical/si-navbar-vertical-group.component.ts index e3724a0ca1..ecde20f942 100644 --- a/projects/element-ng/navbar-vertical/si-navbar-vertical-group.component.ts +++ b/projects/element-ng/navbar-vertical/si-navbar-vertical-group.component.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: MIT */ import { CdkTrapFocus } from '@angular/cdk/a11y'; -import { Component, computed, HostListener, inject } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { RouterLinkActive } from '@angular/router'; @@ -31,7 +31,8 @@ import { SI_NAVBAR_VERTICAL } from './si-navbar-vertical.provider'; role: 'group', '[id]': 'groupTrigger.groupId', '[attr.aria-labelledby]': 'groupTrigger.id', - 'animate.enter': 'component-enter' + 'animate.enter': 'component-enter', + '(keydown.escape)': 'close()' } }) export class SiNavbarVerticalGroupComponent { @@ -52,7 +53,7 @@ export class SiNavbarVerticalGroupComponent { .subscribe(active => this.groupTrigger.active.set(active)); } - @HostListener('keydown.escape') protected close(): void { + protected close(): void { this.groupTrigger.hideFlyout(); } } diff --git a/projects/element-ng/navbar-vertical/si-navbar-vertical-item.component.ts b/projects/element-ng/navbar-vertical/si-navbar-vertical-item.component.ts index de9ea3b916..5dd27db57b 100644 --- a/projects/element-ng/navbar-vertical/si-navbar-vertical-item.component.ts +++ b/projects/element-ng/navbar-vertical/si-navbar-vertical-item.component.ts @@ -2,15 +2,7 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { - ChangeDetectionStrategy, - Component, - computed, - HostListener, - inject, - input, - OnInit -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, OnInit } from '@angular/core'; import { RouterLinkActive } from '@angular/router'; import { elementDown2 } from '@siemens/element-icons'; import { MenuItem } from '@siemens/element-ng/common'; @@ -44,7 +36,8 @@ type NavbarVerticalItemInteractive = '[class.dropdown-item]': 'this.parent?.group?.flyout()', '[class.navbar-vertical-item]': '!this.parent?.group?.flyout()', '[class.active]': 'active', - '[class.hide-badge-collapsed]': 'hideBadgeCollapsed()' + '[class.hide-badge-collapsed]': 'hideBadgeCollapsed()', + '(click)': 'triggered()' } }) export class SiNavbarVerticalItemComponent implements OnInit { @@ -101,7 +94,7 @@ export class SiNavbarVerticalItemComponent implements OnInit { } } - @HostListener('click') protected triggered(): void { + protected triggered(): void { const item = this.item(); if (item.type === 'action') { item.action(item); diff --git a/projects/element-ng/navbar-vertical/si-navbar-vertical.component.ts b/projects/element-ng/navbar-vertical/si-navbar-vertical.component.ts index d44698edfd..9baf7ffb6a 100644 --- a/projects/element-ng/navbar-vertical/si-navbar-vertical.component.ts +++ b/projects/element-ng/navbar-vertical/si-navbar-vertical.component.ts @@ -9,7 +9,6 @@ import { Component, computed, Directive, - HostBinding, inject, input, model, @@ -80,7 +79,7 @@ export class SiNavbarVerticalItemGuardDirective { styleUrl: './si-navbar-vertical.component.scss', providers: [{ provide: SI_NAVBAR_VERTICAL, useExisting: SiNavbarVerticalComponent }], host: { - class: 'si-layout-inner', + class: 'si-layout-inner ready', '[class.nav-collapsed]': 'collapsed()', '[class.nav-text-only]': 'textOnly()', '[class.visible]': 'visible()' @@ -208,9 +207,6 @@ export class SiNavbarVerticalComponent implements OnChanges, OnInit { private readonly searchBar = viewChild.required(SiSearchBarComponent); protected readonly activatedRoute = inject(ActivatedRoute, { optional: true }); - // Is required to prevent the navbar from running the padding animation on creation. - @HostBinding('class.ready') protected readonly ready = true; - private uiStateService = inject(SI_UI_STATE_SERVICE, { optional: true }); private breakpointObserver = inject(BreakpointObserver); private readonly navbarItems = viewChildren(SiNavbarVerticalItemComponent); From 61d8b93b78b0a5073c432f7bb24d15c5a59aa61f Mon Sep 17 00:00:00 2001 From: "Bajohr, Rayk" Date: Wed, 13 May 2026 10:02:42 +0200 Subject: [PATCH 14/18] refactor(select): migrate to component bindings --- .../select-input/si-select-input.component.ts | 17 ++++++++--------- .../select/select-list/si-select-list.base.ts | 8 +++----- .../select/si-select-action.directive.ts | 6 +++--- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/projects/element-ng/select/select-input/si-select-input.component.ts b/projects/element-ng/select/select-input/si-select-input.component.ts index 21615d93ec..407c219d7a 100644 --- a/projects/element-ng/select/select-input/si-select-input.component.ts +++ b/projects/element-ng/select/select-input/si-select-input.component.ts @@ -7,7 +7,6 @@ import { ChangeDetectionStrategy, Component, computed, - HostListener, inject, input, output, @@ -46,7 +45,14 @@ import { SelectOption } from '../si-select.types'; '[attr.aria-disabled]': 'selectionStrategy.disabled()', '[attr.tabindex]': 'selectionStrategy.disabled() ? "-1" : "0"', '[class.disabled]': 'selectionStrategy.disabled()', - '[class.active]': 'open()' + '[class.active]': 'open()', + '(blur)': 'blur()', + '(click)': 'click($event)', + '(keydown.arrowDown)': 'click($event)', + '(keydown.alt.arrowDown)': 'click($event)', + '(keydown.arrowUp)': 'click($event)', + '(keydown.enter)': 'click($event)', + '(keydown.space)': 'click($event)' } }) export class SiSelectInputComponent { @@ -85,19 +91,12 @@ export class SiSelectInputComponent { protected readonly labeledBy = computed(() => `${this.baseId()}-aria-label ${this.labelledby()}`); protected readonly icons = addIcons({ elementDown2 }); - @HostListener('blur') protected blur(): void { if (!this.open()) { this.selectionStrategy.onTouched(); } } - @HostListener('click') - @HostListener('keydown.arrowDown', ['$event']) - @HostListener('keydown.alt.arrowDown', ['$event']) - @HostListener('keydown.arrowUp', ['$event']) - @HostListener('keydown.enter') - @HostListener('keydown.space') protected click(event?: Event): void { event?.preventDefault(); this.openListbox.emit(); diff --git a/projects/element-ng/select/select-list/si-select-list.base.ts b/projects/element-ng/select/select-list/si-select-list.base.ts index 0f0714c024..d7a45adcec 100644 --- a/projects/element-ng/select/select-list/si-select-list.base.ts +++ b/projects/element-ng/select/select-list/si-select-list.base.ts @@ -6,8 +6,6 @@ import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cd import { Directive, ElementRef, - HostBinding, - HostListener, inject, input, OnDestroy, @@ -25,7 +23,9 @@ import { SelectGroup, SelectOption } from '../si-select.types'; @Directive({ host: { - class: 'dropdown-menu position-static w-100 py-4 d-flex flex-column' + class: 'dropdown-menu position-static w-100 py-4 d-flex flex-column', + '[class.si-multi-select]': 'multiSelect', + '(keydown.tab)': 'keydownTab()' } }) export abstract class SiSelectListBase implements OnInit, OnDestroy { @@ -54,7 +54,6 @@ export abstract class SiSelectListBase implements OnInit, OnDestroy { protected rows = this.selectOptions.rows; protected focusTrap!: ConfigurableFocusTrap; - @HostBinding('class.si-multi-select') protected multiSelect = this.selectionStrategy.allowMultiple; ngOnInit(): void { @@ -65,7 +64,6 @@ export abstract class SiSelectListBase implements OnInit, OnDestroy { this.focusTrap.destroy(); } - @HostListener('keydown.tab') protected keydownTab(): void { // Ignore tab key if actions are displayed. if (!this.actionsTemplate()) { diff --git a/projects/element-ng/select/si-select-action.directive.ts b/projects/element-ng/select/si-select-action.directive.ts index 3b8c26c158..3b48964371 100644 --- a/projects/element-ng/select/si-select-action.directive.ts +++ b/projects/element-ng/select/si-select-action.directive.ts @@ -2,14 +2,15 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { booleanAttribute, Directive, HostListener, inject, input } from '@angular/core'; +import { booleanAttribute, Directive, inject, input } from '@angular/core'; import { SiSelectComponent } from './si-select.component'; @Directive({ selector: '[siSelectAction]', host: { - class: 'mx-5 my-4' + class: 'mx-5 my-4', + '(click)': 'close()' }, exportAs: 'si-select-action' }) @@ -21,7 +22,6 @@ export class SiSelectActionDirective { */ readonly selectActionAutoClose = input(false, { transform: booleanAttribute }); - @HostListener('click') protected close(): void { if (this.selectActionAutoClose()) { this.select.close(); From c5393cd6df92c826a5a54f158fb8c959ff01549f Mon Sep 17 00:00:00 2001 From: "akash.sonune" Date: Tue, 12 May 2026 23:45:43 +0530 Subject: [PATCH 15/18] chore(datepicker): remove redundant tabindex --- .../datepicker/components/si-day-selection.component.html | 2 -- .../datepicker/components/si-month-selection.component.html | 1 - 2 files changed, 3 deletions(-) diff --git a/projects/element-ng/datepicker/components/si-day-selection.component.html b/projects/element-ng/datepicker/components/si-day-selection.component.html index 3e9068029e..20946adfc9 100644 --- a/projects/element-ng/datepicker/components/si-day-selection.component.html +++ b/projects/element-ng/datepicker/components/si-day-selection.component.html @@ -9,7 +9,6 @@ + } + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [ + { + directive: SiCustomSelectDirective, + inputs: ['disabled', 'readonly', 'value'], + outputs: ['valueChange'] + } + ] +}) +class SiTestSelectComponent { + readonly select = inject(SiCustomSelectDirective); + readonly options = signal(['Alpha', 'Beta', 'Gamma']); + + pick(opt: string): void { + this.select.updateValue(opt); + this.select.close(); + } +} + +@Component({ + imports: [SiTestSelectComponent, ReactiveFormsModule], + template: `` +}) +class FormHostComponent { + readonly control = new FormControl(undefined); +} + +describe('SiCustomSelectDirective', () => { + let fixture: ComponentFixture; + let overlayContainerElement: HTMLElement; + + const getHost = (): HTMLElement => fixture.nativeElement; + + const getOverlayDropdown = (): HTMLElement | null => + overlayContainerElement.querySelector('.dropdown-menu'); + + const getDropdownItems = (): HTMLElement[] => + Array.from(overlayContainerElement.querySelectorAll('.dropdown-item')); + + describe('direct usage', () => { + let value: ReturnType>; + let disabled: ReturnType>; + let readonly: ReturnType>; + + beforeEach(async () => { + value = signal(undefined); + disabled = signal(false); + readonly = signal(false); + + fixture = TestBed.createComponent(SiTestSelectComponent, { + bindings: [ + twoWayBinding('value', value), + inputBinding('disabled', disabled), + inputBinding('readonly', readonly) + ] + }); + overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement(); + await fixture.whenStable(); + }); + + it('should render combobox role on host', () => { + expect(getHost()).toHaveAttribute('role', 'combobox'); + expect(getHost()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should aria-haspopup listbox', () => { + expect(getHost()).toHaveAttribute('aria-haspopup', 'listbox'); + }); + + it('should reflect dropdown contentType on aria-haspopup', async () => { + @Component({ + selector: 'si-test-tree-select', + imports: [SiSelectComboboxComponent, SiSelectDropdownDirective], + template: ` + {{ select.value() ?? 'Pick...' }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [SiCustomSelectDirective] + }) + class TreeSelectComponent { + readonly select = inject(SiCustomSelectDirective); + } + + const treeFixture = TestBed.createComponent(TreeSelectComponent); + await treeFixture.whenStable(); + + expect(treeFixture.nativeElement).toHaveAttribute('aria-haspopup', 'tree'); + }); + + it('should have an id', () => { + expect(getHost()).toHaveAttribute('id'); + expect(getHost().id).toMatch(/^__si-custom-select-\d+$/); + }); + + it('should have aria-labelledby pointing to its label id and combobox content id', () => { + expect(getHost()).toHaveAttribute( + 'aria-labelledby', + `${getHost().id}-label ${getHost().id}-combobox` + ); + }); + + it('should have aria-describedby pointing to its error message id', () => { + expect(getHost()).toHaveAttribute('aria-describedby', getHost().id + '-errormessage'); + }); + + it('should display placeholder text when no value is set', () => { + expect(getHost()).toHaveTextContent('Pick...'); + }); + + it('should open dropdown on click', async () => { + getHost().click(); + await fixture.whenStable(); + + expect(getOverlayDropdown()).toBeInTheDocument(); + expect(getHost()).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should render options in the dropdown', async () => { + getHost().click(); + await fixture.whenStable(); + + const items = getDropdownItems(); + expect(items).toHaveLength(3); + expect(items[0]).toHaveTextContent('Alpha'); + expect(items[1]).toHaveTextContent('Beta'); + expect(items[2]).toHaveTextContent('Gamma'); + }); + + it('should select a value and close dropdown', async () => { + getHost().click(); + await fixture.whenStable(); + + getDropdownItems()[1].click(); + await fixture.whenStable(); + + expect(value()).toBe('Beta'); + expect(getOverlayDropdown()).not.toBeInTheDocument(); + expect(getHost()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('should display selected value text', async () => { + value.set('Gamma'); + await fixture.whenStable(); + + expect(getHost()).toHaveTextContent('Gamma'); + }); + + it('should close dropdown on backdrop click', async () => { + getHost().click(); + await fixture.whenStable(); + + expect(getOverlayDropdown()).toBeInTheDocument(); + + const backdrop = overlayContainerElement + .closest('body')! + .querySelector('.cdk-overlay-backdrop'); + backdrop!.click(); + await fixture.whenStable(); + + expect(getOverlayDropdown()).not.toBeInTheDocument(); + }); + + it('should close dropdown on Escape key', async () => { + getHost().click(); + await fixture.whenStable(); + + expect(getOverlayDropdown()).toBeInTheDocument(); + + const overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane')!; + overlayPane.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await fixture.whenStable(); + + expect(getOverlayDropdown()).not.toBeInTheDocument(); + }); + + it('should not open when disabled', async () => { + disabled.set(true); + await fixture.whenStable(); + + getHost().click(); + await fixture.whenStable(); + + expect(getOverlayDropdown()).not.toBeInTheDocument(); + }); + + it('should not open when readonly', async () => { + readonly.set(true); + await fixture.whenStable(); + + getHost().click(); + await fixture.whenStable(); + + expect(getOverlayDropdown()).not.toBeInTheDocument(); + }); + + it('should apply disabled host class', async () => { + disabled.set(true); + await fixture.whenStable(); + + expect(getHost()).toHaveClass('disabled'); + }); + + it('should apply readonly host class', async () => { + readonly.set(true); + await fixture.whenStable(); + + expect(getHost()).toHaveClass('readonly'); + }); + + it('should apply open host class when dropdown is open', async () => { + getHost().click(); + await fixture.whenStable(); + + expect(getHost()).toHaveClass('open'); + }); + + it('should apply dropdown host class', () => { + expect(getHost()).toHaveClass('dropdown'); + }); + }); + + describe('as form control', () => { + let formFixture: ComponentFixture; + let formHost: FormHostComponent; + + beforeEach(async () => { + formFixture = TestBed.createComponent(FormHostComponent); + fixture = undefined!; + overlayContainerElement = TestBed.inject(OverlayContainer).getContainerElement(); + formHost = formFixture.componentInstance; + await formFixture.whenStable(); + }); + + it('should write value from form control', async () => { + formHost.control.setValue('Alpha'); + await formFixture.whenStable(); + + const host = formFixture.nativeElement.querySelector('si-test-select'); + expect(host).toHaveTextContent('Alpha'); + }); + + it('should update form control when value is selected', async () => { + const host: HTMLElement = formFixture.nativeElement.querySelector('si-test-select'); + host.click(); + await formFixture.whenStable(); + + getDropdownItems()[2].click(); + await formFixture.whenStable(); + + expect(formHost.control.value).toBe('Gamma'); + }); + + it('should mark control as touched when dropdown is closed', async () => { + const host: HTMLElement = formFixture.nativeElement.querySelector('si-test-select'); + expect(formHost.control.touched).toBe(false); + + host.click(); + await formFixture.whenStable(); + + expect(formHost.control.touched).toBe(false); + + getDropdownItems()[0].click(); + await formFixture.whenStable(); + + expect(formHost.control.touched).toBe(true); + }); + + it('should mark control as touched when closing via backdrop', async () => { + const host: HTMLElement = formFixture.nativeElement.querySelector('si-test-select'); + host.click(); + await formFixture.whenStable(); + + const backdrop = overlayContainerElement + .closest('body')! + .querySelector('.cdk-overlay-backdrop'); + backdrop!.click(); + await formFixture.whenStable(); + + expect(formHost.control.touched).toBe(true); + }); + + it('should disable via form control', async () => { + formHost.control.disable(); + await formFixture.whenStable(); + + const host: HTMLElement = formFixture.nativeElement.querySelector('si-test-select'); + expect(host).toHaveClass('disabled'); + + host.click(); + await formFixture.whenStable(); + + expect(getOverlayDropdown()).not.toBeInTheDocument(); + }); + }); + + describe('SiFormItemControl integration', () => { + it('should provide SI_FORM_ITEM_CONTROL token', async () => { + fixture = TestBed.createComponent(SiTestSelectComponent); + await fixture.whenStable(); + + const formItemControl = fixture.debugElement.injector.get(SI_FORM_ITEM_CONTROL); + expect(formItemControl).toBeTruthy(); + expect(formItemControl.errormessageId).toBeDefined(); + }); + }); +}); diff --git a/projects/element-ng/select/si-custom-select.directive.ts b/projects/element-ng/select/si-custom-select.directive.ts new file mode 100644 index 0000000000..915b903a90 --- /dev/null +++ b/projects/element-ng/select/si-custom-select.directive.ts @@ -0,0 +1,321 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cdk/a11y'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { TemplatePortal } from '@angular/cdk/portal'; +import { isPlatformBrowser } from '@angular/common'; +import { + booleanAttribute, + computed, + DestroyRef, + Directive, + ElementRef, + inject, + input, + model, + output, + PLATFORM_ID, + signal, + ViewContainerRef +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { SI_FORM_ITEM_CONTROL, SiFormItemControl } from '@siemens/element-ng/form'; +import { filter, merge, Subject, takeUntil } from 'rxjs'; + +import type { SiSelectDropdownDirective } from './si-select-dropdown.directive'; + +/** + * Host directive for building custom selects. + * + * Add this as a `hostDirective` on your component and expose the inputs/outputs you need. + * The directive handles: + * - {@link ControlValueAccessor} integration (`formControl`, `ngModel`, `[(value)]`) + * - Disabled / readonly state management + * - Overlay lifecycle for the dropdown (open/close) + * - Focus management and focus trapping in the dropdown + * - Opening the dropdown on click, Enter, Space, ArrowDown, ArrowUp + * - {@link SiFormItemControl} integration + * + * Use {@link SiSelectDropdownDirective} to mark the dropdown template in your component, + * and call {@link open}, {@link close}, {@link updateValue} from your component logic. + * + * @example + * ```ts + * @Component({ + * selector: 'app-my-select', + * hostDirectives: [{ + * directive: SiCustomSelectDirective, + * inputs: ['disabled', 'readonly', 'value'], + * outputs: ['valueChange'] + * }], + * template: ` + * + * {{ select.value() }} + * + * + * + * + * ` + * }) + * export class MySelectComponent { + * readonly select = inject(SiCustomSelectDirective); + * } + * ``` + * + * @experimental + */ +@Directive({ + selector: '[siCustomSelect]', + providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: SiCustomSelectDirective, multi: true }, + { provide: SI_FORM_ITEM_CONTROL, useExisting: SiCustomSelectDirective } + ], + host: { + class: 'dropdown', + '[style.--si-action-icon-offset.rem]': '1.5', + role: 'combobox', + 'aria-autocomplete': 'none', + '[attr.aria-haspopup]': 'haspopup()', + '[attr.aria-labelledby]': 'labelledby()', + '[attr.aria-describedby]': 'errormessageId()', + '[attr.aria-controls]': 'isOpen() ? dropdownId() : null', + '[attr.aria-expanded]': 'isOpen()', + '[attr.aria-disabled]': 'disabled()', + '[attr.id]': 'id()', + '[attr.tabindex]': 'disabled() ? "-1" : "0"', + '[class.disabled]': 'disabled()', + '[class.pe-none]': 'disabled()', + '[class.readonly]': 'readonly()', + '[class.open]': 'isOpen()', + '[class.show]': 'isOpen()', + '(click)': 'open()', + '(keydown.enter)': 'open()', + '(keydown.space)': 'open($event)', + '(keydown.arrowDown)': 'open($event)', + '(keydown.arrowUp)': 'open($event)' + } +}) +export class SiCustomSelectDirective implements ControlValueAccessor, SiFormItemControl { + private static idCounter = 0; + + /** + * Unique identifier. + * + * @defaultValue + * ``` + * `__si-custom-select-${SiCustomSelectDirective.idCounter++}` + * ``` + */ + readonly id = input(`__si-custom-select-${SiCustomSelectDirective.idCounter++}`); + + /** + * Whether the select input is disabled. + * + * @defaultValue false + */ + // eslint-disable-next-line @angular-eslint/no-input-rename + readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute }); + + /** + * Readonly state. Similar to disabled but with higher contrast. + * + * @defaultValue false + */ + readonly readonly = input(false, { transform: booleanAttribute }); + + /** Emits when the dropdown open state changes. */ + readonly openChange = output(); + + /** + * The current value, supports two-way binding via `[(value)]`. + * + * @defaultValue undefined + */ + readonly value = model(undefined); + + /** + * Whether the dropdown is currently open. + * + * @defaultValue false + */ + readonly isOpen = signal(false); + + /** @internal */ + readonly labelledby = computed(() => `${this.id()}-label ${this.id()}-combobox`); + + /** @internal */ + readonly comboboxLabelId = computed(() => `${this.id()}-combobox`); + + /** @internal */ + readonly dropdownId = computed(() => this.id() + '-dropdown'); + + /** + * Value forwarded to the `aria-haspopup` attribute. Reflects the + * `contentType` input of the registered {@link SiSelectDropdownDirective}, + * defaulting to `'listbox'` until a dropdown template is registered. + * @internal + */ + readonly haspopup = computed(() => this.dropdownDirective()?.contentType() ?? 'listbox'); + + /** + * This ID will be bound to the `aria-describedby` attribute of the select. + * + * @defaultValue + * ``` + * `${this.id()}-errormessage` + * ``` + */ + readonly errormessageId = input(`${this.id()}-errormessage`); + + /** Combined disabled state from input and form control. */ + readonly disabled = computed(() => this.disabledInput() || this.disabledByForm()); + + private onTouched: () => void = () => {}; + + private onChange: (_: T | undefined) => void = () => {}; + private readonly disabledByForm = signal(false); + + private readonly overlay = inject(Overlay); + private readonly focusTrapFactory = inject(ConfigurableFocusTrapFactory); + private readonly elementRef = inject>(ElementRef); + private readonly viewContainerRef = inject(ViewContainerRef); + private readonly destroyRef = inject(DestroyRef); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + private overlayRef?: OverlayRef; + private focusTrap?: ConfigurableFocusTrap; + private readonly closeOverlay$ = new Subject(); + + private readonly dropdownDirective = signal(undefined); + + constructor() { + this.destroyRef.onDestroy(() => { + this.disposeOverlay(); + this.closeOverlay$.complete(); + }); + } + + /** + * Registers the dropdown directive. Called by + * {@link SiSelectDropdownDirective} when it is initialized. + * @internal + */ + registerDropdown(directive: SiSelectDropdownDirective): void { + this.dropdownDirective.set(directive); + } + + /** + * Updates the value programmatically. + * Call this from your dropdown template to set the new value. + */ + updateValue(value: T | undefined): void { + this.value.set(value); + this.onChange(value); + } + + /** Opens the dropdown overlay. */ + open(event?: Event): void { + if (this.disabled() || this.readonly() || this.isOpen() || !this.isBrowser) { + return; + } + + if (!this.dropdownDirective()) { + return; + } + + // Prevent default scrolling behavior for Space / ArrowUp / ArrowDown. + event?.preventDefault(); + + const width = this.elementRef.nativeElement.getBoundingClientRect().width; + this.overlayRef = this.overlay.create({ + positionStrategy: this.overlay + .position() + .flexibleConnectedTo(this.elementRef) + .withPositions([ + // Preferred: below, aligned to the start edge of the trigger. + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, + // Below, aligned to the end edge (trigger near the end of the viewport). + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, + // Above, aligned to the start edge (no space below). + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, + // Above, aligned to the end edge (no space below, trigger near the end). + { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom' }, + // Below, centered (small screens, trigger in the middle). + { originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top' }, + // Above, centered. + { originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom' } + ]) + .withFlexibleDimensions(true) + .withPush(true), + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', + panelClass: ['dropdown-menu', 'show'], + minWidth: width + 2 + }); + + const portal = new TemplatePortal(this.dropdownDirective()!.templateRef, this.viewContainerRef); + this.overlayRef.attach(portal); + this.overlayRef.overlayElement.id = this.dropdownId(); + + this.focusTrap = this.focusTrapFactory.create(this.overlayRef.overlayElement); + this.focusTrap.focusFirstTabbableElementWhenReady(); + + this.isOpen.set(true); + this.openChange.emit(true); + + merge( + this.overlayRef.backdropClick(), + this.overlayRef.keydownEvents().pipe(filter(e => e.key === 'Escape')) + ) + .pipe(takeUntil(this.closeOverlay$), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.close()); + } + + /** Closes the dropdown overlay and restores focus. */ + close(): void { + if (!this.isOpen()) { + return; + } + this.isOpen.set(false); + this.disposeOverlay(); + this.openChange.emit(false); + this.onTouched(); + if (this.isBrowser) { + this.elementRef.nativeElement.focus(); + } + } + + /** @internal */ + writeValue(obj: T | null): void { + this.value.set(obj ?? undefined); + } + + /** @internal */ + registerOnChange(fn: (_: T | undefined) => void): void { + this.onChange = fn; + } + + /** @internal */ + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + /** @internal */ + setDisabledState(isDisabled: boolean): void { + this.disabledByForm.set(isDisabled); + } + + private disposeOverlay(): void { + if (this.overlayRef) { + this.closeOverlay$.next(); + this.focusTrap?.destroy(); + this.focusTrap = undefined; + this.overlayRef.detach(); + this.overlayRef.dispose(); + this.overlayRef = undefined; + } + } +} diff --git a/projects/element-ng/select/si-select-combobox-value.component.scss b/projects/element-ng/select/si-select-combobox-value.component.scss new file mode 100644 index 0000000000..1b806e5e72 --- /dev/null +++ b/projects/element-ng/select/si-select-combobox-value.component.scss @@ -0,0 +1,11 @@ +:host { + display: inline-block; + max-inline-size: 100%; +} + +// Opt-in: when the consumer adds the `comma-separated` class to each value, +// a comma is rendered between adjacent values. Not applied by default so +// consumers remain free to use chips, custom separators or none at all. +:host(.comma-separated:not(:first-of-type))::before { + content: ',\00a0'; +} diff --git a/projects/element-ng/select/si-select-combobox-value.component.ts b/projects/element-ng/select/si-select-combobox-value.component.ts new file mode 100644 index 0000000000..7d9184621b --- /dev/null +++ b/projects/element-ng/select/si-select-combobox-value.component.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { SiIconComponent } from '@siemens/element-ng/icon'; + +/** + * Represents a single selected value inside an {@link SiSelectComboboxComponent}. + * + * Project one `` per selected entry. Optional + * `icon`, `iconColor`, `stackedIcon` and `stackedIconColor` inputs mirror the + * icon API of {@link SelectOption} and render an icon (with optional stacked + * overlay) before the projected content. + * + * Add the `comma-separated` CSS class to render a comma between adjacent + * values. Omit it when using chips, custom separators, or a single value. + * + * @example + * ```html + * + * + * {{ select.value() }} + * + * + * ``` + * + * @experimental + */ +@Component({ + selector: 'si-select-combobox-value', + imports: [SiIconComponent], + template: ` + @if (icon(); as iconName) { +
+ + @if (stackedIcon(); as stackedIconName) { + + } +
+ } + + `, + styleUrl: './si-select-combobox-value.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'text-nowrap' + } +}) +export class SiSelectComboboxValueComponent { + /** An optional icon rendered before the projected content. */ + readonly icon = input(); + + /** Optional CSS color class applied to {@link icon}. */ + readonly iconColor = input(); + + /** Optional secondary icon stacked on top of {@link icon}. */ + readonly stackedIcon = input(); + + /** Optional CSS color class applied to {@link stackedIcon}. */ + readonly stackedIconColor = input(); +} diff --git a/projects/element-ng/select/si-select-combobox.component.scss b/projects/element-ng/select/si-select-combobox.component.scss new file mode 100644 index 0000000000..7f96e6fd75 --- /dev/null +++ b/projects/element-ng/select/si-select-combobox.component.scss @@ -0,0 +1,9 @@ +:host-context(.form-control.dropdown) .dropdown-caret { + margin-inline-end: calc( + (var(--si-feedback-icon-offset, 0px) + var(--si-action-icon-offset)) * -1 + ); +} + +.dropdown-caret { + transform-origin: center; +} diff --git a/projects/element-ng/select/si-select-combobox.component.ts b/projects/element-ng/select/si-select-combobox.component.ts new file mode 100644 index 0000000000..5c7f00f334 --- /dev/null +++ b/projects/element-ng/select/si-select-combobox.component.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { elementDown2 } from '@siemens/element-icons'; +import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; + +import { SiCustomSelectDirective } from './si-custom-select.directive'; + +/** + * Visual trigger element for custom selects built with {@link SiCustomSelectDirective}. + * Renders the projected content and a dropdown caret icon. + * + * The ARIA role, focus handling, and state attributes live on the host component + * via {@link SiCustomSelectDirective} — this component is purely visual. + * + * @example + * ```html + * + * {{ select.value() }} + * + * ``` + * + * @experimental + */ +@Component({ + selector: 'si-select-combobox', + imports: [SiIconComponent], + template: ` + +
+ +
+ `, + styleUrl: './si-select-combobox.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'select focus-none dropdown-toggle d-flex align-items-center w-100', + '[attr.id]': 'customSelect.comboboxLabelId()', + '[class.show]': 'customSelect.isOpen()' + } +}) +export class SiSelectComboboxComponent { + protected readonly icons = addIcons({ elementDown2 }); + protected readonly customSelect = inject(SiCustomSelectDirective); +} diff --git a/projects/element-ng/select/si-select-dropdown.directive.ts b/projects/element-ng/select/si-select-dropdown.directive.ts new file mode 100644 index 0000000000..badc47a729 --- /dev/null +++ b/projects/element-ng/select/si-select-dropdown.directive.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { Directive, inject, input, TemplateRef } from '@angular/core'; + +import { SiCustomSelectDirective } from './si-custom-select.directive'; + +/** + * Possible values for the `aria-haspopup` attribute exposed by the + * combobox host of a custom select. + */ +export type SiSelectDropdownContentType = + | 'false' + | 'true' + | 'menu' + | 'listbox' + | 'tree' + | 'grid' + | 'dialog'; + +/** + * Structural directive marking the dropdown template for custom selects + * built with {@link SiCustomSelectDirective}. + * + * When placed on an ``, it automatically registers the template + * with the parent {@link SiCustomSelectDirective}. + * + * @example + * ```html + * + * + * + * ``` + * + * @experimental + */ +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[si-select-dropdown]' +}) +export class SiSelectDropdownDirective { + /** + * Describes the kind of content rendered by the dropdown. The value is + * forwarded to the `aria-haspopup` attribute of the combobox host of + * the parent {@link SiCustomSelectDirective}. + */ + readonly contentType = input.required(); + + /** @internal */ + readonly templateRef = inject>(TemplateRef); + + constructor() { + const customSelect = inject(SiCustomSelectDirective, { optional: true }); + customSelect?.registerDropdown(this); + } +} diff --git a/projects/element-ng/select/testing/si-select.harness.ts b/projects/element-ng/select/testing/si-select.harness.ts index 56cdc417d9..88a77dcf9e 100644 --- a/projects/element-ng/select/testing/si-select.harness.ts +++ b/projects/element-ng/select/testing/si-select.harness.ts @@ -99,7 +99,7 @@ export class SiSelectHarness extends ComponentHarness { async getOverflowCount(): Promise { await new Promise(resolve => setTimeout(() => resolve())); - return this.locatorForOptional('.overflow-item')() + return this.locatorForOptional('.pill')() .then(overflow => overflow?.text()) .then(overflow => overflow?.replace('+', '')) .then(overflow => (overflow ? +overflow : 0)); diff --git a/projects/element-theme/src/styles/bootstrap/_dropdowns.scss b/projects/element-theme/src/styles/bootstrap/_dropdowns.scss index 958f6220e9..5fd29c7662 100644 --- a/projects/element-theme/src/styles/bootstrap/_dropdowns.scss +++ b/projects/element-theme/src/styles/bootstrap/_dropdowns.scss @@ -38,6 +38,15 @@ } } +.dropdown-menu-scroller { + overflow: auto; + // 266px is based on figma and gives 6-8 items. Depending on the number of headings + max-block-size: min( + 100vh, + calc((1lh + 2 * #{bootstrap-variables.$dropdown-item-padding-y}) * 8.3125) // = 266px with rfs 16px + ); +} + :is(.dropdown-menu, .dropdown-item) { .icon + .item-title { padding-inline-start: map.get(spacers.$spacers, 4); diff --git a/src/app/examples/si-select/si-select-custom.html b/src/app/examples/si-select/si-select-custom.html new file mode 100644 index 0000000000..8676dee75c --- /dev/null +++ b/src/app/examples/si-select/si-select-custom.html @@ -0,0 +1,50 @@ + +
+ +
+
+ + +
+ + + +
+
+ +
+
Control panel
+
+
Current value (ngModel): {{ selectedLocation?.label ?? 'none' }}
+
Current value (formControl): {{ locationControl.value?.label ?? 'none' }}
+
Location valid: {{ locationControl.valid }}
+
+ + + +
+
+ + + +
+
+
diff --git a/src/app/examples/si-select/si-select-custom.ts b/src/app/examples/si-select/si-select-custom.ts new file mode 100644 index 0000000000..6df55dc40b --- /dev/null +++ b/src/app/examples/si-select/si-select-custom.ts @@ -0,0 +1,149 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { SiCardComponent } from '@siemens/element-ng/card'; +import { SiFormItemComponent } from '@siemens/element-ng/form'; +import { + SiCustomSelectDirective, + SiSelectComboboxComponent, + SiSelectComboboxValueComponent, + SiSelectDropdownDirective +} from '@siemens/element-ng/select'; +import { SiTreeViewComponent, TreeItem } from '@siemens/element-ng/tree-view'; + +import { treeItems } from '../si-tree-view/tree-items'; + +/** + * Reusable tree-select built with SiCustomSelectDirective as a host directive. + * Embeds an si-tree-view inside the dropdown and lets the user pick a location. + */ +@Component({ + selector: 'app-tree-select', + imports: [ + SiSelectComboboxComponent, + SiSelectComboboxValueComponent, + SiSelectDropdownDirective, + SiTreeViewComponent + ], + template: ` + + @if (select.value(); as val) { + + {{ val.label }} + + } @else { + Select a location... + } + + + + + +
+ +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [ + { + directive: SiCustomSelectDirective, + inputs: ['disabled', 'readonly', 'value'], + outputs: ['valueChange'] + } + ] +}) +export class TreeSelectComponent { + protected readonly select = inject>(SiCustomSelectDirective); + + /** The tree items to display. */ + readonly items = input([]); + + /** + * Pending tree items rendered inside the dropdown. The tree view mutates the + * model (e.g. selection state), so we deep-clone the input before each open + * to avoid leaking state back into the caller's data. + */ + protected readonly pendingItems = signal([]); + + constructor() { + this.select.openChange.subscribe(open => { + if (open) { + const clone = JSON.parse(JSON.stringify(this.items())) as TreeItem[]; + applySelectionState(clone, this.select.value()?.label as string | undefined); + this.pendingItems.set(clone); + } else { + this.pendingItems.set([]); + } + }); + } + + selectItem(item: TreeItem): void { + if (item.label) { + this.select.updateValue(item); + this.select.close(); + } + } + + clearSelection(): void { + this.select.updateValue(undefined); + this.select.close(); + } +} + +/** Marks the item matching `selected` as selected. */ +const applySelectionState = (items: TreeItem[], selected: string | undefined): void => { + for (const item of items) { + if (item.children?.length) { + applySelectionState(item.children, selected); + } else { + item.selected = item.label === selected; + } + } +}; + +@Component({ + selector: 'app-sample', + imports: [ + TreeSelectComponent, + FormsModule, + ReactiveFormsModule, + SiCardComponent, + SiFormItemComponent + ], + templateUrl: './si-select-custom.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'p-5' } +}) +export class SampleComponent { + selectedLocation: TreeItem | undefined; + disabled = false; + readonly = false; + readonly treeItems = treeItems; + readonly locationControl = new FormControl(undefined, Validators.required); + + toggleDisabled(disabled: boolean): void { + if (disabled) { + this.locationControl.disable(); + } else { + this.locationControl.enable(); + } + } +} diff --git a/src/app/examples/si-select/si-select-multi-custom.html b/src/app/examples/si-select/si-select-multi-custom.html new file mode 100644 index 0000000000..a006c744ed --- /dev/null +++ b/src/app/examples/si-select/si-select-multi-custom.html @@ -0,0 +1,56 @@ + +
+ +
+
+ + +
+ + + +
+
+ +
+
Control panel
+
+
Selected (ngModel): + {{ selectedLocations.length ? labelsOf(selectedLocations).join(', ') : 'none' }}
+
Selected (formControl): + {{ + locationsControl.value.length ? labelsOf(locationsControl.value).join(', ') : 'none' + }}
+
Locations valid: {{ locationsControl.valid }}
+
+ + + +
+
+ + + +
+
+
diff --git a/src/app/examples/si-select/si-select-multi-custom.ts b/src/app/examples/si-select/si-select-multi-custom.ts new file mode 100644 index 0000000000..f62f04b5c4 --- /dev/null +++ b/src/app/examples/si-select/si-select-multi-custom.ts @@ -0,0 +1,172 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + SiAutoCollapsableListDirective, + SiAutoCollapsableListItemDirective, + SiAutoCollapsableListOverflowItemDirective +} from '@siemens/element-ng/auto-collapsable-list'; +import { SiCardComponent } from '@siemens/element-ng/card'; +import { SiFormItemComponent } from '@siemens/element-ng/form'; +import { + SiCustomSelectDirective, + SiSelectComboboxComponent, + SiSelectComboboxValueComponent, + SiSelectDropdownDirective +} from '@siemens/element-ng/select'; +import { SiTreeViewComponent, TreeItem } from '@siemens/element-ng/tree-view'; + +import { treeItems } from '../si-tree-view/tree-items'; +import { + cloneTreeWithCheckedState, + collectCheckedLeaves, + compactSelected, + expandCompactItems +} from './tree-select-utils'; + +/** + * Reusable multi-select tree component with an Apply button. + * Uses SiCustomSelectDirective as a host directive and checkboxes in the tree view. + * + * When all children of a parent are selected only the parent label is shown + * in the value display instead of every individual leaf. + */ +@Component({ + selector: 'app-tree-multi-select', + imports: [ + SiAutoCollapsableListDirective, + SiAutoCollapsableListItemDirective, + SiAutoCollapsableListOverflowItemDirective, + SiSelectComboboxComponent, + SiSelectComboboxValueComponent, + SiSelectDropdownDirective, + SiTreeViewComponent + ], + template: ` + +
+ @if (select.value(); as value) { + @for (item of value; track item.label) { + + {{ item.label }} + + } @empty { + Select locations... + } + } @else { + Select locations... + } + {{ overflow.hiddenItemCount }}+ +
+
+ + +
+ +
+ + +
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [ + { + directive: SiCustomSelectDirective, + inputs: ['disabled', 'readonly', 'value'], + outputs: ['valueChange'] + } + ] +}) +export class TreeMultiSelectComponent { + protected readonly select = inject>(SiCustomSelectDirective); + + /** The tree items to display. */ + readonly items = input.required(); + + /** Pending tree items with checkbox state (not yet applied). */ + protected readonly pendingItems = signal([]); + + constructor() { + this.select.openChange.subscribe(open => { + if (open) { + // Expand any compacted parent-items back to leaf labels so the + // tree checkbox state is restored correctly. + const compacted = this.select.value() ?? []; + const expanded = expandCompactItems(compacted); + this.pendingItems.set(cloneTreeWithCheckedState(this.items(), expanded)); + } else { + this.pendingItems.set([]); + } + }); + } + + apply(): void { + const checkedLeaves = collectCheckedLeaves(this.pendingItems()); + // Compact items so fully-selected subtrees are replaced by the parent + // item (e.g. 'Milano' instead of every individual location). + const compacted = compactSelected(this.items(), checkedLeaves); + this.select.updateValue(compacted); + this.select.close(); + } + + cancel(): void { + this.select.close(); + } +} + +@Component({ + selector: 'app-sample', + imports: [ + TreeMultiSelectComponent, + FormsModule, + ReactiveFormsModule, + SiCardComponent, + SiFormItemComponent + ], + templateUrl: './si-select-multi-custom.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'p-5' } +}) +export class SampleComponent { + selectedLocations: TreeItem[] = []; + disabled = false; + readonly = false; + readonly treeItems = treeItems; + readonly locationsControl = new FormControl([], { + nonNullable: true, + validators: Validators.required + }); + + toggleDisabled(disabled: boolean): void { + if (disabled) { + this.locationsControl.disable(); + } else { + this.locationsControl.enable(); + } + } + + labelsOf(items: TreeItem[]): string[] { + return items.map(item => item.label as string); + } +} diff --git a/src/app/examples/si-select/tree-select-utils.ts b/src/app/examples/si-select/tree-select-utils.ts new file mode 100644 index 0000000000..8b4739f174 --- /dev/null +++ b/src/app/examples/si-select/tree-select-utils.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import type { TreeItem } from '@siemens/element-ng/tree-view'; + +/** Recursively sets the `checked` state on every node. */ +const applyCheckedState = (nodes: TreeItem[], selected: string[]): void => { + for (const node of nodes) { + if (node.children?.length) { + applyCheckedState(node.children, selected); + const allChecked = node.children.every(c => c.checked === 'checked'); + const someChecked = node.children.some( + c => c.checked === 'checked' || c.checked === 'indeterminate' + ); + node.checked = allChecked ? 'checked' : someChecked ? 'indeterminate' : 'unchecked'; + } else { + node.checked = selected.includes(node.label as string) ? 'checked' : 'unchecked'; + } + } +}; + +/** + * Deep-clones tree items via JSON serialization and sets the `checked` state + * based on the provided selected labels. The deep clone is required because + * the tree view mutates the model (checked / state / etc.). + */ +export const cloneTreeWithCheckedState = (items: TreeItem[], selected: string[]): TreeItem[] => { + const clone = JSON.parse(JSON.stringify(items)) as TreeItem[]; + applyCheckedState(clone, selected); + return clone; +}; + +/** + * Collects all checked leaf labels from a tree. + */ +export const collectCheckedLeaves = (items: TreeItem[]): string[] => { + const result: string[] = []; + for (const item of items) { + if (item.children?.length) { + result.push(...collectCheckedLeaves(item.children)); + } else if (item.checked === 'checked' && item.label) { + result.push(item.label as string); + } + } + return result; +}; + +/** Collects all leaf labels beneath every node in `nodes`. */ +const collectAllLeafLabels = (nodes: TreeItem[]): string[] => { + const result: string[] = []; + for (const node of nodes) { + if (node.children?.length) { + result.push(...collectAllLeafLabels(node.children)); + } else if (node.label) { + result.push(node.label as string); + } + } + return result; +}; + +const compactNode = (nodes: TreeItem[], selectedSet: Set, result: TreeItem[]): void => { + for (const node of nodes) { + if (node.children?.length) { + const allLeafLabels = collectAllLeafLabels(node.children); + const allSelected = allLeafLabels.every(label => selectedSet.has(label)); + + if (allSelected && allLeafLabels.length > 0) { + result.push(node); + } else { + compactNode(node.children, selectedSet, result); + } + } else if (node.label && selectedSet.has(node.label as string)) { + result.push(node); + } + } +}; + +/** + * Given a flat list of checked leaf labels and the full tree, returns a + * compacted list of {@link TreeItem} references where fully-selected subtrees + * are replaced by their parent node. + */ +export const compactSelected = (items: TreeItem[], selected: string[]): TreeItem[] => { + const selectedSet = new Set(selected); + const result: TreeItem[] = []; + compactNode(items, selectedSet, result); + return result; +}; + +/** + * Expands a list of compacted {@link TreeItem} references back to the + * leaf labels they represent, so the tree can restore checkbox state. + */ +export const expandCompactItems = (items: TreeItem[]): string[] => { + const result: string[] = []; + for (const item of items) { + if (item.children?.length) { + result.push(...collectAllLeafLabels(item.children)); + } else if (item.label) { + result.push(item.label as string); + } + } + return result; +}; diff --git a/src/app/examples/si-tree-view/tree-items.ts b/src/app/examples/si-tree-view/tree-items.ts index be0343e15c..df4629da64 100644 --- a/src/app/examples/si-tree-view/tree-items.ts +++ b/src/app/examples/si-tree-view/tree-items.ts @@ -99,7 +99,8 @@ export const treeItems: TreeItem[] = [ icon: 'element-project', children: [ { - label: 'Child Company3' + label: 'Child Company3', + state: 'leaf' } ] } From 70ec30a4f2d270a17ffc695f988e28efae633524 Mon Sep 17 00:00:00 2001 From: chintankavathia Date: Fri, 15 May 2026 12:09:00 +0530 Subject: [PATCH 18/18] refactor(dashboard): measure scrollbar width directly from element Replace ScrollbarHelper service with direct offsetWidth - clientWidth measurement on the scrollable element, which is more accurate and SSR-safe. DEPRECATED: `ScrollbarHelper` service is deprecated and will be removed in v51. Use `element.offsetWidth - element.clientWidth` on the scrollable element instead. --- api-goldens/element-ng/common/index.api.md | 2 +- .../element-ng/common/services/scrollbar-helper.service.ts | 6 ++++++ projects/element-ng/dashboard/si-dashboard.component.ts | 5 ++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/api-goldens/element-ng/common/index.api.md b/api-goldens/element-ng/common/index.api.md index 3256b7c451..372f73ffe2 100644 --- a/api-goldens/element-ng/common/index.api.md +++ b/api-goldens/element-ng/common/index.api.md @@ -229,7 +229,7 @@ export const responsivelyCheckDirection: (params: { close: boolean; }; -// @public +// @public @deprecated export class ScrollbarHelper { readonly width: number; } diff --git a/projects/element-ng/common/services/scrollbar-helper.service.ts b/projects/element-ng/common/services/scrollbar-helper.service.ts index cd75c37697..245c6a5dae 100644 --- a/projects/element-ng/common/services/scrollbar-helper.service.ts +++ b/projects/element-ng/common/services/scrollbar-helper.service.ts @@ -7,7 +7,13 @@ import { inject, Injectable, DOCUMENT } from '@angular/core'; /** * Gets the width of the scrollbar. Nesc for windows * http://stackoverflow.com/a/13382873/888165 + * + * @deprecated This service uses a legacy DOM-measurement hack to determine scrollbar width. + * Use `element.offsetWidth - element.clientWidth` on the scrollable element directly, + * which is more accurate, SSR-safe, and accounts for OS-level theming and CSS overrides. + * Will be removed in v51. */ + @Injectable({ providedIn: 'root' }) export class ScrollbarHelper { private document = inject(DOCUMENT); diff --git a/projects/element-ng/dashboard/si-dashboard.component.ts b/projects/element-ng/dashboard/si-dashboard.component.ts index 4861d1ea38..c9af2107ba 100644 --- a/projects/element-ng/dashboard/si-dashboard.component.ts +++ b/projects/element-ng/dashboard/si-dashboard.component.ts @@ -21,7 +21,6 @@ import { viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ScrollbarHelper } from '@siemens/element-ng/common'; import { BOOTSTRAP_BREAKPOINTS, ElementDimensions, @@ -99,7 +98,6 @@ export class SiDashboardComponent implements OnChanges, AfterViewInit { private scroller = inject(ViewportScroller); private dashboardService = inject(SiDashboardService); private resizeObserver = inject(ResizeObserverService); - private scrollbarHelper = inject(ScrollbarHelper); private cdRef = inject(ChangeDetectorRef); private document = inject(DOCUMENT); private readonly hideMenubarInternal = signal(false); @@ -256,7 +254,8 @@ export class SiDashboardComponent implements OnChanges, AfterViewInit { dashboardFrameDimensions && dashboardDimensions.height > dashboardFrameDimensions.height ) { - padding = padding - this.scrollbarHelper.width; + const { offsetWidth, clientWidth } = this.dashboardFrame().nativeElement; + padding = padding - (offsetWidth - clientWidth); } this.dashboardFrameEndPadding = padding; this.cdRef.markForCheck();