From 0fa67e4405a308a3530f1c93e8858550471f2ac7 Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 23 Jan 2026 10:52:08 +0200 Subject: [PATCH 1/3] fix(sidenav): fixed SR semantics, added focus handling and styles #207 --- .../sidenav-dropdown-group.component.html | 52 ++++++ .../sidenav-dropdown-group.component.scss | 75 +++++--- .../sidenav-dropdown-group.component.ts | 31 +++- .../sidenav-dropdown-item.component.html | 26 +-- .../sidenav-dropdown-item.component.scss | 39 +++-- .../sidenav-dropdown-item.component.spec.ts | 10 +- .../sidenav-dropdown-item.component.ts | 22 ++- .../sidenav-dropdown.component.html | 4 +- .../sidenav-dropdown.component.scss | 30 ++-- .../sidenav-dropdown.component.spec.ts | 10 +- .../sidenav-dropdown.component.ts | 3 +- .../sidenav-item/sidenav-item.component.html | 81 ++++----- .../sidenav-item/sidenav-item.component.scss | 22 ++- .../sidenav-item.component.spec.ts | 99 +++++++++-- .../sidenav-item/sidenav-item.component.ts | 47 ++++- .../layout/sidenav/sidenav.component.html | 6 +- .../layout/sidenav/sidenav.component.scss | 77 ++++++--- .../layout/sidenav/sidenav.component.spec.ts | 20 +++ .../layout/sidenav/sidenav.component.ts | 20 +++ tedi/services/sidenav/sidenav.service.spec.ts | 161 ++++++++++++++++++ tedi/services/translation/translations.ts | 7 + 21 files changed, 679 insertions(+), 163 deletions(-) create mode 100644 tedi/components/layout/sidenav/sidenav-dropdown-group/sidenav-dropdown-group.component.html create mode 100644 tedi/services/sidenav/sidenav.service.spec.ts 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 (first.href()) { + + {{ first.textContent() }} + + } @else if (first.route()) { + + {{ first.textContent() }} + + } @else { + + {{ first.textContent() }} + + } +
    + + @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.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..f4136956d 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,21 @@ 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); }); }); 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 { -
    - -
    -} + + +
  • - } -
    +
      -
    + 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..056717779 100644 --- a/tedi/components/layout/sidenav/sidenav.component.spec.ts +++ b/tedi/components/layout/sidenav/sidenav.component.spec.ts @@ -112,4 +112,24 @@ describe("SideNavComponent", () => { true, ); }); + + describe("handleBackToMainMenu", () => { + 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(); + }); + }); }); 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"], From 97bc6493a9b60e71242fa8209f2a5acc56ad69ae Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 23 Jan 2026 14:06:35 +0200 Subject: [PATCH 2/3] fix(sidenav): improved sidenav test coverage #207 --- .../sidenav-item.component.spec.ts | 91 ++++++++++++++++++- .../layout/sidenav/sidenav.component.spec.ts | 54 +++++++++++ 2 files changed, 142 insertions(+), 3 deletions(-) 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 b94c5dec9..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: { @@ -143,15 +159,18 @@ describe("SideNavItemComponent", () => { expect(() => fixture.componentInstance.toggleDropdown()).not.toThrow(); }); - it("toggleDropdown should trigger focus management when collapsed", async () => { + 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, @@ -164,11 +183,45 @@ describe("SideNavItemComponent", () => { 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", async () => { + it("toggleDropdown should trigger focus management when mobile", () => { const openSignal = signal(false); const mockDropdownEl = document.createElement("div"); @@ -185,5 +238,37 @@ describe("SideNavItemComponent", () => { 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.component.spec.ts b/tedi/components/layout/sidenav/sidenav.component.spec.ts index 056717779..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; @@ -114,6 +127,10 @@ describe("SideNavComponent", () => { }); describe("handleBackToMainMenu", () => { + afterEach(() => { + mockCallbackHolder.callback = null; + }); + it("should call service.handleGoToMainMenu", () => { fixture.componentInstance.handleBackToMainMenu(); expect(sidenavService.handleGoToMainMenu).toHaveBeenCalled(); @@ -131,5 +148,42 @@ describe("SideNavComponent", () => { 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(); + }); }); }); From 6a034af6f073df37921d6c19e817b0af7c89c51d Mon Sep 17 00:00:00 2001 From: m2rt Date: Fri, 23 Jan 2026 14:13:31 +0200 Subject: [PATCH 3/3] fix(sidenav): improved sidenav test coverage #207 --- .../sidenav-dropdown-group.component.spec.ts | 38 +++++++++++++++++++ .../sidenav-dropdown-item.component.spec.ts | 9 +++++ 2 files changed, 47 insertions(+) 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-item/sidenav-dropdown-item.component.spec.ts b/tedi/components/layout/sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component.spec.ts index f4136956d..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 @@ -28,4 +28,13 @@ describe("SideNavDropdownItemComponent", () => { fixture.detectChanges(); 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"); + }); + });