diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html
new file mode 100644
index 000000000..6912a8f25
--- /dev/null
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html
@@ -0,0 +1,52 @@
+@if (firstItem(); as first) {
+
+
+
+ @if (restItems().length > 0) {
+
+ }
+
+}
+
+
+
+
+
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss
index d87fb6f85..515b22ccf 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.scss
@@ -1,25 +1,62 @@
.tedi-sidenav-dropdown-group {
- .tedi-sidenav-dropdown-item {
- &:first-of-type {
- &::before {
- top: 50%;
- width: var(--_sidenav-tree-bullet-size);
- height: var(--_sidenav-tree-bullet-size);
- border-radius: 50%;
- transform: translateY(-50%) translateX(calc(-50% + var(--borders-01)));
- }
+ --_group-padding-left: var(--navigation-vertical-item-padding-left-level-2);
- &::after {
- width: 0;
- }
+ &__parent-wrapper {
+ position: relative;
+ display: block;
+ list-style: none;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ left: calc(
+ var(--_group-padding-left) + var(--_sidenav-tree-left-padding)
+ );
+ width: var(--_sidenav-tree-trunk-width);
+ height: calc(var(--_sidenav-dropdown-item-height) / 2);
+ content: "";
+ background-color: var(--navigation-vertical-tree-brand-default);
+ }
+ }
+
+ &__list {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
- &:not(:only-child) {
- &::after {
- top: 50%;
- width: var(--_sidenav-tree-trunk-width);
- height: 50%;
- transform: translateY(0);
- }
+ .tedi-sidenav-dropdown-item.tedi-sidenav-dropdown-group__parent {
+ position: relative;
+
+ &::before {
+ position: absolute;
+ top: 50%;
+ left: calc(var(--_padding-left) + var(--_sidenav-tree-left-padding));
+ width: var(--_sidenav-tree-bullet-size);
+ height: var(--_sidenav-tree-bullet-size);
+ content: "";
+ background-color: var(--navigation-vertical-tree-brand-default);
+ border-radius: 50%;
+ transform: translateY(-50%)
+ translateX(calc(-50% + calc(var(--_sidenav-tree-trunk-width) / 2)));
+ }
+
+ &::after {
+ position: absolute;
+ top: 0;
+ left: calc(var(--_padding-left) + var(--_sidenav-tree-left-padding));
+ width: var(--_sidenav-tree-trunk-width);
+ height: 100%;
+ content: "";
+ background-color: var(--navigation-vertical-tree-brand-default);
+ transform: translateY(50%);
+ }
+ }
+
+ &__item {
+ &:last-of-type {
+ &::before {
+ height: 50%;
}
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts
index c4ca795b6..b19b9285e 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.spec.ts
@@ -1,5 +1,7 @@
+import { QueryList } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { SideNavDropdownGroupComponent } from "./sidenav-dropdown-group.component";
+import { SideNavDropdownItemComponent } from "../sidenav-dropdown-item/sidenav-dropdown-item.component";
describe("SideNavDropdownGroupComponent", () => {
let fixture: ComponentFixture;
@@ -22,4 +24,40 @@ describe("SideNavDropdownGroupComponent", () => {
it("should have the base CSS class", () => {
expect(groupEl.classList.contains("tedi-sidenav-dropdown-group")).toBe(true);
});
+
+ it("should set itemsArray from ContentChildren in ngAfterContentInit", () => {
+ const mockItems = {
+ toArray: () => [{ id: 1 }, { id: 2 }],
+ changes: { subscribe: jest.fn() },
+ } as unknown as QueryList;
+
+ fixture.componentInstance.items = mockItems;
+ fixture.componentInstance.ngAfterContentInit();
+
+ expect(fixture.componentInstance.firstItem()).toEqual({ id: 1 });
+ expect(fixture.componentInstance.restItems()).toEqual([{ id: 2 }]);
+ });
+
+ it("should update itemsArray when items.changes emits", () => {
+ let changeCallback: () => void = () => { };
+ const mockItems = {
+ toArray: jest.fn().mockReturnValue([{ id: 1 }]),
+ changes: {
+ subscribe: (cb: () => void) => {
+ changeCallback = cb;
+ },
+ },
+ } as unknown as QueryList;
+
+ fixture.componentInstance.items = mockItems;
+ fixture.componentInstance.ngAfterContentInit();
+
+ expect(fixture.componentInstance.firstItem()).toEqual({ id: 1 });
+
+ mockItems.toArray = jest.fn().mockReturnValue([{ id: 1 }, { id: 2 }, { id: 3 }]);
+ changeCallback();
+
+ expect(fixture.componentInstance.firstItem()).toEqual({ id: 1 });
+ expect(fixture.componentInstance.restItems()).toEqual([{ id: 2 }, { id: 3 }]);
+ });
});
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts
index e09802dd1..d5c0c9c55 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.ts
@@ -1,18 +1,45 @@
import {
+ AfterContentInit,
ChangeDetectionStrategy,
Component,
+ computed,
+ ContentChildren,
+ QueryList,
+ signal,
ViewEncapsulation,
} from "@angular/core";
+import { RouterLink } from "@angular/router";
+import { SideNavDropdownItemComponent } from "../sidenav-dropdown-item/sidenav-dropdown-item.component";
@Component({
selector: "tedi-sidenav-dropdown-group",
standalone: true,
- template: "",
+ templateUrl: "./sidenav-dropdown-group.component.html",
styleUrl: "./sidenav-dropdown-group.component.scss",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
+ imports: [RouterLink],
host: {
"class": "tedi-sidenav-dropdown-group",
+ "role": "presentation",
+ "style": "display: contents",
},
})
-export class SideNavDropdownGroupComponent {}
+export class SideNavDropdownGroupComponent implements AfterContentInit {
+ @ContentChildren(SideNavDropdownItemComponent)
+ items!: QueryList;
+
+ private itemsArray = signal([]);
+
+ firstItem = computed(() => this.itemsArray()[0]);
+ restItems = computed(() => this.itemsArray().slice(1));
+
+ // to keep same component composition structure but rearrange dom elements inside the group for correct html semantics
+ ngAfterContentInit(): void {
+ this.itemsArray.set(this.items.toArray());
+
+ this.items.changes.subscribe(() => {
+ this.itemsArray.set(this.items.toArray());
+ });
+ }
+}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html
index 39e23eebf..2d6f9108a 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.html
@@ -1,14 +1,18 @@
-@if (href()) {
-
-
-
-} @else if (route()) {
-
-
-
-} @else {
-
-}
+
+ @if (href()) {
+
+
+
+ } @else if (route()) {
+
+
+
+ } @else {
+
+
+
+ }
+
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss
index 9637e8c2b..ec6e28d91 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.scss
@@ -5,11 +5,7 @@
position: relative;
display: block;
min-height: var(--_sidenav-dropdown-item-height);
- padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right)
- var(--_sidenav-item-padding-y)
- calc(var(--_padding-left) + var(--_sidenav-tree-container) + var(--_gap));
color: var(--navigation-vertical-item-text);
- cursor: pointer;
&:hover {
background: var(--navigation-vertical-item-background-hover);
@@ -20,6 +16,22 @@
background: var(--navigation-vertical-item-background-active);
}
+ &__trigger {
+ display: block;
+ padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right)
+ var(--_sidenav-item-padding-y)
+ calc(var(--_padding-left) + var(--_sidenav-tree-container) + var(--_gap));
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: none;
+ background: var(--navigation-vertical-item-background-focus);
+ box-shadow: var(--_sidenav-focus-ring);
+ }
+ }
+
&::before {
position: absolute;
top: 0;
@@ -42,22 +54,19 @@
}
&--parent {
- padding-left: var(--dropdown-item-padding-x);
+ .tedi-sidenav-dropdown-item__trigger {
+ padding-left: var(--dropdown-item-padding-x);
+ }
&::before,
&::after {
display: none;
}
}
+}
- &:last-of-type {
- &::before {
- height: 50%;
- }
- }
-
- a {
- color: inherit;
- text-decoration: none;
- }
+tedi-sidenav-dropdown-item:last-child > .tedi-sidenav-dropdown-item::before,
+tedi-sidenav-dropdown-item:has(+ tedi-sidenav-dropdown-group)
+ > .tedi-sidenav-dropdown-item::before {
+ height: 50%;
}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts
index 423a02596..bd52b0313 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts
@@ -3,7 +3,7 @@ import { SideNavDropdownItemComponent } from "./sidenav-dropdown-item.component"
describe("SideNavDropdownItemComponent", () => {
let fixture: ComponentFixture;
- let dropdownItemEl: HTMLElement;
+ let liElement: HTMLLIElement;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -11,21 +11,30 @@ describe("SideNavDropdownItemComponent", () => {
});
fixture = TestBed.createComponent(SideNavDropdownItemComponent);
- dropdownItemEl = fixture.nativeElement;
fixture.detectChanges();
+ liElement = fixture.nativeElement.querySelector("li");
});
it("should create the component", () => {
expect(fixture.componentInstance).toBeTruthy();
});
- it("should have the base CSS class", () => {
- expect(dropdownItemEl.classList.contains("tedi-sidenav-dropdown-item")).toBe(true);
+ it("should have the base CSS class on li element", () => {
+ expect(liElement.classList.contains("tedi-sidenav-dropdown-item")).toBe(true);
});
it("should add selected class when `selected` input is true", () => {
fixture.componentRef.setInput("selected", true);
fixture.detectChanges();
- expect(dropdownItemEl.classList.contains("tedi-sidenav-dropdown-item--selected")).toBe(true);
+ expect(liElement.classList.contains("tedi-sidenav-dropdown-item--selected")).toBe(true);
});
+
+ it("should set textContent value in ngAfterViewInit when text exists", () => {
+ fixture.nativeElement.textContent = "Test Item Text";
+
+ fixture.componentInstance.ngAfterViewInit();
+
+ expect(fixture.componentInstance.textContent()).toBe("Test Item Text");
+ });
+
});
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts
index 78f11c87f..49a6db2c0 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.ts
@@ -1,9 +1,13 @@
import { NgTemplateOutlet } from "@angular/common";
import {
+ AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
+ ElementRef,
+ inject,
input,
+ signal,
ViewEncapsulation,
} from "@angular/core";
import { RouterLink } from "@angular/router";
@@ -17,11 +21,10 @@ import { RouterLink } from "@angular/router";
encapsulation: ViewEncapsulation.None,
imports: [RouterLink, NgTemplateOutlet],
host: {
- role: "menuitem",
- "[class]": "classes()",
+ "style": "display: contents",
},
})
-export class SideNavDropdownItemComponent {
+export class SideNavDropdownItemComponent implements AfterViewInit {
/**
* Is navigation item selected
* @default false
@@ -36,6 +39,19 @@ export class SideNavDropdownItemComponent {
*/
route = input();
+ textContent = signal("");
+
+ private readonly host = inject(ElementRef);
+
+ ngAfterViewInit(): void {
+ if (this.host.nativeElement) {
+ const text = this.host.nativeElement.textContent?.trim();
+ if (text) {
+ this.textContent.set(text);
+ }
+ }
+ }
+
classes = computed(() => {
const classList = ["tedi-sidenav-dropdown-item"];
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html
index 992f44017..541fca773 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.html
@@ -1,4 +1,4 @@
-
+
@if (sidenavService.isCollapsed() && !!sidenavItem.href()) {
}
-
+
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss
index 99b8b1e29..7b1858fff 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.scss
@@ -1,22 +1,26 @@
-.tedi-sidenav-dropdown {
+.tedi-sidenav-dropdown-wrapper {
display: grid;
- visibility: hidden;
grid-template-rows: 0fr;
- overflow: hidden;
transition: grid-template-rows var(--_sidenav-transition-duration) ease;
- &--open {
- visibility: visible;
+ &:has(.tedi-sidenav-dropdown--open) {
grid-template-rows: 1fr;
-
- .tedi-sidenav-dropdown__items {
- visibility: visible;
- }
}
+}
- &__items {
- visibility: hidden;
- min-height: 0;
- transition: visibility var(--_sidenav-transition-duration) ease;
+.tedi-sidenav-dropdown {
+ visibility: hidden;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ list-style: none;
+
+ // z-index and block offsets for correct focus visuals
+ &--open {
+ position: relative;
+ z-index: 1;
+ visibility: visible;
+ padding-block: 4px;
+ margin-block: -4px;
}
}
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts
index c1266c6cf..0c33db271 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.spec.ts
@@ -51,15 +51,19 @@ describe("SideNavDropdownComponent", () => {
});
fixture = TestBed.createComponent(SideNavDropdownComponent);
- dropdownElement = fixture.nativeElement;
fixture.detectChanges();
+ dropdownElement = fixture.nativeElement.querySelector("ul");
});
it("should create the component", () => {
expect(fixture.componentInstance).toBeTruthy();
});
- it("should have the base CSS class", () => {
+ it("should have wrapper class on host element", () => {
+ expect(fixture.nativeElement.classList.contains("tedi-sidenav-dropdown-wrapper")).toBe(true);
+ });
+
+ it("should have the base CSS class on ul element", () => {
expect(dropdownElement.classList.contains("tedi-sidenav-dropdown")).toBe(true);
});
@@ -72,6 +76,6 @@ describe("SideNavDropdownComponent", () => {
it("ngAfterViewInit should set the `element` signal to the host element", () => {
fixture.componentInstance.element.set(null);
fixture.componentInstance.ngAfterViewInit();
- expect(fixture.componentInstance.element()).toBe(dropdownElement);
+ expect(fixture.componentInstance.element()).toBe(fixture.nativeElement);
});
});
diff --git a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts
index bedae79ed..ba87c6e2e 100644
--- a/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-dropdown/sidenav-dropdown.component.ts
@@ -22,8 +22,7 @@ import { SideNavService } from "../../../../services/sidenav/sidenav.service";
encapsulation: ViewEncapsulation.None,
imports: [SideNavDropdownItemComponent],
host: {
- role: "menubar",
- "[class]": "classes()",
+ "class": "tedi-sidenav-dropdown-wrapper",
},
})
export class SideNavDropdownComponent implements AfterViewInit {
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html
index aa027ba41..94194c593 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.html
@@ -1,45 +1,51 @@
-@if (sidenavService.isMobile()) {
- @if (sidenavService.isMobileItemOpen() && dropdown?.open()) {
-
-
-
-
-
-
-
-
+
+ @if (sidenavService.isMobile()) {
+ @if (sidenavService.isMobileItemOpen() && dropdown?.open()) {
+
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
+ } @else {
+
+
+
+ }
+ } @else if (sidenavService.tooltipEnabled()) {
+
+
+
+
+
+
+
+ {{ textContent() }}
+
+
} @else {
}
-} @else if (sidenavService.tooltipEnabled()) {
-
-
-
-
-
- {{ textContent() }}
-
-
-} @else {
-
-
-
-}
+
+
+
-
-
+ @if (route()) {
-
-
+ } @else {
-
+ }
@if (dropdown) {
+ {{ 'sidenav.toggleSubmenu' | tediTranslate: textContent(): dropdown.open() }}
-
-
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss
index 8844acdea..f3238cde7 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss
@@ -54,8 +54,11 @@
border: 0;
&:focus-visible {
- outline: var(--borders-01) solid var(--tedi-neutral-100);
- outline-offset: -2px;
+ position: relative;
+ z-index: 1;
+ outline: none;
+ background: var(--navigation-vertical-item-background-focus);
+ box-shadow: var(--_sidenav-focus-ring);
}
&:not(:only-child) {
@@ -91,9 +94,12 @@
}
&:focus-visible {
+ position: relative;
+ z-index: 1;
+ outline: none;
+
.tedi-sidenav-item__caret-container {
- outline: var(--borders-02) solid var(--tedi-neutral-100);
- outline-offset: var(--borders-01);
+ box-shadow: var(--_sidenav-focus-ring);
}
}
@@ -138,6 +144,14 @@
background: var(--navigation-vertical-item-background-hover);
}
+ &:focus-visible {
+ position: relative;
+ z-index: 1;
+ outline: none;
+ background: var(--navigation-vertical-item-background-focus);
+ box-shadow: var(--_sidenav-focus-ring);
+ }
+
.tedi-sidenav-item__text {
white-space: wrap;
}
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts
index 3ded8a917..216a92e11 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.spec.ts
@@ -3,7 +3,23 @@ import { signal } from "@angular/core";
import { SideNavItemComponent } from "./sidenav-item.component";
import { SideNavService } from "../../../../services/sidenav/sidenav.service";
+const mockCallbackHolder: { callback: (() => void) | null } = { callback: null };
+
+jest.mock("@angular/core", () => {
+ const actual = jest.requireActual("@angular/core");
+ return {
+ ...actual,
+ afterNextRender: jest.fn((callback: () => void, _options?: unknown) => {
+ mockCallbackHolder.callback = callback;
+ return { destroy: jest.fn() };
+ }),
+ };
+});
+
describe("SideNavItemComponent", () => {
+ afterEach(() => {
+ mockCallbackHolder.callback = null;
+ });
let fixture: ComponentFixture;
let itemElement: HTMLElement;
let sidenavService: {
@@ -21,16 +37,16 @@ describe("SideNavItemComponent", () => {
beforeEach(() => {
sidenavService = {
- items: signal([]),
- isCollapsed: signal(false),
- isMobile: signal(false),
- isMobileItemOpen: signal(false),
- isMobileOpen: signal(false),
- tooltipEnabled: signal(false),
- registerItem: jest.fn(),
- unregisterItem: jest.fn(),
- handleGoToMainMenu: jest.fn(),
- handleCollapse: jest.fn()
+ items: signal([]),
+ isCollapsed: signal(false),
+ isMobile: signal(false),
+ isMobileItemOpen: signal(false),
+ isMobileOpen: signal(false),
+ tooltipEnabled: signal(false),
+ registerItem: jest.fn(),
+ unregisterItem: jest.fn(),
+ handleGoToMainMenu: jest.fn(),
+ handleCollapse: jest.fn()
};
TestBed.configureTestingModule({
@@ -41,8 +57,8 @@ describe("SideNavItemComponent", () => {
});
fixture = TestBed.createComponent(SideNavItemComponent);
- itemElement = fixture.nativeElement;
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
});
it("should register on init and unregister on destroy", () => {
@@ -51,12 +67,15 @@ describe("SideNavItemComponent", () => {
expect(sidenavService.unregisterItem).toHaveBeenCalledWith(fixture.componentInstance);
});
- it("should always have base class", () => {
+ it("should always have base class on li element", () => {
expect(itemElement.classList.contains("tedi-sidenav-item")).toBe(true);
});
it("should read textContent in ngAfterViewInit", () => {
- itemElement.innerHTML = `Item Text`;
+ const textSpan = itemElement.querySelector(".tedi-sidenav-item__text");
+ if (textSpan) {
+ textSpan.textContent = "Item Text";
+ }
fixture.componentInstance.ngAfterViewInit();
expect(fixture.componentInstance.textContent()).toBe("Item Text");
});
@@ -64,12 +83,14 @@ describe("SideNavItemComponent", () => {
it("should add selected class when selected input is true", () => {
fixture.componentRef.setInput("selected", true);
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
expect(itemElement.classList.contains("tedi-sidenav-item--selected")).toBe(true);
});
it("should add hidden class when mobile item open and no dropdown open", () => {
sidenavService.isMobileItemOpen.set(true);
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
expect(itemElement.classList.contains("tedi-sidenav-item--hidden")).toBe(true);
});
@@ -79,6 +100,7 @@ describe("SideNavItemComponent", () => {
fixture.componentInstance.dropdown = dropdownStub as any;
sidenavService.isMobileItemOpen.set(true);
fixture.detectChanges();
+ itemElement = fixture.nativeElement.querySelector("li");
expect(itemElement.classList.contains("tedi-sidenav-item--hidden")).toBe(false);
});
@@ -113,4 +135,140 @@ describe("SideNavItemComponent", () => {
document.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(dropdownStub.open()).toBe(false);
});
+
+ it("Escape key should close dropdown and focus trigger when collapsed", async () => {
+ const dropdownStub = {
+ open: signal(true),
+ element: () => document.createElement("div"),
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+ fixture.componentInstance.ngAfterViewInit();
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+
+ expect(dropdownStub.open()).toBe(false);
+ });
+
+
+ it("toggleDropdown should do nothing when no dropdown", () => {
+ fixture.componentInstance.dropdown = undefined;
+ expect(() => fixture.componentInstance.toggleDropdown()).not.toThrow();
+ });
+
+ it("toggleDropdown should focus first dropdown item when opening in collapsed mode", () => {
+ const openSignal = signal(false);
+ const mockDropdownEl = document.createElement("div");
+ const mockUl = document.createElement("ul");
+ mockUl.className = "tedi-sidenav-dropdown";
+ const mockTrigger = document.createElement("a");
+ mockTrigger.className = "tedi-sidenav-dropdown-item__trigger";
+ Object.defineProperty(mockTrigger, "offsetParent", { value: document.body, configurable: true });
+ const focusSpy = jest.spyOn(mockTrigger, "focus");
+ mockUl.appendChild(mockTrigger);
+ mockDropdownEl.appendChild(mockUl);
+ document.body.appendChild(mockDropdownEl);
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ fixture.componentInstance.toggleDropdown();
+ expect(openSignal()).toBe(true);
+
+ // run afterNextRender
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(focusSpy).toHaveBeenCalled();
+ document.body.removeChild(mockDropdownEl);
+ });
+
+ it("toggleDropdown should focus trigger when closing in collapsed mode", () => {
+ const openSignal = signal(true);
+ const mockDropdownEl = document.createElement("div");
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ const actualTriggerBtn = fixture.nativeElement.querySelector(".tedi-sidenav-item__title") as HTMLElement;
+ const focusSpy = jest.spyOn(actualTriggerBtn, "focus");
+
+ fixture.componentInstance.toggleDropdown();
+ expect(openSignal()).toBe(false);
+
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(focusSpy).toHaveBeenCalled();
+ });
+
+ it("toggleDropdown should trigger focus management when mobile", () => {
+ const openSignal = signal(false);
+ const mockDropdownEl = document.createElement("div");
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+
+ sidenavService.isMobile.set(true);
+ fixture.detectChanges();
+
+ fixture.componentInstance.toggleDropdown();
+
+ expect(openSignal()).toBe(true);
+ expect(mockCallbackHolder.callback).not.toBeNull();
+ });
+
+ it("Escape key handler should focus trigger after closing", () => {
+ jest.useFakeTimers();
+
+ const openSignal = signal(true);
+ const mockDropdownEl = document.createElement("div");
+
+ const dropdownStub = {
+ open: openSignal,
+ element: () => mockDropdownEl,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fixture.componentInstance.dropdown = dropdownStub as any;
+ fixture.componentInstance.ngAfterViewInit();
+
+ sidenavService.isCollapsed.set(true);
+ fixture.detectChanges();
+
+ const actualTriggerBtn = fixture.nativeElement.querySelector(".tedi-sidenav-item__title") as HTMLElement;
+ const focusSpy = jest.spyOn(actualTriggerBtn, "focus");
+
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+
+ expect(openSignal()).toBe(false);
+
+ jest.runAllTimers();
+
+ expect(focusSpy).toHaveBeenCalled();
+
+ jest.useRealTimers();
+ });
});
diff --git a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts
index c64025550..fedc583fc 100644
--- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts
+++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.ts
@@ -1,4 +1,5 @@
import {
+ afterNextRender,
AfterViewInit,
ChangeDetectionStrategy,
Component,
@@ -7,6 +8,7 @@ import {
ElementRef,
forwardRef,
inject,
+ Injector,
input,
OnDestroy,
OnInit,
@@ -23,6 +25,7 @@ import { SideNavService } from "../../../../services/sidenav/sidenav.service";
import { TooltipComponent } from "../../../overlay/tooltip/tooltip.component";
import { TooltipContentComponent } from "../../../overlay/tooltip/tooltip-content/tooltip-content.component";
import { TooltipTriggerComponent } from "../../../overlay/tooltip/tooltip-trigger/tooltip-trigger.component";
+import { TediTranslationPipe } from "../../../../services/translation/translation.pipe";
@Component({
selector: "tedi-sidenav-item",
@@ -40,10 +43,10 @@ import { TooltipTriggerComponent } from "../../../overlay/tooltip/tooltip-trigge
TooltipComponent,
TooltipTriggerComponent,
TooltipContentComponent,
+ TediTranslationPipe,
],
host: {
- role: "menuitem",
- "[class]": "classes()",
+ "style": "display: contents",
},
})
export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
@@ -68,11 +71,12 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
@ContentChild(forwardRef(() => SideNavDropdownComponent))
dropdown?: SideNavDropdownComponent;
- textContent = signal("");
+ textContent = signal('');
sidenavService = inject(SideNavService);
private readonly host = inject(ElementRef);
private readonly renderer = inject(Renderer2);
+ private readonly injector = inject(Injector);
private readonly eventListeners: (() => void)[] = [];
ngOnInit() {
@@ -81,6 +85,7 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
ngOnDestroy() {
this.sidenavService.unregisterItem(this);
+ this.eventListeners.forEach((unlisten) => unlisten());
}
ngAfterViewInit(): void {
@@ -112,6 +117,19 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
}
}),
);
+
+ this.eventListeners.push(
+ this.renderer.listen("document", "keydown", (event: KeyboardEvent) => {
+ if (event.key === "Escape" && this.sidenavService.isCollapsed() && dropdown.open()) {
+ dropdown.open.set(false);
+ setTimeout(() => {
+ const hostEl = this.host.nativeElement as HTMLElement;
+ const trigger = hostEl.querySelector('.tedi-sidenav-item__title') as HTMLElement | null;
+ trigger?.focus();
+ }, 0);
+ }
+ }),
+ );
}
classes = computed(() => {
@@ -133,6 +151,29 @@ export class SideNavItemComponent implements AfterViewInit, OnInit, OnDestroy {
return;
}
+ const wasOpen = this.dropdown.open();
+ const dropdown = this.dropdown;
+
this.dropdown.open.update((prev) => !prev);
+
+ if (this.sidenavService.isCollapsed() || this.sidenavService.isMobile()) {
+ afterNextRender(() => {
+ if (!wasOpen) {
+ // Opening - focus first item in dropdown
+ const dropdownEl = dropdown.element();
+ const openDropdown = dropdownEl?.querySelector('ul.tedi-sidenav-dropdown');
+ const allTriggers = openDropdown?.querySelectorAll('.tedi-sidenav-dropdown-item__trigger');
+ const firstFocusable = Array.from(allTriggers ?? []).find(
+ (el) => (el as HTMLElement).offsetParent !== null
+ ) as HTMLElement | null;
+ firstFocusable?.focus();
+ } else {
+ // Closing - focus on parent item
+ const hostEl = this.host.nativeElement as HTMLElement;
+ const trigger = hostEl.querySelector('.tedi-sidenav-item__title') as HTMLElement | null;
+ trigger?.focus();
+ }
+ }, { injector: this.injector });
+ }
}
}
diff --git a/tedi/components/layout/sidenav/sidenav.component.html b/tedi/components/layout/sidenav/sidenav.component.html
index 4f7f188f4..ffdb323e1 100644
--- a/tedi/components/layout/sidenav/sidenav.component.html
+++ b/tedi/components/layout/sidenav/sidenav.component.html
@@ -15,12 +15,12 @@
@if (sidenavService.isMobileItemOpen()) {
{{ "sidenav.backToMainMenu" | tediTranslate }}
}
-
+
diff --git a/tedi/components/layout/sidenav/sidenav.component.scss b/tedi/components/layout/sidenav/sidenav.component.scss
index eb895fa6a..97484b907 100644
--- a/tedi/components/layout/sidenav/sidenav.component.scss
+++ b/tedi/components/layout/sidenav/sidenav.component.scss
@@ -15,9 +15,17 @@
--_sidenav-dropdown-item-collapsed-height: 40px;
--_sidenav-collapsed-text-width: 4.5rem;
--_sidenav-transition-duration: 300ms;
+ --_sidenav-focus-ring:
+ 0 0 0 1px var(--tedi-neutral-100), 0 0 0 3px var(--tedi-primary-400);
}
.tedi-sidenav {
+ &__list {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+
position: relative;
z-index: var(--z-index-sidenav);
display: flex;
@@ -125,13 +133,13 @@
}
&--dividers {
- .tedi-sidenav-item {
+ tedi-sidenav-item > .tedi-sidenav-item {
border-bottom: var(--borders-01) solid
var(--navigation-vertical-item-border);
+ }
- &:last-of-type {
- border-bottom: 0;
- }
+ tedi-sidenav-item:last-child > .tedi-sidenav-item {
+ border-bottom: 0;
}
}
@@ -171,6 +179,11 @@
width: var(--navigation-vertical-item-width-collapsed);
+ tedi-tooltip-trigger {
+ display: block;
+ width: 100%;
+ }
+
.tedi-sidenav-item {
--_sidenav-item-font-size: var(--navigation-vertical-text-size-sm);
@@ -243,10 +256,20 @@
.tedi-sidenav-dropdown-item {
--_gap: var(--dropdown-item-inner-spacing);
- padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
font-size: var(--body-regular-size);
color: var(--dropdown-item-default-text);
+ &__trigger {
+ padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x);
+
+ &:focus-visible {
+ color: var(--dropdown-item-hover-text);
+ outline: none;
+ background-color: var(--dropdown-item-hover-background);
+ box-shadow: var(--_sidenav-focus-ring);
+ }
+ }
+
&.tedi-sidenav-dropdown-item--selected,
&:hover {
color: var(--dropdown-item-hover-text);
@@ -261,11 +284,28 @@
}
.tedi-sidenav-dropdown-group {
- .tedi-sidenav-dropdown-item {
- padding-left: calc(
- var(--dropdown-item-padding-x) + var(--_sidenav-tree-container) +
- var(--_gap)
- );
+ &__parent-wrapper::before {
+ display: none;
+ }
+
+ .tedi-sidenav-dropdown-item.tedi-sidenav-dropdown-group__parent {
+ .tedi-sidenav-dropdown-item__trigger {
+ padding-left: var(--dropdown-item-padding-x);
+ }
+
+ &::before,
+ &::after {
+ display: none;
+ }
+ }
+
+ .tedi-sidenav-dropdown-item.tedi-sidenav-dropdown-group__item {
+ .tedi-sidenav-dropdown-item__trigger {
+ padding-left: calc(
+ var(--dropdown-item-padding-x) + var(--_sidenav-tree-container) +
+ var(--_gap)
+ );
+ }
&::after {
top: 50%;
@@ -281,23 +321,6 @@
background-color: var(--navigation-vertical-tree-neutral-default);
}
- &:first-of-type {
- padding-left: var(--dropdown-item-padding-x);
-
- &::after {
- width: 0;
- height: 0;
- }
-
- &:not(:only-child) {
- &::after,
- &::before {
- width: 0;
- height: 0;
- }
- }
- }
-
&:last-of-type {
&::before {
height: 50%;
diff --git a/tedi/components/layout/sidenav/sidenav.component.spec.ts b/tedi/components/layout/sidenav/sidenav.component.spec.ts
index e3605ad1c..5a85585c5 100644
--- a/tedi/components/layout/sidenav/sidenav.component.spec.ts
+++ b/tedi/components/layout/sidenav/sidenav.component.spec.ts
@@ -4,6 +4,19 @@ import { SideNavService } from "../../../services/sidenav/sidenav.service";
import { signal } from "@angular/core";
import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token";
+const mockCallbackHolder: { callback: (() => void) | null } = { callback: null };
+
+jest.mock("@angular/core", () => {
+ const actual = jest.requireActual("@angular/core");
+ return {
+ ...actual,
+ afterNextRender: jest.fn((callback: () => void, _options?: unknown) => {
+ mockCallbackHolder.callback = callback;
+ return { destroy: jest.fn() };
+ }),
+ };
+});
+
describe("SideNavComponent", () => {
let fixture: ComponentFixture;
let sidenavElement: HTMLElement;
@@ -112,4 +125,65 @@ describe("SideNavComponent", () => {
true,
);
});
+
+ describe("handleBackToMainMenu", () => {
+ afterEach(() => {
+ mockCallbackHolder.callback = null;
+ });
+
+ it("should call service.handleGoToMainMenu", () => {
+ fixture.componentInstance.handleBackToMainMenu();
+ expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled();
+ });
+
+ it("should find the open item before closing", () => {
+ const openSignal = signal(true);
+ const mockItem = {
+ dropdown: { open: openSignal },
+ host: { nativeElement: document.createElement("div") },
+ };
+
+ sidenavService.items.set([mockItem as never]);
+
+ fixture.componentInstance.handleBackToMainMenu();
+ expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled();
+ });
+
+ it("should focus the trigger of the previously open item after closing", () => {
+ const openSignal = signal(true);
+ const mockHostEl = document.createElement("div");
+ const mockTriggerBtn = document.createElement("button");
+ mockTriggerBtn.className = "tedi-sidenav-item__title";
+ mockHostEl.appendChild(mockTriggerBtn);
+
+ const focusSpy = jest.spyOn(mockTriggerBtn, "focus");
+
+ const mockItem = {
+ dropdown: { open: openSignal },
+ host: { nativeElement: mockHostEl },
+ };
+
+ sidenavService.items.set([mockItem as never]);
+
+ fixture.componentInstance.handleBackToMainMenu();
+
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(focusSpy).toHaveBeenCalled();
+ });
+
+ it("should not throw when no item is open", () => {
+ sidenavService.items.set([]);
+
+ fixture.componentInstance.handleBackToMainMenu();
+
+ if (mockCallbackHolder.callback) {
+ mockCallbackHolder.callback();
+ }
+
+ expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled();
+ });
+ });
});
diff --git a/tedi/components/layout/sidenav/sidenav.component.ts b/tedi/components/layout/sidenav/sidenav.component.ts
index 620cea6bf..87f875158 100644
--- a/tedi/components/layout/sidenav/sidenav.component.ts
+++ b/tedi/components/layout/sidenav/sidenav.component.ts
@@ -1,8 +1,11 @@
import {
+ afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
effect,
+ inject,
+ Injector,
input,
ViewEncapsulation,
} from "@angular/core";
@@ -47,12 +50,29 @@ export class SideNavComponent {
*/
desktopBreakpoint = input("lg");
+ private readonly injector = inject(Injector);
+
constructor(public sidenavService: SideNavService) {
effect(() => {
this.sidenavService.desktopBreakpoint.set(this.desktopBreakpoint())
})
}
+ handleBackToMainMenu() {
+ // Find the parent menu item to focus on
+ const openItem = this.sidenavService.items().find(item => item.dropdown?.open());
+
+ this.sidenavService.handleGoToMainMenu();
+
+ afterNextRender(() => {
+ if (openItem) {
+ const itemEl = openItem['host']?.nativeElement as HTMLElement;
+ const trigger = itemEl?.querySelector('.tedi-sidenav-item__title') as HTMLElement | null;
+ trigger?.focus();
+ }
+ }, { injector: this.injector });
+ }
+
classes = computed(() => {
const classList = ["tedi-sidenav", `tedi-sidenav--${this.size()}`];
diff --git a/tedi/services/sidenav/sidenav.service.spec.ts b/tedi/services/sidenav/sidenav.service.spec.ts
new file mode 100644
index 000000000..5425fb685
--- /dev/null
+++ b/tedi/services/sidenav/sidenav.service.spec.ts
@@ -0,0 +1,161 @@
+import { TestBed } from "@angular/core/testing";
+import { signal } from "@angular/core";
+import { SideNavService } from "./sidenav.service";
+import { BreakpointService } from "../breakpoint/breakpoint.service";
+import { SideNavItemComponent } from "../../components/layout/sidenav/sidenav-item/sidenav-item.component";
+
+describe("SideNavService", () => {
+ let service: SideNavService;
+ let isBelowBreakpointSignal: ReturnType>;
+
+ beforeEach(() => {
+ isBelowBreakpointSignal = signal(false);
+
+ const breakpointServiceMock = {
+ isBelowBreakpoint: jest.fn().mockReturnValue(isBelowBreakpointSignal),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ SideNavService,
+ { provide: BreakpointService, useValue: breakpointServiceMock },
+ ],
+ });
+
+ service = TestBed.inject(SideNavService);
+ });
+
+ it("should be created", () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe("registerItem", () => {
+ it("should add item to items array", () => {
+ const item = {} as SideNavItemComponent;
+ expect(service.items().length).toBe(0);
+
+ service.registerItem(item);
+
+ expect(service.items().length).toBe(1);
+ expect(service.items()[0]).toBe(item);
+ });
+ });
+
+ describe("unregisterItem", () => {
+ it("should remove item from items array", () => {
+ const item1 = { id: 1 } as unknown as SideNavItemComponent;
+ const item2 = { id: 2 } as unknown as SideNavItemComponent;
+
+ service.registerItem(item1);
+ service.registerItem(item2);
+ expect(service.items().length).toBe(2);
+
+ service.unregisterItem(item1);
+
+ expect(service.items().length).toBe(1);
+ expect(service.items()[0]).toBe(item2);
+ });
+ });
+
+ describe("handleGoToMainMenu", () => {
+ it("should close all open dropdowns", () => {
+ const openSignal1 = signal(true);
+ const openSignal2 = signal(true);
+ const item1 = { dropdown: { open: openSignal1 } } as unknown as SideNavItemComponent;
+ const item2 = { dropdown: { open: openSignal2 } } as unknown as SideNavItemComponent;
+
+ service.registerItem(item1);
+ service.registerItem(item2);
+
+ service.handleGoToMainMenu();
+
+ expect(openSignal1()).toBe(false);
+ expect(openSignal2()).toBe(false);
+ });
+
+ it("should handle items without dropdowns", () => {
+ const item = { dropdown: undefined } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(() => service.handleGoToMainMenu()).not.toThrow();
+ });
+ });
+
+ describe("handleCollapse", () => {
+ it("should toggle isCollapsed state", () => {
+ expect(service.isCollapsed()).toBe(false);
+
+ service.handleCollapse();
+ expect(service.isCollapsed()).toBe(true);
+
+ service.handleCollapse();
+ expect(service.isCollapsed()).toBe(false);
+ });
+ });
+
+ describe("isMobileItemOpen", () => {
+ it("should return false when not mobile", () => {
+ isBelowBreakpointSignal.set(false);
+ const openSignal = signal(true);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.isMobileItemOpen()).toBe(false);
+ });
+
+ it("should return false when mobile but no dropdown open", () => {
+ isBelowBreakpointSignal.set(true);
+ const openSignal = signal(false);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.isMobileItemOpen()).toBe(false);
+ });
+
+ it("should return true when mobile and dropdown is open", () => {
+ isBelowBreakpointSignal.set(true);
+ const openSignal = signal(true);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.isMobileItemOpen()).toBe(true);
+ });
+ });
+
+ describe("tooltipEnabled", () => {
+ it("should return false when not collapsed", () => {
+ service.isCollapsed.set(false);
+ expect(service.tooltipEnabled()).toBe(false);
+ });
+
+ it("should return true when collapsed and no dropdown open", () => {
+ service.isCollapsed.set(true);
+ const openSignal = signal(false);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.tooltipEnabled()).toBe(true);
+ });
+
+ it("should return false when collapsed but dropdown is open", () => {
+ service.isCollapsed.set(true);
+ const openSignal = signal(true);
+ const item = { dropdown: { open: openSignal } } as unknown as SideNavItemComponent;
+ service.registerItem(item);
+
+ expect(service.tooltipEnabled()).toBe(false);
+ });
+ });
+
+ describe("effect: reset collapsed on mobile", () => {
+ it("should reset isCollapsed to false when switching to mobile while collapsed", () => {
+ service.isCollapsed.set(true);
+ expect(service.isCollapsed()).toBe(true);
+
+ isBelowBreakpointSignal.set(true);
+ TestBed.flushEffects();
+
+ expect(service.isCollapsed()).toBe(false);
+ });
+ });
+});
diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts
index fcbdd36c6..47f8fedc6 100644
--- a/tedi/services/translation/translations.ts
+++ b/tedi/services/translation/translations.ts
@@ -582,6 +582,13 @@ export const translationsMap = {
en: (isOpen: boolean) => (isOpen ? "Close menu" : "Open menu"),
ru: (isOpen: boolean) => (isOpen ? "Закрыть меню" : "Открыть меню"),
},
+ "sidenav.toggleSubmenu": {
+ description: "Label for sidenav submenu toggle",
+ components: ["Sidenav"],
+ et: (value: string, isOpen: boolean) => (`${isOpen ? 'Sulge' : 'Ava'} ${value} alammenüü`),
+ en: (value: string, isOpen: boolean) => (`${isOpen ? 'Close' : 'Open'} ${value} submenu`),
+ ru: (value: string, isOpen: boolean) => (`${isOpen ? 'Закрыть' : 'Открыть'} ${value} подменю`),
+ },
carousel: {
description: "Label for carousel",
components: ["CarouselContent"],