From 7e7803065a99235d49e925062d0f2a70d0c51645 Mon Sep 17 00:00:00 2001 From: Karan Mistry Date: Wed, 19 Aug 2026 15:37:24 +0530 Subject: [PATCH] feat(side-panel): support inner navigation Add an optional back button with a configurable label, output, and focus API. Fixes #2381 --- .../element-ng/side-panel/index.api.md | 4 + .../element-examples/si-side-panel.spec.ts | 18 +++ ...l-element-examples-chromium-dark-linux.png | 3 + ...-element-examples-chromium-light-linux.png | 3 + ...i-side-panel-inner-navigation--detail.yaml | 31 +++++ ...t-element-examples-chromium-dark-linux.png | 3 + ...-element-examples-chromium-light-linux.png | 3 + ...-si-side-panel-inner-navigation--list.yaml | 19 ++++ .../si-side-panel-content.component.html | 22 +++- .../si-side-panel-content.component.spec.ts | 28 ++++- .../si-side-panel-content.component.ts | 34 +++++- .../si-translatable-keys.interface.ts | 1 + .../si-side-panel-inner-navigation.html | 106 ++++++++++++++++++ .../si-side-panel-inner-navigation.ts | 101 +++++++++++++++++ 14 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail.yaml create mode 100644 playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-dark-linux.png create mode 100644 playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-light-linux.png create mode 100644 playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list.yaml create mode 100644 src/app/examples/si-side-panel/si-side-panel-inner-navigation.html create mode 100644 src/app/examples/si-side-panel/si-side-panel-inner-navigation.ts diff --git a/api-goldens/element-ng/side-panel/index.api.md b/api-goldens/element-ng/side-panel/index.api.md index 88f3e33034..f393e784dd 100644 --- a/api-goldens/element-ng/side-panel/index.api.md +++ b/api-goldens/element-ng/side-panel/index.api.md @@ -80,12 +80,15 @@ export class SiSidePanelComponent implements OnInit, OnDestroy, OnChanges { // @public (undocumented) export class SiSidePanelContentComponent implements OnInit { constructor(); + readonly back: _angular_core.OutputEmitterRef; + readonly backButtonLabel: _angular_core.InputSignal; readonly closeButtonLabel: _angular_core.InputSignal; // @deprecated (undocumented) readonly collapsibleInput: _angular_core.InputSignalWithTransform; readonly displayMode: _angular_core.InputSignal; readonly enterFullscreenLabel: _angular_core.InputSignal; readonly exitFullscreenLabel: _angular_core.InputSignal; + focusBackButton(): void; readonly heading: _angular_core.InputSignal; readonly navigateConfig: _angular_core.InputSignal; readonly primaryActions: _angular_core.InputSignal<(MenuItem | ContentActionBarMainItem)[]>; @@ -93,6 +96,7 @@ export class SiSidePanelContentComponent implements OnInit { readonly searchEvent: _angular_core.OutputEmitterRef; readonly searchPlaceholder: _angular_core.InputSignal; readonly secondaryActions: _angular_core.InputSignal<(MenuItem | MenuItem_2)[]>; + readonly showBackButton: _angular_core.InputSignalWithTransform; readonly showMobileDrawerBadge: _angular_core.InputSignalWithTransform; // @deprecated readonly statusActions: _angular_core.InputSignal; diff --git a/playwright/e2e/element-examples/si-side-panel.spec.ts b/playwright/e2e/element-examples/si-side-panel.spec.ts index c741f016f3..d5afa42034 100644 --- a/playwright/e2e/element-examples/si-side-panel.spec.ts +++ b/playwright/e2e/element-examples/si-side-panel.spec.ts @@ -7,6 +7,7 @@ import { expect, test } from '../../support/test-helpers'; test.describe('si-side-panel', () => { const example = 'si-side-panel/si-side-panel'; const exampleCollapsible = 'si-side-panel/si-side-panel-collapsible'; + const exampleInnerNavigation = 'si-side-panel/si-side-panel-inner-navigation'; const exampleCollapsibleLegacyStatusActions = 'si-side-panel/si-side-panel-collapsible-legacy-status-actions'; @@ -87,4 +88,21 @@ test.describe('si-side-panel', () => { await expect(page.locator('si-side-panel .fullscreen-button')).toBeHidden(); await si.runVisualAndA11yTests(); }); + + test(exampleInnerNavigation, async ({ page, si }) => { + await si.visitExample(exampleInnerNavigation); + + await page.getByRole('button', { name: 'Open side panel' }).click(); + const firstItem = page.getByRole('button', { name: 'Title link item 1' }); + await firstItem.click(); + + const backButton = page.getByRole('button', { name: 'Back to main view' }); + await expect(backButton).toBeFocused(); + await expect(page.getByText('Content detail 1', { exact: true })).toBeVisible(); + await si.runVisualAndA11yTests('detail'); + + await backButton.click(); + await expect(firstItem).toBeFocused(); + await si.runVisualAndA11yTests('list'); + }); }); diff --git a/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..306e71c91d --- /dev/null +++ b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab85d92baffa4f96655d6feccd86de7e8e5ec8f74620612713af34b8776c35a3 +size 30408 diff --git a/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-light-linux.png b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..a2e8ff7514 --- /dev/null +++ b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4db8d69768ef89e931f03390a8d285bc0e3a65e510c7452693a039bf9be5beba +size 30122 diff --git a/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail.yaml b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail.yaml new file mode 100644 index 0000000000..7aeea2e966 --- /dev/null +++ b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--detail.yaml @@ -0,0 +1,31 @@ +- button "Back to main view" +- paragraph: Content detail 1 +- button "Close" +- text: Property 1 +- textbox "Placeholder" +- text: Property 2 +- textbox "Placeholder" +- text: Property 3 +- combobox "Property 3": + - option "Placeholder" [disabled] [selected] + - option "Option 1" + - option "Option 2" +- text: Property 4 +- spinbutton "Property 4" +- text: °C Group label +- checkbox "Label Label Label Label" +- text: Label +- checkbox +- text: Label Group label +- radio +- text: Label +- radio +- text: Label Section title +- switch "Toggle label" +- text: Toggle label +- main: + - link "Siemens logo": + - /url: "#/" + - heading "Application name" [level=1] + - button "Side panel" + - button "Open side panel" \ No newline at end of file diff --git a/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..1ff58e85d2 --- /dev/null +++ b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59decb2f69799bb9b4e7d4c1c2eb188a412b0f3e72c72e32515806753cbd6333 +size 17023 diff --git a/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-light-linux.png b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..772526b9de --- /dev/null +++ b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21463b7bdf4dae2162fbb0ae63fb3e52424d84e102b298338d8540530a6a5038 +size 16863 diff --git a/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list.yaml b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list.yaml new file mode 100644 index 0000000000..73d854e6d0 --- /dev/null +++ b/playwright/snapshots/si-side-panel.spec.ts-snapshots/si-side-panel--si-side-panel-inner-navigation--list.yaml @@ -0,0 +1,19 @@ +- paragraph: Content title 1 +- button "Close" +- list: + - listitem: + - button "Title link item 1" + - listitem: + - button "Title link item 2" + - listitem: + - button "Title link item 3" + - listitem: + - button "Title link item 4" + - listitem: + - button "Title link item 5" +- main: + - link "Siemens logo": + - /url: "#/" + - heading "Application name" [level=1] + - button "Side panel" + - button "Open side panel" \ No newline at end of file diff --git a/projects/element-ng/side-panel/si-side-panel-content.component.html b/projects/element-ng/side-panel/si-side-panel-content.component.html index 4957690f7a..f094fba359 100644 --- a/projects/element-ng/side-panel/si-side-panel-content.component.html +++ b/projects/element-ng/side-panel/si-side-panel-content.component.html @@ -1,5 +1,25 @@
-

{{ heading() | translate }}

+
+ @if (showBackButton()) { + + } +

+ {{ heading() | translate }} +

+
@if ((primaryActions().length || secondaryActions().length) && focusable()) { ` }) class TestHostComponent { + readonly sidePanelContent = viewChild.required(SiSidePanelContentComponent); + readonly showBackButton = signal(false); readonly displayMode = signal(undefined); + readonly back = vi.fn(); readonly navigateConfig: SidePanelNavigateConfig = { type: 'link', label: 'Navigate', @@ -55,6 +63,24 @@ describe('SiSidePanelContentComponent', () => { expect(sidePanelService.toggle).toHaveBeenCalled(); }); + it('should show the optional back button and emit when activated', async () => { + const backButton = page.getByRole('button', { name: 'Back to devices' }); + + await expect.element(backButton).not.toBeInTheDocument(); + + component.showBackButton.set(true); + await fixture.whenStable(); + + await expect.element(backButton).toBeInTheDocument(); + + component.sidePanelContent().focusBackButton(); + await expect.element(backButton).toHaveFocus(); + + await userEvent.click(backButton); + + expect(component.back).toHaveBeenCalledOnce(); + }); + it('should show fullscreen/navigation button based on display mode', () => { const getNavigateLink = (): HTMLAnchorElement | null => element.querySelector('a[href="/details"]'); diff --git a/projects/element-ng/side-panel/si-side-panel-content.component.ts b/projects/element-ng/side-panel/si-side-panel-content.component.ts index 253d7fede8..d08b0e1f8a 100644 --- a/projects/element-ng/side-panel/si-side-panel-content.component.ts +++ b/projects/element-ng/side-panel/si-side-panel-content.component.ts @@ -10,11 +10,13 @@ import { computed, DestroyRef, effect, + ElementRef, inject, input, OnInit, output, - signal + signal, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; @@ -23,6 +25,7 @@ import { elementDoubleLeft, elementDoubleRight, elementExport, + elementLeft4, elementPinch, elementZoom } from '@siemens/element-icons'; @@ -96,6 +99,7 @@ export interface StatusItem extends MenuItemLegacy { }) export class SiSidePanelContentComponent implements OnInit { private static idCounter = 0; + private readonly backButton = viewChild>('backButton'); protected readonly panelContentId = `__si-side-panel-content-${SiSidePanelContentComponent.idCounter++}`; /** * @deprecated This input is no longer used. The collapsible state is managed by the SiSidePanelService. @@ -114,6 +118,28 @@ export class SiSidePanelContentComponent implements OnInit { */ readonly heading = input(''); + /** + * Whether to show the back button. + * + * @defaultValue false + */ + readonly showBackButton = input(false, { transform: booleanAttribute }); + + /** + * Aria label for the optional back button. + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_SIDE_PANEL.BACK:Back`) + * ``` + */ + readonly backButtonLabel = input(t(() => $localize`:@@SI_SIDE_PANEL.BACK:Back`)); + + /** Focuses the back button when it is available. */ + focusBackButton(): void { + this.backButton()?.nativeElement.focus(); + } + /** * Input list of primary action items * @@ -240,6 +266,11 @@ export class SiSidePanelContentComponent implements OnInit { */ readonly searchEvent = output(); + /** + * Emitted when the back button is activated. + */ + readonly back = output(); + protected readonly activatedRoute = inject(ActivatedRoute, { optional: true }); protected readonly service = inject(SiSidePanelService); protected readonly isCollapsed = signal(false); @@ -258,6 +289,7 @@ export class SiSidePanelContentComponent implements OnInit { elementDoubleLeft, elementDoubleRight, elementExport, + elementLeft4, elementPinch, elementZoom }); diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index be2c7a83fa..b6cc672077 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -244,6 +244,7 @@ export interface SiTranslatableKeys { 'SI_SEARCH_BAR.CLEAR_BUTTON'?: string; 'SI_SELECT.NO-RESULTS-FOUND'?: string; 'SI_SELECT.SEARCH-PLACEHOLDER'?: string; + 'SI_SIDE_PANEL.BACK'?: string; 'SI_SIDE_PANEL.CLOSE'?: string; 'SI_SIDE_PANEL.ENTER_FULLSCREEN'?: string; 'SI_SIDE_PANEL.EXIT_FULLSCREEN'?: string; diff --git a/src/app/examples/si-side-panel/si-side-panel-inner-navigation.html b/src/app/examples/si-side-panel/si-side-panel-inner-navigation.html new file mode 100644 index 0000000000..b98a8f879a --- /dev/null +++ b/src/app/examples/si-side-panel/si-side-panel-inner-navigation.html @@ -0,0 +1,106 @@ + +
+ + + +

Application name

+
+ + + + +
+ +
+ +
+
+ + + @if (selectedItem()) { +
+ + + + + + + + + + + + + +
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ } @else { +
    + @for (item of navigationItems; track item.id) { +
  • + +
  • + } +
+ } +
+
diff --git a/src/app/examples/si-side-panel/si-side-panel-inner-navigation.ts b/src/app/examples/si-side-panel/si-side-panel-inner-navigation.ts new file mode 100644 index 0000000000..367f34d02d --- /dev/null +++ b/src/app/examples/si-side-panel/si-side-panel-inner-navigation.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { + afterRenderEffect, + Component, + ElementRef, + signal, + viewChild, + viewChildren +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + SiApplicationHeaderComponent, + SiHeaderActionItemComponent, + SiHeaderActionsDirective, + SiHeaderBrandDirective, + SiHeaderLogoDirective +} from '@siemens/element-ng/application-header'; +import { SiFormItemComponent } from '@siemens/element-ng/form'; +import { SiNumberInputComponent } from '@siemens/element-ng/number-input'; +import { SiSidePanelComponent, SiSidePanelContentComponent } from '@siemens/element-ng/side-panel'; + +interface NavigationItem { + id: string; + title: string; + detailTitle: string; +} + +@Component({ + selector: 'app-sample', + imports: [ + RouterLink, + SiApplicationHeaderComponent, + SiHeaderActionItemComponent, + SiHeaderActionsDirective, + SiHeaderBrandDirective, + SiHeaderLogoDirective, + SiFormItemComponent, + SiSidePanelComponent, + SiNumberInputComponent, + SiSidePanelContentComponent + ], + templateUrl: './si-side-panel-inner-navigation.html' +}) +export class SampleComponent { + readonly collapsed = signal(true); + readonly selectedItem = signal(undefined); + readonly navigationItems: NavigationItem[] = [ + { + id: 'title-link-item-1', + title: 'Title link item 1', + detailTitle: 'Content detail 1' + }, + { + id: 'title-link-item-2', + title: 'Title link item 2', + detailTitle: 'Content detail 2' + }, + { + id: 'title-link-item-3', + title: 'Title link item 3', + detailTitle: 'Content detail 3' + }, + { + id: 'title-link-item-4', + title: 'Title link item 4', + detailTitle: 'Content detail 4' + }, + { + id: 'title-link-item-5', + title: 'Title link item 5', + detailTitle: 'Content detail 5' + } + ]; + + private readonly sidePanelContent = viewChild.required(SiSidePanelContentComponent); + private readonly navigationButtons = + viewChildren>('navigationButton'); + private originIndex?: number; + + constructor() { + afterRenderEffect(() => { + if (this.selectedItem()) { + this.sidePanelContent().focusBackButton(); + } else if (this.originIndex !== undefined) { + this.navigationButtons()[this.originIndex]?.nativeElement.focus(); + } + }); + } + + showDetails(item: NavigationItem, index: number): void { + this.originIndex = index; + this.selectedItem.set(item); + } + + showNavigation(): void { + this.selectedItem.set(undefined); + } +}