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/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/api-goldens/element-ng/select/index.api.md b/api-goldens/element-ng/select/index.api.md index fe2fa5f02d..a30d256431 100644 --- a/api-goldens/element-ng/select/index.api.md +++ b/api-goldens/element-ng/select/index.api.md @@ -52,6 +52,22 @@ export interface SelectOptionSource { valuesEqual?(optionA: TValue, optionB: TValue): boolean; } +// @public +export class SiCustomSelectDirective implements ControlValueAccessor, SiFormItemControl { + constructor(); + close(): void; + readonly disabled: _angular_core.Signal; + readonly disabledInput: _angular_core.InputSignalWithTransform; + readonly errormessageId: _angular_core.InputSignal; + readonly id: _angular_core.InputSignal; + readonly isOpen: _angular_core.WritableSignal; + open(event?: Event): void; + readonly openChange: _angular_core.OutputEmitterRef; + readonly readonly: _angular_core.InputSignalWithTransform; + updateValue(value: T | undefined): void; + readonly value: _angular_core.ModelSignal; +} + // @public (undocumented) export class SiSelectActionDirective { readonly selectActionAutoClose: _angular_core.InputSignalWithTransform; @@ -61,6 +77,18 @@ export class SiSelectActionDirective { export class SiSelectActionsDirective { } +// @public +export class SiSelectComboboxComponent { +} + +// @public +export class SiSelectComboboxValueComponent { + readonly icon: _angular_core.InputSignal; + readonly iconColor: _angular_core.InputSignal; + readonly stackedIcon: _angular_core.InputSignal; + readonly stackedIconColor: _angular_core.InputSignal; +} + // @public (undocumented) export class SiSelectComponent implements SiFormItemControl { readonly ariaLabel: _angular_core.InputSignal; @@ -77,6 +105,15 @@ export class SiSelectComponent implements SiFormItemControl { readonly readonly: _angular_core.InputSignalWithTransform; } +// @public +export type SiSelectDropdownContentType = 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'; + +// @public +export class SiSelectDropdownDirective { + constructor(); + readonly contentType: _angular_core.InputSignal; +} + // @public export class SiSelectGroupTemplateDirective { // (undocumented) diff --git a/api-goldens/element-ng/tree-view/index.api.md b/api-goldens/element-ng/tree-view/index.api.md index ae0094f09c..3e2c4d0df9 100644 --- a/api-goldens/element-ng/tree-view/index.api.md +++ b/api-goldens/element-ng/tree-view/index.api.md @@ -214,7 +214,7 @@ export class SiTreeViewComponent implements OnInit, OnChanges, OnDestroy, AfterV } // @public (undocumented) -export class SiTreeViewItemComponent implements OnInit, OnDestroy, AfterViewInit, FocusableOption, DoCheck { +export class SiTreeViewItemComponent implements OnInit, AfterViewInit, FocusableOption, DoCheck { constructor(); focus(): void; getLabel(): string; diff --git a/docs/components/forms-inputs/select.md b/docs/components/forms-inputs/select.md index f480175616..d417ebc62d 100644 --- a/docs/components/forms-inputs/select.md +++ b/docs/components/forms-inputs/select.md @@ -343,8 +343,107 @@ The following demonstrates how to add a "Create" button within the dropdown acti +### Custom select (experimental) + +When the rigid `si-select` options API is not enough — for example to embed a +tree, drive multi-stage selection with `Apply` / `Cancel` semantics, or render +arbitrary UI inside the dropdown — applications can compose their own select +from the following primitives: + +- `SiCustomSelectDirective` — host directive providing the combobox wiring: + `ControlValueAccessor` (`formControl`, `ngModel`, `[(value)]`), disabled / + readonly handling, `SiFormItemControl` integration, ARIA `combobox` role, + keyboard activation (Enter, Space, ArrowDown / ArrowUp), CDK overlay + lifecycle, and focus trapping inside the dropdown. +- `SiSelectComboboxComponent` — the visual trigger that renders the projected + value and the dropdown caret. Pair it with `SiSelectComboboxValueComponent` + to mark individual selected entries. +- `SiSelectDropdownDirective` — structural directive (`*si-select-dropdown` / + ``) that registers the dropdown content + template with the parent custom select. Requires a `contentType` input + whose value is forwarded to the `aria-haspopup` attribute of the combobox + host (`menu`, `listbox`, `tree`, `grid`, `dialog`, `true`, or `false`). + +The strategy is intentionally minimal: the host directive owns state, focus, +keyboard handling and form integration, while applications retain full +control over what is rendered in the trigger and in the dropdown. The same +component can therefore be styled either as a `form-control` (inside an +`si-form-item`, with validation) or as a button (`btn btn-primary-ghost`) by +applying the corresponding class on the host element. + +A custom select component typically applies `SiCustomSelectDirective` as a +host directive and exposes the inputs and outputs the application needs: + +```ts +@Component({ + selector: 'app-tree-select', + imports: [SiSelectComboboxComponent, SiSelectDropdownDirective, SiTreeViewComponent], + template: ` + + @if (select.value(); as val) { + {{ val }} + } @else { + Select a location... + } + + + + + + `, + hostDirectives: [ + { + directive: SiCustomSelectDirective, + inputs: ['disabled', 'readonly', 'value'], + outputs: ['valueChange'] + } + ] +}) +export class TreeSelectComponent { + protected readonly select = inject>(SiCustomSelectDirective); + + readonly items = input([]); + + selectItem(item: TreeItem): void { + if (item.label) { + this.select.updateValue(item.label as string); + this.select.close(); + } + } +} +``` + +Because `si-tree-view` (and similar components) mutate the model they render, +inputs must be deep-cloned before they are passed in. A robust pattern is to +keep a `pendingItems` signal that is repopulated from a fresh +`JSON.parse(JSON.stringify(...))` clone whenever the dropdown opens, so each +opening starts from a clean slate without leaking state back to the caller. + + + +The same primitives can drive a multi-select with `Apply` / `Cancel`. The +host component buffers pending changes while the dropdown is open and only +calls `select.updateValue(...)` once the user confirms — keeping the +`ControlValueAccessor` value stable until the selection is applied. + + + + + + + + + + + diff --git a/playwright/e2e/element-examples/si-select.spec.ts b/playwright/e2e/element-examples/si-select.spec.ts index a09cee00d3..d012b661dd 100644 --- a/playwright/e2e/element-examples/si-select.spec.ts +++ b/playwright/e2e/element-examples/si-select.spec.ts @@ -7,6 +7,8 @@ import { expect, test } from '../../support/test-helpers'; test.describe('si-select', () => { const example = 'si-select/si-select'; const lazyLoadExample = 'si-select/si-select-lazy-load'; + const customExample = 'si-select/si-select-custom'; + const multiCustomExample = 'si-select/si-select-multi-custom'; test(example, async ({ page, si }) => { await si.visitExample(example); @@ -103,4 +105,51 @@ test.describe('si-select', () => { await page.getByText('Switzerland').click(); await si.runVisualAndA11yTests('checked'); }); + + test(customExample, async ({ page, si }) => { + await si.visitExample(customExample); + + const comboboxes = page.getByRole('combobox'); + const buttonCombobox = comboboxes.first(); + const formControlCombobox = comboboxes.last(); + + // Button variant: open, snapshot, select a value. + await buttonCombobox.click(); + await expect(buttonCombobox).toHaveAttribute('aria-expanded', 'true'); + await si.runVisualAndA11yTests('custom-button-opened'); + + await page.getByRole('treeitem', { name: 'Company1' }).focus(); + await page.keyboard.press('ArrowRight'); + await page.getByRole('treeitem', { name: 'Milano' }).click(); + await expect(buttonCombobox).not.toHaveAttribute('aria-expanded', 'true'); + await expect(buttonCombobox).toContainText('Milano'); + + // Form-control variant: open, then close and snapshot the closed (invalid) state. + await formControlCombobox.click(); + await expect(formControlCombobox).toHaveAttribute('aria-expanded', 'true'); + + await page.keyboard.press('Escape'); + await expect(formControlCombobox).not.toHaveAttribute('aria-expanded', 'true'); + await si.runVisualAndA11yTests('custom-form-control-closed'); + }); + + test(multiCustomExample, async ({ page, si }) => { + await si.visitExample(multiCustomExample); + + const formControlCombobox = page.getByRole('combobox').last(); + + // Open form-control variant. + await formControlCombobox.click(); + await expect(formControlCombobox).toHaveAttribute('aria-expanded', 'true'); + + // Check the first node (selects all of its children). + await page.getByRole('tree').getByRole('checkbox').first().check(); + await si.runVisualAndA11yTests('multi-custom-first-selected'); + + // Apply. + await page.getByRole('button', { name: 'Apply' }).click(); + await expect(formControlCombobox).not.toHaveAttribute('aria-expanded', 'true'); + await expect(formControlCombobox).toContainText('Company1'); + await si.runVisualAndA11yTests('multi-custom-applied'); + }); }); diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..5e4b9b05dc --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7065445f9ded5fc9b85f16ce4f8d0d0ed85d143b64bebfe7b46e8e44f8c8340 +size 16049 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened-element-examples-chromium-light-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..7ad72ea968 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fc1e8e489ad9dd2fba28f1dd6ec6a5fbf56994d3ae491b0c5e8e7f92b4a9bbb +size 16013 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened.yaml b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened.yaml new file mode 100644 index 0000000000..8255c22ffc --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-button-opened.yaml @@ -0,0 +1,14 @@ +- text: Button variant +- combobox "Select a location..." [expanded] +- text: Form-control variant Location (required)* +- combobox "Select a location..." +- text: "Control panel Current value (ngModel): none Current value (formControl): none Location valid: false" +- checkbox "Readonly" +- text: Readonly +- checkbox "Disabled" +- text: Disabled +- tree "Locations": + - treeitem "Company1" [level=1] + - treeitem "Company2" [level=1] + - treeitem "Company3" [level=1] +- button "Clear selection" [disabled] \ No newline at end of file diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..d48ddde884 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e0c6bd33a9dadd8fdb63eb6dc793eb5377aa415bd0acecd1a5f391d7d62f4fe +size 13427 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed-element-examples-chromium-light-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..860d643c03 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ad5069dafdd4c062aaeb0777f0c312a0807b88df25d69b80c7b07fbc70f7571 +size 13171 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed.yaml b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed.yaml new file mode 100644 index 0000000000..10891b3715 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-closed.yaml @@ -0,0 +1,9 @@ +- text: Button variant +- combobox "Milano" +- text: Form-control variant Location (required)* +- combobox "Select a location..." +- text: "Required Control panel Current value (ngModel): Milano Current value (formControl): none Location valid: false" +- checkbox "Readonly" +- text: Readonly +- checkbox "Disabled" +- text: Disabled \ No newline at end of file diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..5c0a4c5198 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30bcf94a2014fcaead03f87615453ee759bdc92776cfbc82d4ab018eea692775 +size 20264 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened-element-examples-chromium-light-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..5501ec8835 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95cf18fdec465f850aac9788db7ed3d619606f2cc312621e37f0002064a03f43 +size 20137 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened.yaml b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened.yaml new file mode 100644 index 0000000000..22a811f6f8 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-custom--custom-form-control-opened.yaml @@ -0,0 +1,14 @@ +- text: Button variant +- combobox: Milano +- text: Form-control variant Location (required)* +- combobox [expanded]: Select a location... +- text: "Control panel Current value (ngModel): Milano Current value (formControl): none Location valid: false" +- checkbox "Readonly" +- text: Readonly +- checkbox "Disabled" +- text: Disabled +- tree "Locations": + - treeitem "Company1" [level=1] + - treeitem "Company2" [level=1] + - treeitem "Company3" [level=1] +- button "Clear selection" [disabled] \ No newline at end of file diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..bcf74d76da --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fda4a41f6064a8df0f4ef3ddaca4537e121da08fc77a047cf4913f52f056bd82 +size 13552 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied-element-examples-chromium-light-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..80fd03d08f --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d330ff45389df1fe678d76d0c66f6ad61e42193951bf5ed40d4013e7882bb9ef +size 13211 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied.yaml b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied.yaml new file mode 100644 index 0000000000..c2a815f4fd --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-applied.yaml @@ -0,0 +1,9 @@ +- text: Button variant +- combobox "Select locations..." +- text: Form-control variant Locations (required)* +- combobox "Company1" +- text: "Control panel Selected (ngModel): none Selected (formControl): Company1 Locations valid: true" +- checkbox "Readonly" +- text: Readonly +- checkbox "Disabled" +- text: Disabled \ No newline at end of file diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..f2b6926433 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad7ad1bdadb635feb217c85bcae21e686ca8d8f8ff9cd75d678ee87914496811 +size 22344 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected-element-examples-chromium-light-linux.png b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..cd3328ccf2 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:073f0dbb492578e434b251e92495f24bd078bbfd156068c5da493421ecfbac90 +size 22289 diff --git a/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected.yaml b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected.yaml new file mode 100644 index 0000000000..2a9af36b51 --- /dev/null +++ b/playwright/snapshots/si-select.spec.ts-snapshots/si-select--si-select-multi-custom--multi-custom-first-selected.yaml @@ -0,0 +1,22 @@ +- text: Button variant +- combobox "Select locations..." +- text: Form-control variant Locations (required)* +- combobox "Select locations..." [expanded] +- text: "Control panel Selected (ngModel): none Selected (formControl): none Locations valid: false" +- checkbox "Readonly" +- text: Readonly +- checkbox "Disabled" +- text: Disabled +- dialog "Select locations": + - tree "Locations": + - treeitem "Company1 Company1" [checked] [level=1]: + - checkbox "Company1" [checked] + - text: "" + - treeitem "Company2 Company2" [level=1]: + - checkbox "Company2" + - text: "" + - treeitem "Company3 Company3" [level=1]: + - checkbox "Company3" + - text: "" + - button "Cancel" + - button "Apply" \ No newline at end of file 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))} + ); } } 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; 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(); 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) { 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-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(); 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-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); } 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; 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 { diff --git a/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.ts b/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.ts index a8dd1e41dc..83653d8cb4 100644 --- a/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.ts +++ b/projects/element-ng/tree-view/si-tree-view-item/si-tree-view-item.component.ts @@ -10,23 +10,24 @@ import { ChangeDetectorRef, Component, computed, + DestroyRef, DoCheck, effect, ElementRef, HostBinding, inject, - OnDestroy, OnInit, signal, TemplateRef, viewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { correctKeyRTL, MenuItem as MenuItemLegacy } from '@siemens/element-ng/common'; import { SiIconComponent } from '@siemens/element-ng/icon'; import { SiLoadingSpinnerComponent } from '@siemens/element-ng/loading-spinner'; import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; import { SiTranslatePipe } from '@siemens/element-translate-ng/translate'; -import { asyncScheduler, Subject, Subscription } from 'rxjs'; +import { asyncScheduler, Subject } from 'rxjs'; import { take, takeUntil } from 'rxjs/operators'; import { TREE_ITEM_CONTEXT } from '../si-tree-view-item-context'; @@ -70,12 +71,11 @@ import { '(keydown)': 'onKeydown($event)' } }) -export class SiTreeViewItemComponent - implements OnInit, OnDestroy, AfterViewInit, FocusableOption, DoCheck -{ +export class SiTreeViewItemComponent implements OnInit, AfterViewInit, FocusableOption, DoCheck { private element = inject(ElementRef); private siTreeViewService = inject(SiTreeViewService); private cdRef = inject(ChangeDetectorRef); + private destroyRef = inject(DestroyRef); protected treeItemContext = inject(TREE_ITEM_CONTEXT); protected treeViewComponent = this.treeItemContext.parent; /** @internal */ @@ -102,7 +102,6 @@ export class SiTreeViewItemComponent protected icons = this.treeViewComponent.computedIcons; private savedElement: ElementRef | undefined; - private subscriptions: Subscription[] = []; private indentLevel = this.treeItem.level ?? 0; private nextSiblingElement!: HTMLElement; protected readonly menuTrigger = viewChild(CdkMenuTrigger); @@ -145,15 +144,17 @@ export class SiTreeViewItemComponent } ngOnInit(): void { - this.subscriptions.push( - this.scrollIntoView.subscribe(event => this.onScrollIntoViewByConsumer(event)) - ); - this.subscriptions.push( - this.childrenLoaded.subscribe(event => this.childrenLoadingDone(event)) - ); - this.subscriptions.push( - this.siTreeViewService.triggerMarkForCheck.subscribe(() => this.cdRef.markForCheck()) - ); + this.scrollIntoView + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onScrollIntoViewByConsumer(event)); + + this.childrenLoaded + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.childrenLoadingDone(event)); + + this.siTreeViewService.triggerMarkForCheck + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.cdRef.markForCheck()); } ngDoCheck(): void { @@ -176,12 +177,6 @@ export class SiTreeViewItemComponent this.nextSiblingElement = this.element.nativeElement?.nextElementSibling; } - ngOnDestroy(): void { - this.subscriptions - .filter(subscription => !!subscription) - .forEach(subscription => subscription.unsubscribe()); - } - protected readonly enableSelection = this.treeViewComponent.enableSelection; protected readonly enableContextMenuButton = this.treeViewComponent.enableContextMenuButton; diff --git a/projects/element-ng/tree-view/si-tree-view.component.ts b/projects/element-ng/tree-view/si-tree-view.component.ts index e929c78de0..3bcf700a2b 100644 --- a/projects/element-ng/tree-view/si-tree-view.component.ts +++ b/projects/element-ng/tree-view/si-tree-view.component.ts @@ -17,6 +17,7 @@ import { computed, contentChild, contentChildren, + DestroyRef, ElementRef, inject, INJECTOR, @@ -32,11 +33,12 @@ import { viewChild, viewChildren } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MenuItem as MenuItemLegacy } from '@siemens/element-ng/common'; import { MenuItem } from '@siemens/element-ng/menu'; import { ElementDimensions, ResizeObserverService } from '@siemens/element-ng/resize-observer'; import { SiTranslatePipe, t, TranslatableString } from '@siemens/element-translate-ng/translate'; -import { asyncScheduler, defer, fromEvent, merge, Observable, Subject, Subscription } from 'rxjs'; +import { asyncScheduler, defer, fromEvent, merge, Observable, Subject } from 'rxjs'; import { map, withLatestFrom } from 'rxjs/operators'; import { SiTreeViewConverterService } from './si-tree-view-converter.service'; @@ -377,7 +379,6 @@ export class SiTreeViewComponent private manuallySelectedTreeItems = false; private latestFolderChanged?: TreeItem; private breadCrumbTreeItems: TreeItem[] = []; - private subscriptions: Subscription[] = []; private multiSelectionStart!: TreeItem; private _multiSelectionActive = false; private domChangeObserver?: MutationObserver; @@ -391,6 +392,7 @@ export class SiTreeViewComponent private cdRef = inject(ChangeDetectorRef); private resizeObserver = inject(ResizeObserverService); private injector = inject(INJECTOR); + private destroyRef = inject(DestroyRef); /** * Create a virtual root node so there is just a single root node. This makes sure the tree * can be fully traversed starting from any node. This is needed e.g. for recursively @@ -533,56 +535,62 @@ export class SiTreeViewComponent this.scroll$ = defer(() => fromEvent(this.treeViewInnerElement().nativeElement, 'scroll')); this.siTreeViewService.scroll$ = this.scroll$; if (this.isVirtualized()) { - this.subscriptions.push(this.scroll$.subscribe(event => this.onScroll(event))); + this.scroll$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onScroll(event)); } - this.subscriptions.push( - this.siTreeViewService.clickEvent.subscribe(event => this.onItemClicked(event)) - ); - this.subscriptions.push( - this.siTreeViewService.folderClickEvent.subscribe(event => this.onItemFolderClicked(event)) - ); - this.subscriptions.push( - this.siTreeViewService.checkboxClickEvent.subscribe(event => - this.onItemCheckboxClicked(event) - ) - ); - this.subscriptions.push( - this.siTreeViewService.loadChildrenEvent.subscribe(event => this.onLoadChildren(event)) - ); - this.subscriptions.push( - this.siTreeViewService.scrollIntoViewEvent.subscribe(event => this.onScrollIntoView(event)) - ); - this.subscriptions.push( - this.siTreeViewService.focusParentEvent.subscribe(event => { + this.siTreeViewService.clickEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onItemClicked(event)); + + this.siTreeViewService.folderClickEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onItemFolderClicked(event)); + + this.siTreeViewService.checkboxClickEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onItemCheckboxClicked(event)); + + this.siTreeViewService.loadChildrenEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onLoadChildren(event)); + + this.siTreeViewService.scrollIntoViewEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.onScrollIntoView(event)); + + this.siTreeViewService.focusParentEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => { if (!this.flatTree()) { this.focusParentItem(event); return; } this.onFlatTreeNavigateUp(); - }) - ); - this.subscriptions.push( - this.siTreeViewService.focusFirstChildEvent.subscribe(event => - this.focusFirstChildItem(event) - ) - ); - this.subscriptions.push( - this.siTreeViewVirtualizationService.itemsVirtualizedChanged.subscribe( - (event: ItemsVirtualizedArgs) => { - this.itemsVirtualizedChanged.emit(event); - this.evaluateForTreeItemsDiffer.next(); - this.cdRef.markForCheck(); - } - ) - ); + }); + + this.siTreeViewService.focusFirstChildEvent + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(event => this.focusFirstChildItem(event)); + + this.siTreeViewVirtualizationService.itemsVirtualizedChanged + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event: ItemsVirtualizedArgs) => { + this.itemsVirtualizedChanged.emit(event); + this.evaluateForTreeItemsDiffer.next(); + this.cdRef.markForCheck(); + }); + this.initialized = true; this.handleTreeMode(); } ngAfterViewInit(): void { this.addClassObserver(); - this.subscriptions.push(this.monitorTreeSizeChanges().subscribe(d => this.updatePageSize(d))); + this.monitorTreeSizeChanges() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(d => this.updatePageSize(d)); this.keyManager = new FocusKeyManager(this.getChildren(), this.injector) .withWrap() .withAllowedModifierKeys(['shiftKey']) @@ -663,9 +671,9 @@ export class SiTreeViewComponent } ngOnDestroy(): void { - this.subscriptions - .filter(subscription => !!subscription) - .forEach(subscription => subscription.unsubscribe()); + this.scrollChildIntoView.complete(); + this.childrenLoaded.complete(); + this.evaluateForTreeItemsDiffer.complete(); this.domChangeObserver?.disconnect(); } @@ -1025,7 +1033,7 @@ export class SiTreeViewComponent } private emitSelectedItems(): void { - if (this.enableSelection()) { + if (!this.destroyRef.destroyed && this.enableSelection()) { if (this.siTreeViewService.groupedList) { const filtered: TreeItem[] = this.selectedTreeItems.filter( item => !this.siTreeViewService.isGroupedItem(item) 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/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/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; } 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); 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' } ] }