diff --git a/api-goldens/element-ng/navbar-vertical-next/index.api.md b/api-goldens/element-ng/navbar-vertical-next/index.api.md index b2bc70a6e8..a95e80fb73 100644 --- a/api-goldens/element-ng/navbar-vertical-next/index.api.md +++ b/api-goldens/element-ng/navbar-vertical-next/index.api.md @@ -64,6 +64,7 @@ export class SiNavbarVerticalNextHeaderComponent { // @public (undocumented) export class SiNavbarVerticalNextItemComponent implements OnInit { + constructor(); readonly active: _angular_core.Signal; readonly activeOverride: _angular_core.InputSignal; readonly badge: _angular_core.InputSignal; diff --git a/playwright/e2e/element-examples/navbar-vertical-next.spec.ts b/playwright/e2e/element-examples/navbar-vertical-next.spec.ts index becd9fc75b..492a6d2d61 100644 --- a/playwright/e2e/element-examples/navbar-vertical-next.spec.ts +++ b/playwright/e2e/element-examples/navbar-vertical-next.spec.ts @@ -116,29 +116,97 @@ test.describe('navbar vertical next', () => { await expect(tooltip).not.toBeVisible(); }); - test(example + ' mobile collapsed', async ({ page, si }) => { - await page.setViewportSize({ width: 570, height: 600 }); - await si.visitExample(example, false); - - await expect(page.locator('.mobile-drawer')).toBeVisible(); - - await si.waitForAllAnimationsToComplete(); - await si.runVisualAndA11yTests('mobile-collapsed'); - }); - - test(example + ' mobile expanded', async ({ page, si }) => { - await page.setViewportSize({ width: 570, height: 600 }); - await si.visitExample(example, false); - - await page.locator('.mobile-drawer > button').click(); - await expect(page.locator('si-navbar-vertical-next:not(.nav-collapsed)')).toBeVisible(); - await page.getByText('Documentation').click(); - await page.getByRole('link', { name: 'Sub item 4' }).click(); - await expect(page.locator('si-navbar-vertical-next:not(.nav-collapsed)')).toHaveCount(0); - await page.locator('.mobile-drawer > button').click(); - - await si.waitForAllAnimationsToComplete(); - await si.runVisualAndA11yTests('mobile-expanded'); + test.describe('mobile', () => { + test.beforeEach(async ({ page, si }) => { + await page.setViewportSize({ width: 570, height: 600 }); + await si.visitExample(example, false); + }); + + test(example + ' collapsed', async ({ page, si }) => { + await expect(page.locator('.mobile-drawer')).toBeVisible(); + + await si.waitForAllAnimationsToComplete(); + await si.runVisualAndA11yTests('mobile-collapsed'); + }); + + test.describe('flat group', () => { + test.beforeEach(async ({ page }) => { + // Expand the drawer and open the Documentation flat group. + await page.locator('.mobile-drawer > button').click(); + await expect(page.locator('si-navbar-vertical-next:not(.nav-collapsed)')).toBeVisible(); + await page.getByRole('button', { name: 'Documentation' }).click(); + await expect(page.locator('si-navbar-vertical-next.nav-flat-group-open')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Back' })).toBeVisible(); + }); + + test(example + ' opened', async ({ page, si }) => { + // Sub-items of the opened flat group are visible. The trigger button itself + // is hidden in flat-group-open state (only the header label + back button show). + await expect(page.getByRole('link', { name: 'Sub item 4' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sub item 5' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sub item 6' })).toBeVisible(); + + await si.waitForAllAnimationsToComplete(); + await si.runVisualAndA11yTests('mobile-flat-group-opened'); + }); + + test(example + ' expanded', async ({ page, si }) => { + await expect(page.getByRole('link', { name: 'Sub item 4' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sub item 5' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sub item 6' })).toBeVisible(); + + // Click Sub item 4: navigates and collapses the drawer on mobile. + await page.getByRole('link', { name: 'Sub item 4' }).click(); + await expect(page).toHaveURL(/subItem4/); + + // Re-open the drawer: the flat group state is preserved and the + // active item is highlighted. + await page.locator('.mobile-drawer > button').click(); + await expect(page.locator('si-navbar-vertical-next.nav-flat-group-open')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sub item 4' })).toHaveClass(/active/); + + await si.waitForAllAnimationsToComplete(); + await si.runVisualAndA11yTests('mobile-expanded'); + }); + + test(example + ' back navigation closes the submenu', async ({ page, si }) => { + await page.getByRole('button', { name: 'Back' }).click(); + + await expect(page.getByRole('button', { name: 'Back' })).toHaveCount(0); + await expect(page.locator('si-navbar-vertical-next.nav-flat-group-open')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Documentation' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + await si.waitForAllAnimationsToComplete(); + await si.runVisualAndA11yTests('mobile-flat-group-closed'); + }); + + test( + example + ' is preserved across drawer collapse and auto-closes on resize', + async ({ page, si }) => { + // Collapse the drawer: flat group state is preserved. + await page.locator('.mobile-drawer > button').click(); + await expect(page.locator('si-navbar-vertical-next.nav-collapsed')).toBeVisible(); + + // Re-open the drawer: the same flat group is still open. + await page.locator('.mobile-drawer > button').click(); + await expect(page.locator('si-navbar-vertical-next.nav-flat-group-open')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Back' })).toBeVisible(); + + // Resize to desktop: flat group auto-closes and the group falls back to flyout. + await page.setViewportSize({ width: 1200, height: 800 }); + await expect(page.locator('si-navbar-vertical-next.nav-flat-group-open')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Back' })).toHaveCount(0); + await page.getByRole('button', { name: 'Documentation' }).click(); + await expect(page.getByRole('group', { name: 'Documentation' })).toBeVisible(); + + await si.waitForAllAnimationsToComplete(); + await si.runVisualAndA11yTests('mobile-flat-group-resize-out'); + } + ); + }); }); }); diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-dark-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-dark-linux.png index 552b801340..e6bd35310c 100644 --- a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b354bc0d34a24c45a6bf0386ba92fbc93a32539ccdfd483f020a37ddb00579f4 -size 25480 +oid sha256:58bcf5a16e64e780686435790773df96ebb48302b99d94ab73e4100ee6fc25e2 +size 12628 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-light-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-light-linux.png index 9897e6ed75..2e19c43bd1 100644 --- a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:baefad5d39bc30de3fd213f70e80f7cbf4b45cae001d2d8fe747ef20845c6606 -size 24485 +oid sha256:97e2ba5fe3ca372b12407441482b2247a8479600a68a67d5f08c7258f410105e +size 12100 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded.yaml b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded.yaml index ec70854cd8..7363a39d6b 100644 --- a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded.yaml +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-expanded.yaml @@ -6,17 +6,8 @@ - heading "Navbar Vertical Next Example" [level=1] - navigation: - button "Toggle" [expanded] - - textbox "Search..." - - link "Home": - - /url: "#/viewer/viewer/home" - - text: Modules - - link "Energy & sustainability": - - /url: "#/viewer/viewer/energy" - - button "User management" - - group "User management" - - link "Test coverage": - - /url: "#/viewer/viewer/coverage" - - button "Documentation" [expanded] + - button "Back" + - heading "Documentation" [level=2] - group "Documentation": - link "Sub item 4": - /url: "#/viewer/viewer/subItem4" @@ -24,11 +15,10 @@ - /url: "#/viewer/viewer/subItem5" - link "Sub item 6": - /url: "#/viewer/viewer/subItem6" - - button "Action" - - link "Configuration": - - /url: "#/viewer/viewer/configuration" - main: - heading "Here is a title" [level=2] - text: Content with path 'subItem4' Control panel - checkbox "Always flyout" - - text: Always flyout \ No newline at end of file + - text: Always flyout + - checkbox "Inline collapse" + - text: Inline collapse \ No newline at end of file diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed-element-examples-chromium-dark-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..d9cea48573 --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dd541d35181b07b473b5063b45a32a6557f2dda0d19ef92cc1ce006d3847a5e +size 23662 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed-element-examples-chromium-light-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..1d7d039932 --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b26d6ce8aefff5fef29b52f050c36fc8c1048690584bf055d4503d2e5461fe81 +size 22885 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed.yaml b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed.yaml new file mode 100644 index 0000000000..1ac75f73ee --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-closed.yaml @@ -0,0 +1,30 @@ +- link "Jump to Main content" +- link "Jump to Navigation" +- banner: + - link "Siemens logo": + - /url: "#/" + - heading "Navbar Vertical Next Example" [level=1] +- navigation: + - button "Toggle" [expanded] + - textbox "Search..." + - link "Home": + - /url: "#/viewer/viewer/home" + - text: Modules + - link "Energy & sustainability": + - /url: "#/viewer/viewer/energy" + - button "User management" + - group "User management" + - link "Test coverage": + - /url: "#/viewer/viewer/coverage" + - button "Documentation" + - group "Documentation" + - button "Action" + - link "Configuration": + - /url: "#/viewer/viewer/configuration" +- main: + - heading "Here is a title" [level=2] + - text: Content with path 'home' Control panel + - checkbox "Always flyout" + - text: Always flyout + - checkbox "Inline collapse" + - text: Inline collapse \ No newline at end of file diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened-element-examples-chromium-dark-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..46f490b71f --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff14cfa8f496c200a2a6ad07c8b43b4c95efb7bf3fc2ba42b6af54d29f6e9df3 +size 12442 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened-element-examples-chromium-light-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..ef876bc1d8 --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97bc661d34b0f0c4bd02ac9bae9c37ec74e118dd6a258cc92a5187d8a8fbbc34 +size 11823 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened.yaml b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened.yaml new file mode 100644 index 0000000000..a6eba9a7a9 --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-opened.yaml @@ -0,0 +1,24 @@ +- link "Jump to Main content" +- link "Jump to Navigation" +- banner: + - link "Siemens logo": + - /url: "#/" + - heading "Navbar Vertical Next Example" [level=1] +- navigation: + - button "Toggle" [expanded] + - button "Back" + - heading "Documentation" [level=2] + - group "Documentation": + - link "Sub item 4": + - /url: "#/viewer/viewer/subItem4" + - link "Sub item 5": + - /url: "#/viewer/viewer/subItem5" + - link "Sub item 6": + - /url: "#/viewer/viewer/subItem6" +- main: + - heading "Here is a title" [level=2] + - text: Content with path 'home' Control panel + - checkbox "Always flyout" + - text: Always flyout + - checkbox "Inline collapse" + - text: Inline collapse \ No newline at end of file diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out-element-examples-chromium-dark-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..2ac36f4c6b --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebbfb9e920259b503c67ffca2ce3a6be70fd3c7b917a5aaad86ce42c82408190 +size 33767 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out-element-examples-chromium-light-linux.png b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..1ede8992f4 --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fc1bf8b9e03a57ce5c8448da7de938d00375646025f7924eacbb11bac57a41f +size 32677 diff --git a/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out.yaml b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out.yaml new file mode 100644 index 0000000000..aaa6d35225 --- /dev/null +++ b/playwright/snapshots/navbar-vertical-next.spec.ts-snapshots/si-navbar-vertical-next--si-navbar-vertical-next--mobile-flat-group-resize-out.yaml @@ -0,0 +1,36 @@ +- link "Jump to Main content" +- link "Jump to Navigation" +- banner: + - link "Siemens logo": + - /url: "#/" + - heading "Navbar Vertical Next Example" [level=1] +- navigation: + - button "Toggle" [expanded] + - textbox "Search..." + - link "Home": + - /url: "#/viewer/viewer/home" + - text: Modules + - link "Energy & sustainability": + - /url: "#/viewer/viewer/energy" + - button "User management" + - group "User management" + - link "Test coverage": + - /url: "#/viewer/viewer/coverage" + - button "Documentation" [expanded] + - group "Documentation": + - link "Sub item 4": + - /url: "#/viewer/viewer/subItem4" + - link "Sub item 5": + - /url: "#/viewer/viewer/subItem5" + - link "Sub item 6": + - /url: "#/viewer/viewer/subItem6" + - button "Action" + - link "Configuration": + - /url: "#/viewer/viewer/configuration" +- main: + - heading "Here is a title" [level=2] + - text: Content with path 'home' Control panel + - checkbox "Always flyout" + - text: Always flyout + - checkbox "Inline collapse" + - text: Inline collapse \ No newline at end of file diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-flat-group.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-flat-group.component.scss new file mode 100644 index 0000000000..a0efe4403e --- /dev/null +++ b/projects/element-ng/navbar-vertical-next/si-navbar-flat-group.component.scss @@ -0,0 +1,43 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: flex; + flex-direction: column; + min-block-size: 0; + flex: 1 0 0; +} + +.flat-group-header { + display: flex; + align-items: center; + // `gap` keeps the back button's focus outline (6px wide + 1px offset) clear of the title. + gap: map.get(variables.$spacers, 4); + // Horizontal padding gives the focus outline clearance against the drawer edge and + // visually aligns the back button with the icon position of regular navbar items. + padding-inline: map.get(variables.$spacers, 5); + color: variables.$element-text-primary; + opacity: 1; + transform: translateY(0); + transition: + opacity variables.element-transition-duration(200ms) ease, + transform variables.element-transition-duration(200ms) ease; + + @starting-style { + opacity: 0; + transform: translateY(-4px); + } + + &.flat-group-header-leave { + opacity: 0; + transform: translateY(-4px); + transition-duration: variables.element-transition-duration(150ms); + } + + // Keep the flat-group state alive while the mobile drawer is collapsed, + // but hide the header so it doesn't bleed out of the 0-width nav. + // When the drawer re-opens, the header reappears together with the items. + :host-context(.nav-collapsed) & { + display: none; + } +} diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-flat-group.component.ts b/projects/element-ng/navbar-vertical-next/si-navbar-flat-group.component.ts new file mode 100644 index 0000000000..013f970634 --- /dev/null +++ b/projects/element-ng/navbar-vertical-next/si-navbar-flat-group.component.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { CdkPortalOutlet, DomPortal } from '@angular/cdk/portal'; +import { ChangeDetectionStrategy, Component, computed, effect, input, signal } from '@angular/core'; +import { elementLeft2 } from '@siemens/element-icons'; +import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; + +/** + * Mobile flat-group shell. + * + * On mobile, opening a group renders its items as a flat list inside this + * shell with a back button on top, instead of expanding inline. + * + * The shell owns the open/close state and renders the header (back button + + * active group label). Items are projected via `` and stay + * mounted across open/close so their state (focus, scroll, ...) is preserved. + * + * @experimental + */ +@Component({ + selector: 'si-navbar-flat-group', + imports: [CdkPortalOutlet, SiIconComponent], + template: `@let labelPortal = activeFlatLabelPortal(); + @if (labelPortal) { +
+ + +
+ } + `, + styleUrl: './si-navbar-flat-group.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SiNavbarFlatGroupComponent { + protected readonly icons = addIcons({ elementLeft2 }); + + /** + * Whether the navbar is in flat-group mode (mobile + expanded). + * When `false`, any open group is closed automatically. + * + * @defaultValue false + */ + readonly flatMode = input(false); + + /** + * @internal + * Id of the group currently shown as a flat group, or `undefined` if none. + */ + readonly openFlatGroupId = signal(undefined); + + /** + * @internal + * Label of the active trigger item, rendered inside the header chip. + * The active item pushes its portal here when its group opens. + */ + readonly activeFlatLabelPortal = signal(undefined); + + /** @internal `true` while a flat group is open. */ + readonly flatGroupOpen = computed(() => !!this.activeFlatLabelPortal()); + + constructor() { + // Auto-close when leaving flat mode (e.g. resize to desktop, drawer collapse). + effect(() => { + if (!this.flatMode()) { + this.closeFlatGroup(); + } + }); + } + + /** @internal Closes the open flat group. Used by the back button. */ + closeFlatGroup(): void { + this.openFlatGroupId.set(undefined); + this.activeFlatLabelPortal.set(undefined); + } +} diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-divider.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-divider.component.scss index d375928496..22edb49c2a 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-divider.component.scss +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-divider.component.scss @@ -8,3 +8,7 @@ margin-block: map.get(variables.$spacers, 3); margin-inline: map.get(variables.$spacers, 5); } + +:host-context(.nav-flat-group-open) { + display: none; +} diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-footer-items.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-footer-items.component.scss index 19810db970..faf55a59bb 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-footer-items.component.scss +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-footer-items.component.scss @@ -12,6 +12,10 @@ display: none; } +:host-context(.nav-flat-group-open) { + display: none; +} + @include all-variables.media-breakpoint-up(sm) { :host-context(:not(.nav-text-only).nav-collapsed) { display: block; diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group-trigger.directive.ts b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group-trigger.directive.ts index 0b9156f0c7..aee8ba4895 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group-trigger.directive.ts +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group-trigger.directive.ts @@ -16,6 +16,7 @@ import { DestroyRef, Directive, effect, + ElementRef, EmbeddedViewRef, inject, Injector, @@ -53,9 +54,9 @@ class SiNavbarFlyoutAnchorComponent { host: { class: 'dropdown-toggle', '[id]': 'id', - '[class.show]': 'expanded()', + '[class.show]': 'navbar.flatMode() ? flatGroupActive() : expanded()', '[attr.aria-controls]': 'groupId', - '[attr.aria-expanded]': 'flyoutMode() ? flyout() : expanded()', + '[attr.aria-expanded]': 'ariaExpanded()', '(click)': 'triggered()' } }) @@ -110,6 +111,31 @@ export class SiNavbarVerticalNextGroupTriggerDirective { computation: () => false }); + /** `true` when this group is currently rendered as a mobile flat group. + * @internal + */ + readonly flatGroupActive = computed( + () => this.navbar.flatMode() && this.navbar.flatGroup()?.openFlatGroupId() === this.groupId + ); + + /** + * Whether the group is currently revealed to the user, taking the active + * rendering mode into account. Used for `aria-expanded`. + * @internal + */ + readonly ariaExpanded = computed(() => { + if (this.navbar.flatMode()) { + return this.flatGroupActive(); + } + if (this.flyoutMode()) { + return this.flyout(); + } + return this.expanded(); + }); + + /** + * Whether the open flyout overlay currently contains the active route. + * Reset together with `flyout` so it never lingers across mode changes. /** `true` when this group contains the active route. Set by the group component. * @internal */ @@ -124,6 +150,7 @@ export class SiNavbarVerticalNextGroupTriggerDirective { private flyoutOutsideClickSubscription?: Subscription; private readonly viewContainer = inject(ViewContainerRef); + private readonly elementRef = inject>(ElementRef); private readonly overlay = inject(Overlay); private readonly destroyRef = inject(DestroyRef); private readonly injector = Injector.create({ parent: inject(Injector), providers: [] }); @@ -180,8 +207,16 @@ export class SiNavbarVerticalNextGroupTriggerDirective { this.attachInline(); } + /** @internal */ + focus(): void { + this.elementRef.nativeElement.focus(); + } + protected triggered(): void { - if (this.flyoutMode()) { + if (this.navbar.flatMode()) { + const flat = this.navbar.flatGroup(); + flat?.openFlatGroupId.set(this.flatGroupActive() ? undefined : this.groupId); + } else if (this.flyoutMode()) { this.toggleFlyout(); } else { this.expanded.set(!this.expanded()); diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.scss index 3676641684..706c543439 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.scss +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.scss @@ -30,3 +30,28 @@ display: block; position: static; } + +:host-context(.nav-flat-group-open) { + display: none; +} + +:host(.flat-group-active) { + display: block; +} + +.flat-group { + opacity: 1; + transform: translateX(0); + transition: + opacity variables.element-transition-duration(250ms) ease, + transform variables.element-transition-duration(250ms) ease; + + @starting-style { + opacity: 0; + transform: translateX(16px); + } + + &.group-leave { + display: none; + } +} diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.ts b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.ts index 2df8c656fa..36b80ec5eb 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.ts +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-group.component.ts @@ -3,11 +3,19 @@ * SPDX-License-Identifier: MIT */ import { CdkTrapFocus } from '@angular/cdk/a11y'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + contentChildren, + effect, + inject +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { RouterLinkActive } from '@angular/router'; import { SiNavbarVerticalNextGroupTriggerDirective } from './si-navbar-vertical-next-group-trigger.directive'; +import { SiNavbarVerticalNextItemComponent } from './si-navbar-vertical-next-item.component'; import { SI_NAVBAR_VERTICAL_NEXT } from './si-navbar-vertical-next.provider'; /** @experimental */ @@ -16,10 +24,13 @@ import { SI_NAVBAR_VERTICAL_NEXT } from './si-navbar-vertical-next.provider'; imports: [CdkTrapFocus], template: `@if (visible()) { @let flyout = groupTrigger.flyout(); + @let flat = navbar.flatMode(); +
@@ -31,10 +42,12 @@ import { SI_NAVBAR_VERTICAL_NEXT } from './si-navbar-vertical-next.provider'; styleUrl: './si-navbar-vertical-next-group.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, host: { - role: 'group', + 'role': 'group', '[id]': 'groupTrigger.groupId', - '[attr.aria-labelledby]': 'groupTrigger.id', + '[attr.aria-labelledby]': + 'groupTrigger.flatGroupActive() ? groupTrigger.groupId + "-heading" : groupTrigger.id', 'animate.enter': 'component-enter', + '[class.flat-group-active]': 'groupTrigger.flatGroupActive()', '(keydown.escape)': 'close()' } }) @@ -43,17 +56,44 @@ export class SiNavbarVerticalNextGroupComponent { protected readonly groupTrigger = inject(SiNavbarVerticalNextGroupTriggerDirective); private readonly routerLinkActive = inject(RouterLinkActive, { optional: true }); + /** Projected items, used to move focus into the first item when the flat group opens. */ + private readonly items = contentChildren(SiNavbarVerticalNextItemComponent, { + descendants: true + }); + protected readonly visible = computed(() => { - return this.groupTrigger.flyout() || (!this.navbar.collapsed() && this.groupTrigger.expanded()); + return ( + this.groupTrigger.flyout() || + this.groupTrigger.flatGroupActive() || + (!this.navbar.collapsed() && !this.navbar.flatMode() && this.groupTrigger.expanded()) + ); }); constructor() { this.routerLinkActive?.isActiveChange .pipe(takeUntilDestroyed()) .subscribe(active => this.groupTrigger.active.set(active)); + + // Flat group mode does not trap focus (the user must still be able to + // tab to the back button and the drawer toggle). Instead, move focus to + // the first item when it opens and back to the trigger when it closes. + let wasActive = false; + effect(() => { + const active = this.groupTrigger.flatGroupActive(); + if (active && !wasActive) { + this.items().at(0)?.focus(); + } else if (!active && wasActive) { + this.groupTrigger.focus(); + } + wasActive = active; + }); } protected close(): void { + if (this.groupTrigger.flatGroupActive()) { + this.navbar.flatGroup()?.closeFlatGroup(); + return; + } this.groupTrigger.hideFlyout(); } } diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-header.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-header.component.scss index 537ed1cc77..156fbb407f 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-header.component.scss +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-header.component.scss @@ -53,3 +53,7 @@ $transition-duration: variables.element-transition-duration(0.25s); transition-delay: 0s; } } + +:host-context(.nav-flat-group-open) { + display: none; +} diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.html b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.html index 6e77701a71..43f33da6bd 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.html +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.html @@ -2,7 +2,7 @@ @if (icon) { } -
+
@if (!isChip() && !navbar.textOnly() && formattedBadge()) { @@ -18,8 +18,5 @@ > } @if (group) { - + } diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.scss index ea163c5f96..bb6199479d 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.scss +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.scss @@ -86,6 +86,10 @@ } } +:host-context(si-navbar-vertical-next-group.flat-group-active) :host(.navbar-vertical-item) { + padding-inline-start: calc(1lh + #{map.get(variables.$spacers, 8)}); +} + :host-context(.dropdown-menu) :host(.navbar-vertical-item) { padding-inline-start: 0; @@ -120,6 +124,14 @@ margin-inline: auto 0; } +:host-context(.nav-flat-group-open) { + display: none; +} + +:host-context(si-navbar-vertical-next-group.flat-group-active) { + display: flex; +} + :host(.is-chip) { // Suppress the left-bar active indicator; `aria-current="page"` carries // the active role in the chip. diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.spec.ts b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.spec.ts index a041c96fd0..ace3467945 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.spec.ts +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.spec.ts @@ -54,6 +54,8 @@ describe('SiNavbarVerticalNextItemComponent', () => { collapsed: signal(false), textOnly: signal(false), alwaysFlyout: signal(false), + flatMode: signal(false), + flatGroup: signal(undefined), inlineCollapse: signal(false), chipPortalAttached: signal(false), chipMode: signal(false), @@ -64,6 +66,8 @@ describe('SiNavbarVerticalNextItemComponent', () => { mockNavbar.collapsed.set(false); mockNavbar.textOnly.set(false); mockNavbar.alwaysFlyout.set(false); + mockNavbar.flatMode.set(false); + mockNavbar.inlineCollapse.set(false); await TestBed.configureTestingModule({ providers: [{ provide: SI_NAVBAR_VERTICAL_NEXT, useValue: mockNavbar }] diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.ts b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.ts index e113507cc5..2a15ed9090 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.ts +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-item.component.ts @@ -8,10 +8,14 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, + effect, ElementRef, inject, input, - OnInit + OnInit, + untracked, + viewChild } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterLinkActive } from '@angular/router'; @@ -95,6 +99,16 @@ export class SiNavbarVerticalNextItemComponent implements OnInit { */ readonly chipMode = computed(() => this.navbar.chipMode()); + /** Label element of this item. Reused as the chip label in flat-group mode. */ + private readonly labelContentEl = viewChild.required>('labelContent'); + + /** + * @internal + * Portal of the item's label, rendered inside the flat-group header + * when this item's group is open. + */ + readonly labelPortal = computed(() => new DomPortal(this.labelContentEl())); + /** * Determines if the badge contains text-only content (not numeric) */ @@ -132,6 +146,13 @@ export class SiNavbarVerticalNextItemComponent implements OnInit { return badge.toString(); }); + /** Caret icon for the group: right when collapsed/flyout/flat, down when expanded inline. */ + protected readonly toggleIcon = computed(() => + this.navbar.alwaysFlyout() || this.navbar.flatMode() + ? this.icons.elementRight2 + : this.icons.elementDown2 + ); + /** Active via override or router link — excludes group state. * @internal */ @@ -176,10 +197,34 @@ export class SiNavbarVerticalNextItemComponent implements OnInit { } } + constructor() { + // Publish the label portal to the flat-group shell while this item's group is open. + effect(() => { + const flat = this.navbar.flatGroup(); + if (!flat || !this.group) return; + if (flat.openFlatGroupId() === this.group.groupId) { + const portal = this.labelPortal(); + untracked(() => flat.activeFlatLabelPortal.set(portal)); + } + }); + + inject(DestroyRef).onDestroy(() => { + const flat = this.navbar.flatGroup(); + if (flat && this.group && flat.openFlatGroupId() === this.group.groupId) { + flat.closeFlatGroup(); + } + }); + } + protected triggered(): void { this.parent?.group?.hideFlyout(); if (!this.group) { this.navbar.itemTriggered(); } } + + /** @internal */ + focus(): void { + this.elementRef.nativeElement.focus(); + } } diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-search.component.scss b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-search.component.scss index 022edb6dba..017a9cc155 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-search.component.scss +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next-search.component.scss @@ -17,6 +17,10 @@ } } +:host-context(.nav-flat-group-open) { + display: none; +} + si-search-bar { inline-size: 100%; } diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.html b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.html index c565d06bc2..359fdd89e6 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.html +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.html @@ -43,7 +43,9 @@ [attr.aria-hidden]="chipMode() ? 'true' : null" > - + + +
diff --git a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.ts b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.ts index 963622332c..b925c1b7f4 100644 --- a/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.ts +++ b/projects/element-ng/navbar-vertical-next/si-navbar-vertical-next.component.ts @@ -35,6 +35,7 @@ import { BOOTSTRAP_BREAKPOINTS } from '@siemens/element-ng/resize-observer'; import { SiSkipLinkTargetDirective } from '@siemens/element-ng/skip-links'; import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; +import { SiNavbarFlatGroupComponent } from './si-navbar-flat-group.component'; import { SiNavbarVerticalNextItemComponent } from './si-navbar-vertical-next-item.component'; import { SI_NAVBAR_VERTICAL_NEXT } from './si-navbar-vertical-next.provider'; @@ -47,7 +48,13 @@ interface UIState { /** @experimental */ @Component({ selector: 'si-navbar-vertical-next', - imports: [PortalModule, SiIconComponent, SiSkipLinkTargetDirective, SiTranslatePipe], + imports: [ + PortalModule, + SiIconComponent, + SiNavbarFlatGroupComponent, + SiSkipLinkTargetDirective, + SiTranslatePipe + ], templateUrl: './si-navbar-vertical-next.component.html', styleUrl: './si-navbar-vertical-next.component.scss', providers: [{ provide: SI_NAVBAR_VERTICAL_NEXT, useExisting: SiNavbarVerticalNextComponent }], @@ -58,6 +65,7 @@ interface UIState { '[class.nav-text-only]': 'textOnly()', '[class.nav-inline-collapse]': 'inlineCollapse()', '[class.visible]': 'visible()', + '[class.nav-flat-group-open]': 'flatGroup()?.flatGroupOpen()', '[class.ready]': 'ready()' } }) @@ -159,6 +167,26 @@ export class SiNavbarVerticalNextComponent implements OnChanges, OnInit { protected readonly ready = signal(false); protected readonly smallScreen = signal(false); + /** `true` when viewport width is at or below the {@link BOOTSTRAP_BREAKPOINTS.smMinimum} (576px) breakpoint. */ + private readonly mobileScreen = signal(false); + + /** + * `true` when triggering a group should open the mobile flat group + * instead of inline/flyout. Tied to the mobile breakpoint only — collapsing the + * drawer on mobile must not reset the open flat group, so this stays `true` + * regardless of `collapsed`. + * @internal + */ + readonly flatMode = computed(() => this.mobileScreen()); + + /** + * @internal + * Reference to the inner flat-group shell. Consumers (items, groups, + * triggers) read flat-group state through this getter because content + * projection breaks element-injector inheritance. Undefined until the + * navbar's own view is initialized. + */ + readonly flatGroup = viewChild(SiNavbarFlatGroupComponent); /** Stable ViewContainerRef inside