From b52d92321f283ca6a4ba5c5d71842def7332305e Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 15 May 2026 16:14:18 +0300 Subject: [PATCH 1/4] feat(header): update Header component against Figma #312 --- .storybook/preview.tsx | 7 +- public/header-logo-white.svg | 5 + tedi/components/base/text/text.component.ts | 7 +- .../header-actions.component.scss | 15 +- .../header-bottom.component.scss | 13 + .../header-bottom.component.spec.ts | 46 + .../header-bottom/header-bottom.component.ts | 35 + .../layout/header/header-bottom/index.ts | 1 + .../header-content.component.scss | 34 +- .../header-content.component.spec.ts | 61 + .../header-content.component.ts | 33 +- .../header-language.component.html | 8 +- .../header-language.component.scss | 31 +- .../header-language.component.spec.ts | 49 +- .../header-login/header-login.component.html | 30 +- .../header-login/header-login.component.scss | 23 - .../header-login.component.spec.ts | 130 +- .../header-login/header-login.component.ts | 70 +- .../header-logo/header-logo-dark.directive.ts | 12 + .../header-logo/header-logo.component.html | 16 + .../header-logo/header-logo.component.scss | 40 + .../header-logo/header-logo.component.spec.ts | 186 +++ .../header-logo/header-logo.component.ts | 55 + .../header-logout.component.html | 30 +- .../header-logout.component.scss | 56 +- .../header-logout.component.spec.ts | 150 +- .../header-logout/header-logout.component.ts | 82 +- .../header-mobile-button.component.html | 37 + .../header-mobile-button.component.scss | 52 + .../header-mobile-button.component.spec.ts | 154 +++ .../header-mobile-button.component.ts | 81 ++ .../header/header-mobile-button/index.ts | 1 + .../header-profile.component.html | 151 +-- .../header-profile.component.scss | 70 +- .../header-profile.component.spec.ts | 52 +- .../header-profile.component.ts | 85 +- .../header-role-title.directive.ts | 19 + .../header-role/header-role.component.html | 74 +- .../header-role/header-role.component.scss | 101 +- .../header-role/header-role.component.spec.ts | 363 ++++- .../header-role/header-role.component.ts | 105 +- .../header-search.component.html | 41 + .../header-search.component.scss | 57 + .../header-search.component.spec.ts | 316 +++++ .../header-search/header-search.component.ts | 122 ++ .../layout/header/header-search/index.ts | 1 + .../layout/header/header.component.html | 7 +- .../layout/header/header.component.scss | 32 +- .../layout/header/header.stories.ts | 1205 +++++++++++++---- tedi/components/layout/header/index.ts | 8 +- .../sidenav-item/sidenav-item.component.scss | 1 + .../sidenav-toggle.component.html | 5 +- .../sidenav-toggle.component.scss | 5 + .../navigation/link/link.component.scss | 1 + .../popover-trigger.directive.ts | 4 +- .../overlay/popover/popover.component.scss | 22 +- .../overlay/popover/popover.component.ts | 9 + tedi/services/translation/translations.ts | 35 +- 58 files changed, 3692 insertions(+), 749 deletions(-) create mode 100644 public/header-logo-white.svg create mode 100644 tedi/components/layout/header/header-bottom/header-bottom.component.scss create mode 100644 tedi/components/layout/header/header-bottom/header-bottom.component.spec.ts create mode 100644 tedi/components/layout/header/header-bottom/header-bottom.component.ts create mode 100644 tedi/components/layout/header/header-bottom/index.ts create mode 100644 tedi/components/layout/header/header-content/header-content.component.spec.ts create mode 100644 tedi/components/layout/header/header-logo/header-logo-dark.directive.ts create mode 100644 tedi/components/layout/header/header-logo/header-logo.component.html create mode 100644 tedi/components/layout/header/header-logo/header-logo.component.scss create mode 100644 tedi/components/layout/header/header-logo/header-logo.component.spec.ts create mode 100644 tedi/components/layout/header/header-logo/header-logo.component.ts create mode 100644 tedi/components/layout/header/header-mobile-button/header-mobile-button.component.html create mode 100644 tedi/components/layout/header/header-mobile-button/header-mobile-button.component.scss create mode 100644 tedi/components/layout/header/header-mobile-button/header-mobile-button.component.spec.ts create mode 100644 tedi/components/layout/header/header-mobile-button/header-mobile-button.component.ts create mode 100644 tedi/components/layout/header/header-mobile-button/index.ts create mode 100644 tedi/components/layout/header/header-role/header-role-title.directive.ts create mode 100644 tedi/components/layout/header/header-search/header-search.component.html create mode 100644 tedi/components/layout/header/header-search/header-search.component.scss create mode 100644 tedi/components/layout/header/header-search/header-search.component.spec.ts create mode 100644 tedi/components/layout/header/header-search/header-search.component.ts create mode 100644 tedi/components/layout/header/header-search/index.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 242ee3c0b..4ce5a5086 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -9,6 +9,8 @@ import { Title, } from "@storybook/blocks"; import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../tedi/tokens/translation.token"; +import { TEDI_THEME_DEFAULT_TOKEN } from "../tedi/tokens/theme.token"; +import { THEME_FALLBACK_VALUE } from "../tedi/services/theme/theme.service"; export const globalTypes = { theme: { @@ -62,7 +64,10 @@ const preview: Preview = { decorators: [ themeDecorator, applicationConfig({ - providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: TEDI_THEME_DEFAULT_TOKEN, useValue: THEME_FALLBACK_VALUE }, + ], }), ], parameters: { diff --git a/public/header-logo-white.svg b/public/header-logo-white.svg new file mode 100644 index 000000000..58f4f664f --- /dev/null +++ b/public/header-logo-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tedi/components/base/text/text.component.ts b/tedi/components/base/text/text.component.ts index f3fda95ba..c917963a4 100644 --- a/tedi/components/base/text/text.component.ts +++ b/tedi/components/base/text/text.component.ts @@ -14,6 +14,7 @@ export type TextModifiers = | "h6" | "normal" | "small" + | "extra-small" | "bold" | "thin" | "italic" @@ -45,7 +46,8 @@ export type TextColor = | "warning" | "danger" | "info" - | "neutral"; + | "neutral" + | "inherit"; @Component({ selector: "[tedi-text]", @@ -53,7 +55,7 @@ export type TextColor = templateUrl: "./text.component.html", changeDetection: ChangeDetectionStrategy.OnPush, host: { - "[class]": "classes()" + "[class]": "classes()", }, }) export class TextComponent { @@ -81,7 +83,6 @@ export class TextComponent { : modifiersValue ? [modifiersValue] : []; - modifierClasses.forEach((modifier) => { if (this.isHeadingModifier(modifier)) { diff --git a/tedi/components/layout/header/header-actions/header-actions.component.scss b/tedi/components/layout/header/header-actions/header-actions.component.scss index 960482b43..4c063dd7d 100644 --- a/tedi/components/layout/header/header-actions/header-actions.component.scss +++ b/tedi/components/layout/header/header-actions/header-actions.component.scss @@ -2,26 +2,23 @@ .tedi-header-actions { display: flex; + gap: var(--layout-header-items-right-gutter-x); align-items: center; height: 100%; + margin-left: auto; > * { display: flex; align-items: center; height: 100%; - &:not(:first-child) { - padding-left: var(--layout-header-items-right-gutter-x); - border-left: var(--tedi-borders-01) solid var(--general-border-primary); - } - - &:not(:last-child) { - padding-right: var(--layout-header-items-right-gutter-x); - } - &:has(.tedi-header-login__button--mobile), &:has(.tedi-header-profile--mobile) { padding-left: 0; } + + @include breakpoints.media-breakpoint-only(md) { + max-height: 2.75rem; + } } } diff --git a/tedi/components/layout/header/header-bottom/header-bottom.component.scss b/tedi/components/layout/header/header-bottom/header-bottom.component.scss new file mode 100644 index 000000000..0d9d6bbd0 --- /dev/null +++ b/tedi/components/layout/header/header-bottom/header-bottom.component.scss @@ -0,0 +1,13 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +.tedi-header-bottom { + display: block; + padding: var(--layout-grid-gutters-12) var(--layout-page-spacing-x); + background: var(--general-surface-primary); + border-top: var(--tedi-borders-01) solid var(--general-border-primary); + border-bottom: var(--tedi-borders-01) solid var(--general-border-primary); + + @include breakpoints.media-breakpoint-up(md) { + display: none; + } +} diff --git a/tedi/components/layout/header/header-bottom/header-bottom.component.spec.ts b/tedi/components/layout/header/header-bottom/header-bottom.component.spec.ts new file mode 100644 index 000000000..1cbb8592a --- /dev/null +++ b/tedi/components/layout/header/header-bottom/header-bottom.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; +import { HeaderBottomComponent } from "./header-bottom.component"; + +describe("HeaderBottomComponent", () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HeaderBottomComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderBottomComponent); + fixture.detectChanges(); + }); + + it("should create the component", () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should apply the host class", () => { + expect(fixture.nativeElement.classList).toContain("tedi-header-bottom"); + }); + + it("should project children content", () => { + @Component({ + standalone: true, + imports: [HeaderBottomComponent], + template: ` + + hello + + `, + }) + class HostComponent {} + + const hostFixture = TestBed.createComponent(HostComponent); + hostFixture.detectChanges(); + + const projected = hostFixture.nativeElement.querySelector( + "[data-testid='projected']", + ); + expect(projected).toBeTruthy(); + expect(projected.textContent).toBe("hello"); + }); +}); diff --git a/tedi/components/layout/header/header-bottom/header-bottom.component.ts b/tedi/components/layout/header/header-bottom/header-bottom.component.ts new file mode 100644 index 000000000..2b60b1770 --- /dev/null +++ b/tedi/components/layout/header/header-bottom/header-bottom.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from "@angular/core"; + +/** + * Mobile-only secondary row rendered below the main header bar. + * + * Typical use: a compact search bar or mobile-specific navigation that only + * appears below the `md` breakpoint. Above `md` the component is hidden via + * CSS, so consumers can include it unconditionally — desktop users won't see it. + * + * @example + *
+ * + * + * + * + * + * + *
+ */ +@Component({ + selector: "tedi-header-bottom", + standalone: true, + template: "", + styleUrl: "./header-bottom.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "class": "tedi-header-bottom", + }, +}) +export class HeaderBottomComponent {} diff --git a/tedi/components/layout/header/header-bottom/index.ts b/tedi/components/layout/header/header-bottom/index.ts new file mode 100644 index 000000000..c31822bea --- /dev/null +++ b/tedi/components/layout/header/header-bottom/index.ts @@ -0,0 +1 @@ +export * from "./header-bottom.component"; diff --git a/tedi/components/layout/header/header-content/header-content.component.scss b/tedi/components/layout/header/header-content/header-content.component.scss index 266fe91ce..c79dccc60 100644 --- a/tedi/components/layout/header/header-content/header-content.component.scss +++ b/tedi/components/layout/header/header-content/header-content.component.scss @@ -1,23 +1,41 @@ -tedi-header-content { +.tedi-header-content { display: flex; + flex: 1 0 0; gap: var(--layout-header-items-center-gutter-x); align-items: center; + &--flex-start { + justify-content: flex-start; + } + + &--center { + justify-content: center; + } + + &--space-between { + justify-content: space-between; + } + a, .tedi-link { - color: var(--general-text-primary); + color: var(--header-link-default); - &:hover { - color: var(--general-text-secondary); + &:hover:not(:disabled, [aria-disabled="true"]) { + color: var(--header-link-hover); } - &:active { - color: var(--general-text-tertiary); + &:active:not(:disabled, [aria-disabled="true"]) { + color: var(--header-link-active); } - &:focus-visible { - outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + &:focus-visible:not(:disabled) { + outline: var(--tedi-borders-02) solid var(--header-link-focus); outline-offset: var(--tedi-borders-01); } } + + > * { + display: flex; + gap: var(--layout-header-items-center-gutter-x); + } } diff --git a/tedi/components/layout/header/header-content/header-content.component.spec.ts b/tedi/components/layout/header/header-content/header-content.component.spec.ts new file mode 100644 index 000000000..7cb277e33 --- /dev/null +++ b/tedi/components/layout/header/header-content/header-content.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HeaderContentAlignment, HeaderContentComponent } from "./header-content.component"; + +describe("HeaderContentComponent", () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HeaderContentComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderContentComponent); + fixture.detectChanges(); + }); + + it("should create the component", () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("should apply the base host class", () => { + expect(fixture.nativeElement.classList).toContain("tedi-header-content"); + }); + + it("should default to the center alignment modifier", () => { + expect(fixture.nativeElement.classList).toContain( + "tedi-header-content--center", + ); + }); + + it.each(["flex-start", "center", "space-between"])( + "should apply the %s alignment modifier when set", + (alignment) => { + fixture.componentRef.setInput("alignment", alignment); + fixture.detectChanges(); + + expect(fixture.nativeElement.classList).toContain( + `tedi-header-content--${alignment}`, + ); + }, + ); + + it("should swap the modifier class when alignment changes", () => { + fixture.componentRef.setInput("alignment", "space-between"); + fixture.detectChanges(); + expect(fixture.nativeElement.classList).toContain( + "tedi-header-content--space-between", + ); + expect(fixture.nativeElement.classList).not.toContain( + "tedi-header-content--center", + ); + + fixture.componentRef.setInput("alignment", "flex-start"); + fixture.detectChanges(); + expect(fixture.nativeElement.classList).toContain( + "tedi-header-content--flex-start", + ); + expect(fixture.nativeElement.classList).not.toContain( + "tedi-header-content--space-between", + ); + }); +}); diff --git a/tedi/components/layout/header/header-content/header-content.component.ts b/tedi/components/layout/header/header-content/header-content.component.ts index 6e7fc1609..7b1f8b0f0 100644 --- a/tedi/components/layout/header/header-content/header-content.component.ts +++ b/tedi/components/layout/header/header-content/header-content.component.ts @@ -1,11 +1,34 @@ -import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + computed, + input, +} from "@angular/core"; + +export type HeaderContentAlignment = "flex-start" | "center" | "space-between"; @Component({ - selector: 'tedi-header-content', + selector: "tedi-header-content", standalone: true, template: "", - styleUrl: './header-content.component.scss', + styleUrl: "./header-content.component.scss", encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "classes()", + }, }) -export class HeaderContentComponent {} +export class HeaderContentComponent { + /** + * Controls the horizontal alignment of the projected content (`justify-content`). + * Useful when the center area mixes nav links with another component (e.g. a + * ``) and you want to push them apart or align them to one edge. + * @default "center" + */ + readonly alignment = input("center"); + + protected readonly classes = computed( + () => `tedi-header-content tedi-header-content--${this.alignment()}`, + ); +} diff --git a/tedi/components/layout/header/header-language/header-language.component.html b/tedi/components/layout/header/header-language/header-language.component.html index f0ca1e360..ce950d1db 100644 --- a/tedi/components/layout/header/header-language/header-language.component.html +++ b/tedi/components/layout/header/header-language/header-language.component.html @@ -5,14 +5,18 @@ class="tedi-header-language__label" >{{ "header.select-lang" | tediTranslate }} - + \ No newline at end of file +@if (isSmall()) { + +} @else if (href()) { + +} @else { + +} diff --git a/tedi/components/layout/header/header-login/header-login.component.scss b/tedi/components/layout/header/header-login/header-login.component.scss index 80be33599..4d99051b2 100644 --- a/tedi/components/layout/header/header-login/header-login.component.scss +++ b/tedi/components/layout/header/header-login/header-login.component.scss @@ -5,28 +5,5 @@ tedi-header-login { .tedi-header-login__button { flex-shrink: 0; - - &--mobile { - flex-direction: column; - gap: 0; - align-items: center; - justify-content: center; - min-width: var(--layout-header-mobile-button-size); - min-height: var(--layout-header-mobile-button-size); - padding: var(--layout-grid-gutters-08); - font-size: var(--body-extra-small-size); - line-height: var(--body-regular-line-height); - border: 0; - border-radius: 0; - - &:focus-visible { - outline: var(--tedi-borders-02) solid var(--tedi-primary-500); - outline-offset: calc(-1 * var(--tedi-borders-02)); - } - - tedi-icon { - font-size: var(--icon-05); - } - } } } diff --git a/tedi/components/layout/header/header-login/header-login.component.spec.ts b/tedi/components/layout/header/header-login/header-login.component.spec.ts index df6fd55bd..149cd9108 100644 --- a/tedi/components/layout/header/header-login/header-login.component.spec.ts +++ b/tedi/components/layout/header/header-login/header-login.component.spec.ts @@ -1,56 +1,134 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { signal, Signal } from '@angular/core'; -import { HeaderLoginComponent } from './header-login.component'; -import { BreakpointService } from '../../../../services/breakpoint/breakpoint.service'; -import { TediTranslationService } from '../../../../services/translation/translation.service'; +import { Component, signal } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HeaderLoginComponent } from "./header-login.component"; +import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; @Component({ standalone: true, imports: [HeaderLoginComponent], - template: ``, + template: ``, }) -class TestHostComponent {} +class TestHostComponent { + href?: string; + label = ""; +} -describe('HeaderLoginComponent', () => { +describe("HeaderLoginComponent", () => { let fixture: ComponentFixture; - let isMobileSignal: Signal; - let mockBreakpointService: { isBelowBreakpoint: jest.Mock }; - let mockTranslationService: { track: jest.Mock }; + let isMobileSignal: ReturnType>; + let mockBreakpointService: Partial; + let mockTranslationService: { + translate: jest.Mock; + track: jest.Mock; + }; beforeEach(async () => { isMobileSignal = signal(false); mockBreakpointService = { - isBelowBreakpoint: jest.fn().mockReturnValue(isMobileSignal) - }; + isBelowBreakpoint: () => isMobileSignal, + } as Partial; mockTranslationService = { - track: jest.fn((key: string) => () => key) + translate: jest.fn((key: string) => key), + track: jest.fn((key: string) => () => key), }; await TestBed.configureTestingModule({ imports: [TestHostComponent], providers: [ { provide: BreakpointService, useValue: mockBreakpointService }, - { provide: TediTranslationService, useValue: mockTranslationService } - ] + { provide: TediTranslationService, useValue: mockTranslationService }, + ], }).compileComponents(); fixture = TestBed.createComponent(TestHostComponent); - fixture.detectChanges(); }); - it('should create component', () => { + it("should create component", () => { + fixture.detectChanges(); expect(fixture.componentInstance).toBeTruthy(); }); - it('should call translationService.track for both desktop and mobile keys', () => { - expect(mockTranslationService.track).toHaveBeenCalledWith('header.login'); - expect(mockTranslationService.track).toHaveBeenCalledWith('header.login-mobile'); + it("looks up the desktop translation key by default", () => { + fixture.detectChanges(); + expect(mockTranslationService.translate).toHaveBeenCalledWith( + "header.login", + ); + }); + + it("looks up the small/mobile translation key when below md", () => { + isMobileSignal.set(true); + fixture.detectChanges(); + expect(mockTranslationService.translate).toHaveBeenCalledWith( + "header.login-small", + ); + }); + + it("renders the custom label as-is when `label` is set, without translating", () => { + fixture.componentInstance.label = "Sign in"; + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector( + "button.tedi-header-login__button", + ) as HTMLButtonElement | null; + expect(button?.textContent?.trim()).toBe("Sign in"); + expect(mockTranslationService.translate).not.toHaveBeenCalled(); }); - it('should display desktop text when isMobile is false', () => { - const buttonEl = fixture.debugElement.query(By.css('button')).nativeElement; - expect(buttonEl.textContent.trim()).toBe('header.login'); + describe("desktop (above md)", () => { + beforeEach(() => { + isMobileSignal.set(false); + }); + + it("renders a primary button with the desktop label by default", () => { + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector( + "button.tedi-header-login__button", + ) as HTMLButtonElement | null; + expect(button).toBeTruthy(); + expect(button?.textContent?.trim()).toBe("header.login"); + expect( + fixture.nativeElement.querySelector("a.tedi-header-login__button"), + ).toBeFalsy(); + }); + + it("renders as an anchor when `href` is provided", () => { + fixture.componentInstance.href = "/login"; + fixture.detectChanges(); + + const anchor = fixture.nativeElement.querySelector( + "a.tedi-header-login__button", + ) as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.getAttribute("href")).toBe("/login"); + expect(anchor?.textContent?.trim()).toBe("header.login"); + expect( + fixture.nativeElement.querySelector("button.tedi-header-login__button"), + ).toBeFalsy(); + }); + }); + + describe("mobile (below md)", () => { + beforeEach(() => { + isMobileSignal.set(true); + }); + + it("renders HeaderMobileButton with the small label", () => { + fixture.detectChanges(); + const text = fixture.nativeElement.querySelector( + "tedi-header-mobile-button .tedi-header-mobile-button__text", + ); + expect(text?.textContent?.trim()).toBe("header.login-small"); + }); + + it("forwards `href` to HeaderMobileButton so it renders as an anchor", () => { + fixture.componentInstance.href = "/login"; + fixture.detectChanges(); + + const anchor = fixture.nativeElement.querySelector( + "tedi-header-mobile-button a.tedi-header-mobile-button", + ) as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.getAttribute("href")).toBe("/login"); + }); }); }); diff --git a/tedi/components/layout/header/header-login/header-login.component.ts b/tedi/components/layout/header/header-login/header-login.component.ts index dc533272b..37847aacb 100644 --- a/tedi/components/layout/header/header-login/header-login.component.ts +++ b/tedi/components/layout/header/header-login/header-login.component.ts @@ -1,15 +1,24 @@ -import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core'; -import { IconComponent } from '../../../base/icon/icon.component'; -import { ButtonComponent } from '../../../buttons/button/button.component'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; +import { ButtonComponent } from "../../../buttons/button/button.component"; import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; -import { TediTranslationService } from '../../../../services/translation/translation.service'; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { HeaderMobileButtonComponent } from "../header-mobile-button/header-mobile-button.component"; + +export type HeaderLoginSize = "default" | "small"; @Component({ - selector: 'tedi-header-login', + selector: "tedi-header-login", standalone: true, - imports: [ButtonComponent, IconComponent], - templateUrl: './header-login.component.html', - styleUrl: './header-login.component.scss', + imports: [ButtonComponent, HeaderMobileButtonComponent], + templateUrl: "./header-login.component.html", + styleUrl: "./header-login.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -17,9 +26,46 @@ export class HeaderLoginComponent { breakpointService = inject(BreakpointService); translationService = inject(TediTranslationService); - isMobile = this.breakpointService.isBelowBreakpoint("sm"); - textDesktop = this.translationService.track("header.login"); - textMobile = this.translationService.track("header.login-mobile"); + private isMobile = this.breakpointService.isBelowBreakpoint("md"); + + /** + * Visual size of the login button. + * - `'small'` renders a compact icon-and-caption button (the + * `` variant). + * - `'default'` renders a primary button or anchor with text only. + * + * When left unset, the size is chosen automatically based on the viewport: + * `'small'` below the `md` breakpoint, `'default'` from `md` up. Pass an + * explicit value to force a variant regardless of viewport. + */ + size = input(); + + /** + * Custom label text for the login button. When provided, used as-is — not + * translated. When omitted or empty, falls back to the `header.login-small` + * translation key for the compact variant and `header.login` for the full + * variant. + */ + label = input(""); + + /** + * If provided, the login button renders as an `` navigating to this URL. + * Otherwise renders as a ` +} diff --git a/tedi/components/layout/header/header-logout/header-logout.component.scss b/tedi/components/layout/header/header-logout/header-logout.component.scss index a8c8adca3..f2a51590b 100644 --- a/tedi/components/layout/header/header-logout/header-logout.component.scss +++ b/tedi/components/layout/header/header-logout/header-logout.component.scss @@ -1,44 +1,38 @@ .tedi-header-logout { - display: flex; - gap: var(--link-inner-spacing-x); + display: inline-flex; align-items: center; - padding: 0; - font-size: var(--body-regular-size); - background: transparent; - border: 0; - &:hover { - span { - text-decoration: underline !important; + &__button { + display: flex; + gap: var(--link-inner-spacing-x); + align-items: center; + min-width: max-content; + padding: 0; + font-family: var(--family-default); + font-size: var(--body-regular-size); + + tedi-icon { + margin: 0; + font-size: var(--icon-03); } } - &--mobile { - flex-direction: column; - gap: 0; - align-items: center; - justify-content: center; - min-width: var(--layout-header-mobile-button-size); - min-height: var(--layout-header-mobile-button-size); - padding: var(--layout-grid-gutters-08); - font-size: var(--body-extra-small-size); - line-height: var(--body-regular-line-height); + &__button:not(.tedi-link) { + color: var(--header-mobile-button-text-default); + cursor: pointer; + background: transparent; border: 0; - border-radius: 0; - &:focus-visible { - outline: var(--tedi-borders-02) solid var(--tedi-primary-500); - outline-offset: calc(-1 * var(--tedi-borders-02)); - } + &:hover { + color: var(--header-mobile-button-text-hover); - tedi-icon { - margin: 0 !important; - margin-bottom: 4px !important; - font-size: var(--icon-05) !important; + span { + text-decoration: underline; + } } - } - span { - text-decoration: none !important; + &:active { + color: var(--header-mobile-button-text-active); + } } } diff --git a/tedi/components/layout/header/header-logout/header-logout.component.spec.ts b/tedi/components/layout/header/header-logout/header-logout.component.spec.ts index c7f8fc675..88053d910 100644 --- a/tedi/components/layout/header/header-logout/header-logout.component.spec.ts +++ b/tedi/components/layout/header/header-logout/header-logout.component.spec.ts @@ -1,54 +1,130 @@ -import { Component, Signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { signal } from '@angular/core'; -import { HeaderLogoutComponent } from './header-logout.component'; -import { BreakpointService } from '../../../../services/breakpoint/breakpoint.service'; - -@Component({ - standalone: true, - imports: [HeaderLogoutComponent], - template: ` - - `, -}) -class TestHostComponent {} - -describe('HeaderLogoutComponent', () => { - let fixture: ComponentFixture; - let isMobileSignal: Signal; - let mockBreakpointService: { - isBelowBreakpoint: jest.Mock - }; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { signal } from "@angular/core"; +import { HeaderLogoutComponent } from "./header-logout.component"; +import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; + +class TranslationMock { + translate(key: string) { + return key; + } + track(key: string) { + return () => key; + } + setLanguage() {} + getLanguage = signal("et"); +} + +describe("HeaderLogoutComponent", () => { + let fixture: ComponentFixture; + let isMobileSignal: ReturnType>; + let mockBreakpointService: Partial; beforeEach(async () => { isMobileSignal = signal(false); mockBreakpointService = { - isBelowBreakpoint: jest.fn().mockReturnValue(isMobileSignal) - }; + isBelowBreakpoint: () => isMobileSignal, + getBreakpointInputs: (inputs: object) => inputs, + } as unknown as Partial; await TestBed.configureTestingModule({ - imports: [TestHostComponent], + imports: [HeaderLogoutComponent], providers: [ - { provide: BreakpointService, useValue: mockBreakpointService } + { provide: BreakpointService, useValue: mockBreakpointService }, + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, ], }).compileComponents(); - fixture = TestBed.createComponent(TestHostComponent); + fixture = TestBed.createComponent(HeaderLogoutComponent); + }); + + it("applies the host class", () => { + fixture.detectChanges(); + expect(fixture.nativeElement.classList).toContain("tedi-header-logout"); }); - it('should apply base and desktop classes when not mobile', () => { - mockBreakpointService.isBelowBreakpoint.mockReturnValue(signal(false)); + it("renders the custom label as-is when `label` is set, skipping translation", () => { + fixture.componentRef.setInput("label", "Sign out"); fixture.detectChanges(); + const labelSpan = fixture.nativeElement.querySelector( + "button.tedi-header-logout__button [tedi-text]", + ); + expect(labelSpan?.textContent?.trim()).toBe("Sign out"); + }); + + describe("desktop (above md)", () => { + beforeEach(() => { + isMobileSignal.set(false); + fixture.detectChanges(); + }); + + it("renders a +} + + + + + @if (label()) { + + {{ label() }} + + } + + diff --git a/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.scss b/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.scss new file mode 100644 index 000000000..83003d3f6 --- /dev/null +++ b/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.scss @@ -0,0 +1,52 @@ +.tedi-header-mobile-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: var(--layout-header-mobile-button-size); + min-width: var(--layout-header-mobile-button-min-size); + height: var(--layout-header-mobile-button-min-size); + padding: var(--layout-grid-gutters-08); + font-family: var(--family-default); + color: var(--header-mobile-button-text-default); + text-decoration: none; + cursor: pointer; + background-color: var(--header-mobile-button-background-default); + border: transparent; + border-radius: unset; + + &__inner { + display: flex; + flex-direction: column; + align-items: center; + } + + &__text { + color: inherit; + } + + &:not(&--disabled):hover { + color: var(--header-mobile-button-text-hover); + } + + &:not(&--disabled):active { + color: var(--header-mobile-button-text-active); + } + + &:not(&--disabled):focus-visible { + outline: none; + box-shadow: + inset 0 0 0 1px var(--tedi-neutral-100), + inset 0 0 0 3px var(--tedi-blue-500); + } + + &--selected { + background: var(--header-mobile-button-background-selected); + } + + &--disabled { + color: var(--header-mobile-button-text-disabled); + cursor: default; + background: var(--header-mobile-button-background-disabled); + } +} diff --git a/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.spec.ts b/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.spec.ts new file mode 100644 index 000000000..d257d2a6a --- /dev/null +++ b/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.spec.ts @@ -0,0 +1,154 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HeaderMobileButtonComponent } from "./header-mobile-button.component"; + +describe("HeaderMobileButtonComponent", () => { + let fixture: ComponentFixture; + let component: HeaderMobileButtonComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HeaderMobileButtonComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderMobileButtonComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("icon", "menu"); + fixture.detectChanges(); + }); + + function getButton(): HTMLButtonElement | null { + return fixture.nativeElement.querySelector("button.tedi-header-mobile-button"); + } + + function getAnchor(): HTMLAnchorElement | null { + return fixture.nativeElement.querySelector("a.tedi-header-mobile-button"); + } + + function getIcon(): Element | null { + return fixture.nativeElement.querySelector("tedi-icon"); + } + + function getLabel(): Element | null { + return fixture.nativeElement.querySelector(".tedi-header-mobile-button__text"); + } + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("renders an icon", () => { + expect(getIcon()).toBeTruthy(); + expect(getIcon()?.getAttribute("ng-reflect-name")).toBe("menu"); + }); + + describe("when no href is provided", () => { + it("renders as a button", () => { + expect(getButton()).toBeTruthy(); + expect(getAnchor()).toBeFalsy(); + }); + + it("renders the label when provided", () => { + fixture.componentRef.setInput("label", "Menu"); + fixture.detectChanges(); + expect(getLabel()?.textContent?.trim()).toBe("Menu"); + }); + + it("renders without a label", () => { + expect(getLabel()).toBeFalsy(); + }); + }); + + describe("when href is provided", () => { + beforeEach(() => { + fixture.componentRef.setInput("href", "/page"); + fixture.detectChanges(); + }); + + it("renders as an anchor", () => { + expect(getAnchor()).toBeTruthy(); + expect(getButton()).toBeFalsy(); + expect(getAnchor()?.getAttribute("href")).toBe("/page"); + }); + + it("falls back to a button when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + expect(getButton()).toBeTruthy(); + expect(getAnchor()).toBeFalsy(); + expect(getButton()?.disabled).toBe(true); + }); + }); + + describe("aria attribute passthrough", () => { + it("forwards `ariaLabel` to the inner button", () => { + fixture.componentRef.setInput("ariaLabel", "Open search"); + fixture.detectChanges(); + expect(getButton()?.getAttribute("aria-label")).toBe("Open search"); + }); + + it("forwards `ariaHasPopup` to the inner button", () => { + fixture.componentRef.setInput("ariaHasPopup", "dialog"); + fixture.detectChanges(); + expect(getButton()?.getAttribute("aria-haspopup")).toBe("dialog"); + }); + + it("forwards `ariaExpanded` to the inner button", () => { + fixture.componentRef.setInput("ariaExpanded", true); + fixture.detectChanges(); + expect(getButton()?.getAttribute("aria-expanded")).toBe("true"); + + fixture.componentRef.setInput("ariaExpanded", false); + fixture.detectChanges(); + expect(getButton()?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("forwards aria attrs to the inner anchor when rendered as link", () => { + fixture.componentRef.setInput("href", "/page"); + fixture.componentRef.setInput("ariaLabel", "Open"); + fixture.componentRef.setInput("ariaHasPopup", "dialog"); + fixture.componentRef.setInput("ariaExpanded", false); + fixture.detectChanges(); + const anchor = getAnchor(); + expect(anchor?.getAttribute("aria-label")).toBe("Open"); + expect(anchor?.getAttribute("aria-haspopup")).toBe("dialog"); + expect(anchor?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("omits aria attrs when inputs are not set", () => { + const button = getButton(); + expect(button?.hasAttribute("aria-label")).toBe(false); + expect(button?.hasAttribute("aria-haspopup")).toBe(false); + expect(button?.hasAttribute("aria-expanded")).toBe(false); + }); + }); + + describe("modifier classes", () => { + it("applies `--selected` when `selected` is true", () => { + fixture.componentRef.setInput("selected", true); + fixture.detectChanges(); + expect(getButton()?.classList).toContain( + "tedi-header-mobile-button--selected", + ); + }); + + it("applies `--disabled` when `disabled` is true", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + expect(getButton()?.classList).toContain( + "tedi-header-mobile-button--disabled", + ); + expect(getButton()?.disabled).toBe(true); + }); + + it("applies no modifier classes by default", () => { + const button = getButton(); + expect(button?.classList).toContain("tedi-header-mobile-button"); + expect(button?.classList).not.toContain( + "tedi-header-mobile-button--selected", + ); + expect(button?.classList).not.toContain( + "tedi-header-mobile-button--disabled", + ); + }); + }); +}); diff --git a/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.ts b/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.ts new file mode 100644 index 000000000..682b1c914 --- /dev/null +++ b/tedi/components/layout/header/header-mobile-button/header-mobile-button.component.ts @@ -0,0 +1,81 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + ViewEncapsulation, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TextComponent } from "../../../base/text/text.component"; + +/** + * Compact icon + label button used inside the mobile header row + * (`` or a mobile-specific section of + * ``). + * + * Renders as an `` when `href` is provided and `disabled` is false, + * otherwise as a ` - - + + + + + - - - - - - - - -} @else { - - -} + + @if (isSmall()) { + + } @else { + + } + + + + + + @if (label()) { + {{ label() }} + + } + @if (modalOpen()) {
-
- +
+
} diff --git a/tedi/components/layout/header/header-profile/header-profile.component.scss b/tedi/components/layout/header/header-profile/header-profile.component.scss index 695ecd852..b0fe3e29a 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.scss +++ b/tedi/components/layout/header/header-profile/header-profile.component.scss @@ -1,33 +1,30 @@ tedi-header-profile { align-content: center; - .tedi-header-profile--mobile { - flex-direction: column; - gap: 0; - align-items: center; - justify-content: center; - min-width: var(--layout-header-mobile-button-size); - min-height: var(--layout-header-mobile-button-size); - padding: var(--layout-grid-gutters-08); - font-size: 12px; - line-height: 16px; - border: 0; - border-radius: 0; + [tedi-popover-trigger] tedi-icon[name="expand_more"] { + transition: transform 0.2s ease-in-out; + } - &:focus-visible { - outline: var(--tedi-borders-02) solid var(--tedi-primary-500); - outline-offset: calc(-1 * var(--tedi-borders-02)); - } + [tedi-popover-trigger][aria-expanded="true"] tedi-icon[name="expand_more"] { + transform: rotate(-180deg); + } - tedi-icon { - font-size: var(--icon-05); - } + tedi-icon.tedi-header-profile__icon { + font-size: var(--icon-06); - &[data-open="true"] { - background: var(--button-main-neutral-icon-only-background-active); + &.tedi-header-profile__icon--small { + font-size: var(--icon-03); } } + tedi-icon.tedi-header-profile__icon--expand { + font-size: var(--icon-03); + } + + .tedi-header-profile__label { + white-space: nowrap; + } + .tedi-header-profile__overlay { position: absolute; top: var(--layout-header-height); @@ -51,28 +48,43 @@ tedi-header-profile { max-height: 100%; overflow-y: auto; background: var(--general-surface-primary); + border-top: 1px solid var(--general-border-primary); - > * { - padding: var(--layout-header-modal-item-padding); - - &:not(:last-child) { - border-bottom: var(--tedi-borders-01) solid - var(--general-border-primary); + &:not(&--no-style) > * { + &:not(.tedi-header-role) { + padding: var(--layout-header-modal-item-padding); } + border-bottom: var(--tedi-borders-01) solid var(--general-border-primary); + &:has(.tedi-header-role__head) { border-bottom: var(--tedi-borders-04) solid var(--general-border-brand); } } - .tedi-header-logout--mobile { + tedi-header-logout .tedi-header-mobile-button { flex-direction: row; gap: var(--link-inner-spacing-x); + align-items: center; justify-content: flex-start; + width: 100%; + height: auto; + min-height: 0; + padding: 0; font-size: var(--body-regular-size); + .tedi-header-mobile-button__inner { + flex-direction: row; + gap: var(--link-inner-spacing-x); + align-items: center; + } + + .tedi-header-mobile-button__text { + font-size: inherit; + } + tedi-icon { - font-size: inherit !important; + font-size: var(--icon-02); } } } diff --git a/tedi/components/layout/header/header-profile/header-profile.component.spec.ts b/tedi/components/layout/header/header-profile/header-profile.component.spec.ts index d909845b5..dffd023e1 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.spec.ts +++ b/tedi/components/layout/header/header-profile/header-profile.component.spec.ts @@ -25,8 +25,8 @@ describe("HeaderProfileComponent", () => { component = fixture.componentInstance; // set required inputs - fixture.componentRef.setInput("name", "John Doe"); - fixture.componentRef.setInput("showDropdown", "lg"); + fixture.componentRef.setInput("label", "John Doe"); + fixture.componentRef.setInput("showPopover", "lg"); fixture.detectChanges(); }); @@ -66,8 +66,46 @@ describe("HeaderProfileComponent", () => { expect(component.modalOpen()).toBe(false); }); + describe("resolvedLabel", () => { + it("returns the custom label as-is when `label` is set", () => { + expect(component.resolvedLabel()).toBe("John Doe"); + }); + + it("falls back to the `header.profile` translation key when `label` is empty", () => { + const translate = jest + .spyOn(component.translationService, "translate") + .mockImplementation(((...args: unknown[]) => `__${args[0]}__`) as unknown as typeof component.translationService.translate); + + fixture.componentRef.setInput("label", ""); + fixture.detectChanges(); + + expect(component.resolvedLabel()).toBe("__header.profile__"); + expect(translate).toHaveBeenCalledWith("header.profile"); + }); + }); + + describe("body scroll lock effect", () => { + afterEach(() => { + documentMock.body.style.removeProperty("overflow"); + }); + + it("locks body scroll when the modal opens", () => { + component.modalOpen.set(true); + fixture.detectChanges(); + expect(documentMock.body.style.overflow).toBe("hidden"); + }); + + it("restores body scroll when the modal closes", () => { + component.modalOpen.set(true); + fixture.detectChanges(); + component.modalOpen.set(false); + fixture.detectChanges(); + expect(documentMock.body.style.overflow).toBe(""); + }); + }); + describe("buttonVariant", () => { - it("should return 'neutral' when below 'sm' breakpoint", () => { + it("should return 'neutral' when below 'md' breakpoint", () => { const mockSignal = signal(true); jest .spyOn(component.breakpointService, "isBelowBreakpoint") @@ -76,23 +114,23 @@ describe("HeaderProfileComponent", () => { expect(component.buttonVariant()).toBe("neutral"); }); - it("should return 'neutral' when name is empty", () => { + it("should return 'neutral' when label is empty", () => { const mockSignal = signal(false); jest .spyOn(component.breakpointService, "isBelowBreakpoint") .mockReturnValue(mockSignal); - fixture.componentRef.setInput("name", ""); + fixture.componentRef.setInput("label", ""); fixture.detectChanges(); expect(component.buttonVariant()).toBe("neutral"); }); - it("should return 'secondary' when above 'sm' breakpoint and name is provided", () => { + it("should return 'secondary' when above 'md' breakpoint and label is provided", () => { const mockSignal = signal(false); jest .spyOn(component.breakpointService, "isBelowBreakpoint") .mockReturnValue(mockSignal); - fixture.componentRef.setInput("name", "John Doe"); + fixture.componentRef.setInput("label", "John Doe"); fixture.detectChanges(); expect(component.buttonVariant()).toBe("secondary"); diff --git a/tedi/components/layout/header/header-profile/header-profile.component.ts b/tedi/components/layout/header/header-profile/header-profile.component.ts index 0d6bda26b..3e64f8d5b 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.ts +++ b/tedi/components/layout/header/header-profile/header-profile.component.ts @@ -25,9 +25,11 @@ import { Breakpoint, BreakpointService, } from "../../../../services/breakpoint/breakpoint.service"; -import { TediTranslationPipe } from "../../../../services/translation/translation.pipe"; import { PopoverTriggerDirective } from "../../../overlay/popover/popover-trigger/popover-trigger.directive"; import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { HeaderMobileButtonComponent } from "../header-mobile-button/header-mobile-button.component"; + +export type HeaderProfileSize = "default" | "small"; @Component({ selector: "tedi-header-profile", @@ -41,7 +43,7 @@ import { TediTranslationService } from "../../../../services/translation/transla ShowAtDirective, HideAtDirective, NgTemplateOutlet, - TediTranslationPipe, + HeaderMobileButtonComponent, ], templateUrl: "./header-profile.component.html", styleUrl: "./header-profile.component.scss", @@ -49,10 +51,45 @@ import { TediTranslationService } from "../../../../services/translation/transla changeDetection: ChangeDetectionStrategy.OnPush, }) export class HeaderProfileComponent implements AfterContentInit { - /** Name of representative */ - name = input(""); - /** Breakpoint at which we show dropdown instead of modal */ - showDropdown = input(); + /** + * Custom label text for the profile button. When provided, used as-is — not + * translated. When omitted or empty, the desktop trigger renders as an + * icon-only button (no visible label) and the mobile trigger falls back to + * the `header.profile` translation key. + */ + label = input(""); + /** + * Defines the breakpoint from which the profile menu is displayed as a popover. + * Below this breakpoint, it is rendered as a modal. + * + * @default 'lg' + */ + showPopover = input("lg"); + + /** + * Removes default item styles from the mobile modal content. When `true`, + * projected children render without the padding, border, or background that + * `` normally applies to each direct child of the + * modal. Use when the content requires custom item styling. + * + * Only affects the mobile/modal branch. The desktop popover uses + * ``'s own styling and is unaffected. + * + * @default false + */ + noStyle = input(false); + + /** + * Visual size of the profile button. + * - `'small'` renders a compact icon-and-caption button (the + * `` variant). + * - `'default'` renders a button with the user's name + icon. + * + * When left unset, the size is chosen automatically based on the viewport: + * `'small'` below the `md` breakpoint, `'default'` from `md` up. Pass an + * explicit value to force a variant regardless of viewport. + */ + size = input(); readonly breakpointService = inject(BreakpointService); readonly translationService = inject(TediTranslationService); @@ -61,6 +98,23 @@ export class HeaderProfileComponent implements AfterContentInit { private readonly renderer = inject(Renderer2); private readonly eventListeners: (() => void)[] = []; + readonly isMobile = computed(() => + this.breakpointService.isBelowBreakpoint("md")(), + ); + + readonly isSmall = computed(() => { + const size = this.size() ?? (this.isMobile() ? "small" : "default"); + return size === "small"; + }); + + readonly resolvedLabel = computed(() => { + if (this.label()) { + return this.label(); + } + + return this.translationService.translate("header.profile"); + }); + constructor() { effect(() => { if (this.modalOpen()) { @@ -74,21 +128,13 @@ export class HeaderProfileComponent implements AfterContentInit { modalOpen = signal(false); readonly buttonVariant = computed(() => { - if (this.breakpointService.isBelowBreakpoint("sm")() || !this.name()) { + if (this.isSmall() || !this.label()) { return "neutral"; } return "secondary"; }); - readonly buttonClass = computed(() => { - if (this.breakpointService.isBelowBreakpoint("sm")()) { - return "tedi-header-profile--mobile"; - } - - return null; - }); - ngAfterContentInit(): void { const element = this.host.nativeElement as HTMLElement; @@ -105,14 +151,7 @@ export class HeaderProfileComponent implements AfterContentInit { } handleModalOpen() { - const dropdownBreakpoint = this.showDropdown(); - - if ( - !( - dropdownBreakpoint && - this.breakpointService.isAboveBreakpoint(dropdownBreakpoint)() - ) - ) { + if (!this.breakpointService.isAboveBreakpoint(this.showPopover())()) { this.modalOpen.update((prev) => !prev); } } diff --git a/tedi/components/layout/header/header-role/header-role-title.directive.ts b/tedi/components/layout/header/header-role/header-role-title.directive.ts new file mode 100644 index 000000000..f1b0a2bb3 --- /dev/null +++ b/tedi/components/layout/header/header-role/header-role-title.directive.ts @@ -0,0 +1,19 @@ +import { Directive } from "@angular/core"; + +/** + * Marker directive applied to a projected child of `` to mark it + * as the title content (e.g. a ``). The directive itself adds no DOM or + * behavior — it only exists so `HeaderRoleComponent` can detect, via `contentChild`, + * whether a consumer has projected title content. When projected, the title slot + * replaces the bold `role` string fallback. + * + * @example + * + * Esindatav + * + */ +@Directive({ + selector: "[tedi-header-role-title]", + standalone: true, +}) +export class HeaderRoleTitleDirective {} diff --git a/tedi/components/layout/header/header-role/header-role.component.html b/tedi/components/layout/header/header-role/header-role.component.html index 0230e692f..8c53610bb 100644 --- a/tedi/components/layout/header/header-role/header-role.component.html +++ b/tedi/components/layout/header/header-role/header-role.component.html @@ -1,24 +1,37 @@ -
+
- {{ role() }} {{ currentRepresentative().name }} +
+ +

+ {{ currentRepresentative().name }} +

+
@if (description()) { + @if (!hasMultipleRepresentatives()) { + + } {{ description() }} }
- @if (representatives().length > 1) { + @if (hasMultipleRepresentatives()) { }
- @if (representatives().length > 1) { + @if (hasMultipleRepresentatives()) {
@@ -28,16 +41,17 @@ - @if (role() || description()) { + @if (label() || description() || hasTitle()) {
- @if (role()) { - {{ role() }} - } + @if (description()) { {{ description() }} @@ -45,8 +59,12 @@
} - - + + +
+
+ +
+ +} @else { + +} + + + + diff --git a/tedi/components/layout/header/header-search/header-search.component.scss b/tedi/components/layout/header/header-search/header-search.component.scss new file mode 100644 index 000000000..57b250d31 --- /dev/null +++ b/tedi/components/layout/header/header-search/header-search.component.scss @@ -0,0 +1,57 @@ +.tedi-header-search { + display: contents; + + &__modal { + position: fixed; + top: 0; + left: 0; + z-index: var(--z-index-header); + width: 100%; + max-width: 100%; + height: 100dvh; + max-height: 100dvh; + padding: 0; + background: var(--modal-background); + border: 0; + + &[open] { + display: flex; + flex-direction: column; + } + + &::backdrop { + background: transparent; + } + } + + &__modal-heading { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + padding: var(--modal-heading-padding-y) var(--modal-heading-padding-x); + border-bottom: var(--tedi-borders-01) solid var(--modal-border-inner); + } + + &__modal-body { + flex: 1 1 0; + padding: var(--modal-body-padding); + overflow-y: auto; + } + + &__button-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--layout-grid-gutters-08); + color: var(--button-close-text-default); + cursor: pointer; + background: transparent; + border: 0; + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + } +} diff --git a/tedi/components/layout/header/header-search/header-search.component.spec.ts b/tedi/components/layout/header/header-search/header-search.component.spec.ts new file mode 100644 index 000000000..51eb506d2 --- /dev/null +++ b/tedi/components/layout/header/header-search/header-search.component.spec.ts @@ -0,0 +1,316 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component, signal } from "@angular/core"; +import { HeaderSearchComponent } from "./header-search.component"; +import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; + +class TranslationMock { + translate(key: string) { + return key; + } + track(key: string) { + return () => key; + } + setLanguage() {} + getLanguage = signal("et"); +} + +function stubDialogElement() { + if (typeof HTMLDialogElement !== "undefined") { + if (!HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }; + } + if (!HTMLDialogElement.prototype.close) { + HTMLDialogElement.prototype.close = function close( + this: HTMLDialogElement, + ) { + this.removeAttribute("open"); + this.dispatchEvent(new Event("close")); + }; + } + } +} + +describe("HeaderSearchComponent", () => { + let fixture: ComponentFixture; + let component: HeaderSearchComponent; + let isMobileSignal: ReturnType>; + let mockBreakpointService: Partial; + + beforeEach(async () => { + stubDialogElement(); + isMobileSignal = signal(false); + mockBreakpointService = { + isBelowBreakpoint: () => isMobileSignal, + } as Partial; + + await TestBed.configureTestingModule({ + imports: [HeaderSearchComponent], + providers: [ + { provide: BreakpointService, useValue: mockBreakpointService }, + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderSearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + function getToggle(): HTMLButtonElement | null { + return fixture.nativeElement.querySelector( + "tedi-header-mobile-button button.tedi-header-mobile-button", + ); + } + + function getToggleHost(): HTMLElement | null { + return fixture.nativeElement.querySelector("tedi-header-mobile-button"); + } + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should have the host class applied", () => { + expect(fixture.nativeElement.classList).toContain("tedi-header-search"); + }); + + describe("desktop", () => { + beforeEach(() => { + isMobileSignal.set(false); + fixture.detectChanges(); + }); + + it("does not render the toggle button", () => { + expect(getToggleHost()).toBeFalsy(); + }); + + it("does not render the modal dialog", () => { + expect( + fixture.nativeElement.querySelector(".tedi-header-search__modal"), + ).toBeFalsy(); + }); + }); + + describe("mobile + modal variant", () => { + beforeEach(() => { + isMobileSignal.set(true); + fixture.detectChanges(); + }); + + it("renders the toggle button with the search label", () => { + const btn = getToggle(); + expect(btn).toBeTruthy(); + expect( + btn + ?.querySelector(".tedi-header-mobile-button__text") + ?.textContent?.trim(), + ).toBe("header.search"); + }); + + it("forwards dialog-trigger aria attributes to the inner button", () => { + const btn = getToggle(); + expect(btn?.getAttribute("aria-label")).toBe("header.search"); + expect(btn?.getAttribute("aria-haspopup")).toBe("dialog"); + expect(btn?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("renders the modal dialog", () => { + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement | null; + expect(dialog).toBeTruthy(); + expect(dialog?.tagName).toBe("DIALOG"); + expect(dialog?.getAttribute("aria-label")).toBe("header.search"); + }); + + it("opens the modal when the toggle button is clicked", () => { + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement; + const showSpy = jest.spyOn(dialog, "showModal"); + + getToggle()!.click(); + fixture.detectChanges(); + + expect(showSpy).toHaveBeenCalled(); + expect(getToggle()?.classList).toContain( + "tedi-header-mobile-button--selected", + ); + expect(getToggle()?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("closes the modal when the close button is clicked", () => { + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement; + const closeSpy = jest.spyOn(dialog, "close"); + + getToggle()!.click(); + fixture.detectChanges(); + + const closeBtn = fixture.nativeElement.querySelector( + ".tedi-header-search__button-close", + ) as HTMLButtonElement; + closeBtn.click(); + fixture.detectChanges(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it("syncs state when the dialog emits a `close` event (Escape, etc.)", () => { + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement; + + getToggle()!.click(); + fixture.detectChanges(); + + dialog.dispatchEvent(new Event("close")); + fixture.detectChanges(); + + expect(getToggle()?.classList).not.toContain( + "tedi-header-mobile-button--selected", + ); + expect(getToggle()?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("does not open the modal when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement; + const showSpy = jest.spyOn(dialog, "showModal"); + + const btn = getToggle(); + expect(btn?.disabled).toBe(true); + btn?.click(); + fixture.detectChanges(); + + expect(showSpy).not.toHaveBeenCalled(); + }); + + it("uses custom labels when provided", () => { + fixture.componentRef.setInput("mobileLabels", { + button: "Otsi", + modalTitle: "Otsing", + }); + fixture.detectChanges(); + + const btn = getToggle(); + expect( + btn + ?.querySelector(".tedi-header-mobile-button__text") + ?.textContent?.trim(), + ).toBe("Otsi"); + + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement; + expect(dialog.getAttribute("aria-label")).toBe("Otsing"); + expect( + fixture.nativeElement + .querySelector(".tedi-header-search__modal-heading [tedi-text]") + ?.textContent?.trim(), + ).toBe("Otsing"); + }); + + it("renders projected children inside the modal body", () => { + @Component({ + standalone: true, + imports: [HeaderSearchComponent], + template: ` + + + + `, + }) + class HostComponent {} + + const hostFixture = TestBed.createComponent(HostComponent); + hostFixture.detectChanges(); + + const input = hostFixture.nativeElement.querySelector( + "[data-testid='search-input']", + ); + expect(input).toBeTruthy(); + expect( + hostFixture.nativeElement + .querySelector(".tedi-header-search__modal-body") + ?.contains(input), + ).toBe(true); + }); + + it("resets modalOpen when leaving the mobile breakpoint", () => { + const dialog = fixture.nativeElement.querySelector( + ".tedi-header-search__modal", + ) as HTMLDialogElement; + const closeSpy = jest.spyOn(dialog, "close"); + + getToggle()!.click(); + fixture.detectChanges(); + + isMobileSignal.set(false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector(".tedi-header-search__modal"), + ).toBeFalsy(); + expect(getToggleHost()).toBeFalsy(); + expect(closeSpy).toHaveBeenCalled(); + }); + }); + + describe("mobile + inline variant", () => { + beforeEach(() => { + isMobileSignal.set(true); + fixture.componentRef.setInput("mobileVariant", "inline"); + fixture.detectChanges(); + }); + + it("does not render the toggle button", () => { + expect(getToggleHost()).toBeFalsy(); + }); + + it("does not render the modal dialog", () => { + expect( + fixture.nativeElement.querySelector(".tedi-header-search__modal"), + ).toBeFalsy(); + }); + + it("renders projected children inline", () => { + @Component({ + standalone: true, + imports: [HeaderSearchComponent], + template: ` + + + + `, + }) + class HostComponent {} + + isMobileSignal.set(true); + const hostFixture = TestBed.createComponent(HostComponent); + hostFixture.detectChanges(); + + const input = hostFixture.nativeElement.querySelector( + "[data-testid='inline-input']", + ); + expect(input).toBeTruthy(); + expect( + hostFixture.nativeElement.querySelector( + ".tedi-header-search__modal-body", + ), + ).toBeFalsy(); + }); + }); +}); diff --git a/tedi/components/layout/header/header-search/header-search.component.ts b/tedi/components/layout/header/header-search/header-search.component.ts new file mode 100644 index 000000000..35a4dadc6 --- /dev/null +++ b/tedi/components/layout/header/header-search/header-search.component.ts @@ -0,0 +1,122 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + ViewEncapsulation, + computed, + effect, + inject, + input, + signal, + viewChild, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TextComponent } from "../../../base/text/text.component"; +import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { HeaderMobileButtonComponent } from "../header-mobile-button/header-mobile-button.component"; + +export type HeaderSearchMobileVariant = "modal" | "inline"; + +export interface HeaderSearchMobileLabels { + /** Label shown on the mobile toggle button. Falls back to the `header.search` translation. */ + button?: string; + /** Title shown in the mobile modal heading. Falls back to the `header.search` translation. */ + modalTitle?: string; +} + +@Component({ + selector: "tedi-header-search", + standalone: true, + imports: [ + NgTemplateOutlet, + IconComponent, + TextComponent, + HeaderMobileButtonComponent, + ], + templateUrl: "./header-search.component.html", + styleUrl: "./header-search.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: "tedi-header-search", + }, +}) +export class HeaderSearchComponent { + /** + * Behavior on mobile viewports (below the `md` breakpoint). + * - `'modal'` shows a toggle button that opens a fullscreen native dialog containing the projected content. + * - `'inline'` renders the projected content inline at all breakpoints (no toggle, no dialog). + * @default "modal" + */ + readonly mobileVariant = input("modal"); + + /** + * Mobile-specific labels for the toggle button and the modal title. + * When omitted, both fall back to the `header.search` translation key. + */ + readonly mobileLabels = input({}); + + /** + * Disables the mobile toggle button. Has no effect on desktop or in the `inline` variant. + * @default false + */ + readonly disabled = input(false); + + protected readonly translationService = inject(TediTranslationService); + protected readonly isMobile = + inject(BreakpointService).isBelowBreakpoint("md"); + + protected readonly modalOpen = signal(false); + protected readonly dialogEl = + viewChild>("dialog"); + + protected readonly buttonLabel = computed( + () => + this.mobileLabels()?.button ?? + this.translationService.translate("header.search"), + ); + + protected readonly modalTitle = computed( + () => + this.mobileLabels()?.modalTitle ?? + this.translationService.translate("header.search"), + ); + + protected readonly closeLabel = computed(() => + this.translationService.translate("close"), + ); + + constructor() { + effect(() => { + const dialog = this.dialogEl()?.nativeElement; + if (!dialog) return; + + if (this.modalOpen() && !dialog.open) { + dialog.showModal(); + } else if (!this.modalOpen() && dialog.open) { + dialog.close(); + } + }); + + effect(() => { + if (!this.isMobile() && this.modalOpen()) { + this.modalOpen.set(false); + } + }); + } + + protected open(): void { + if (this.disabled()) return; + this.modalOpen.set(true); + } + + protected close(): void { + this.modalOpen.set(false); + } + + protected onDialogClose(): void { + this.modalOpen.set(false); + } +} diff --git a/tedi/components/layout/header/header-search/index.ts b/tedi/components/layout/header/header-search/index.ts new file mode 100644 index 000000000..8911bde29 --- /dev/null +++ b/tedi/components/layout/header/header-search/index.ts @@ -0,0 +1 @@ +export * from "./header-search.component"; diff --git a/tedi/components/layout/header/header.component.html b/tedi/components/layout/header/header.component.html index 59ab69496..506938fcc 100644 --- a/tedi/components/layout/header/header.component.html +++ b/tedi/components/layout/header/header.component.html @@ -1,4 +1,7 @@ -
+ +
-
\ No newline at end of file +
+
+ diff --git a/tedi/components/layout/header/header.component.scss b/tedi/components/layout/header/header.component.scss index 31ad976b5..80d06c435 100644 --- a/tedi/components/layout/header/header.component.scss +++ b/tedi/components/layout/header/header.component.scss @@ -1,22 +1,34 @@ .tedi-header { - display: flex; + display: block; + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); &__main { display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - height: var(--layout-header-height); - padding: var(--layout-header-padding-y) var(--layout-header-padding-right) var(--layout-header-padding-y) var(--layout-header-padding-left); + min-height: var(--layout-header-height); + overflow: auto; background: var(--header-background); - box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); + + &--content { + display: flex; + gap: var(--layout-header-items-right-gutter-x); + align-items: center; + justify-content: space-between; + width: 100%; + min-height: var(--layout-header-height); + padding: var(--layout-header-padding-y) var(--layout-header-padding-right) + var(--layout-header-padding-y) var(--layout-header-padding-left); + } } - &__link-button { + &__link-button.tedi-link { display: inline-flex; + gap: var(--link-inner-spacing-x); align-items: center; padding: 0; + font-family: var(--family-default); font-size: var(--body-regular-size); + color: var(--header-dropdown-link); + white-space: nowrap; background-color: transparent; border: 0; @@ -30,4 +42,8 @@ text-decoration: none !important; } } + + .tedi-separator { + padding: var(--layout-header-separator-padding-y) 0; + } } diff --git a/tedi/components/layout/header/header.stories.ts b/tedi/components/layout/header/header.stories.ts index 25440b4ca..ea5e0b8d1 100644 --- a/tedi/components/layout/header/header.stories.ts +++ b/tedi/components/layout/header/header.stories.ts @@ -1,16 +1,31 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { - argsToTemplate, - Meta, - moduleMetadata, - StoryObj, -} from "@storybook/angular"; + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + Directive, + inject, + Pipe, + PipeTransform, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { ThemeService } from "../../../services/theme/theme.service"; +import { + Language, + TediTranslationService, +} from "../../../services/translation/translation.service"; import { HeaderComponent } from "./header.component"; import { HeaderContentComponent } from "./header-content/header-content.component"; import { HeaderActionsComponent } from "./header-actions/header-actions.component"; import { HeaderRoleComponent } from "./header-role/header-role.component"; +import { HeaderRoleTitleDirective } from "./header-role/header-role-title.directive"; import { HeaderLanguageComponent } from "./header-language/header-language.component"; import { HeaderProfileComponent } from "./header-profile/header-profile.component"; import { HeaderLoginComponent } from "./header-login/header-login.component"; +import { HeaderLogoComponent } from "./header-logo/header-logo.component"; +import { HeaderLogoDarkDirective } from "./header-logo/header-logo-dark.directive"; import { HeaderLogoutComponent } from "./header-logout/header-logout.component"; import { LinkComponent } from "../../navigation/link/link.component"; import { IconComponent } from "../../base/icon/icon.component"; @@ -20,27 +35,120 @@ import { HideAtDirective } from "../../../directives/hide-at/hide-at.directive"; import { SideNavToggleComponent } from "../sidenav/sidenav-toggle/sidenav-toggle.component"; import { SideNavComponent } from "../sidenav/sidenav.component"; import { SideNavItemComponent } from "../sidenav/sidenav-item/sidenav-item.component"; -import { SideNavOverlayComponent } from "../sidenav/sidenav-overlay/sidenav-overlay.component"; +import { SideNavDropdownComponent } from "../sidenav/sidenav-dropdown/sidenav-dropdown.component"; +import { SideNavDropdownItemComponent } from "../sidenav/sidenav-dropdown-item/sidenav-dropdown-item.component"; import { SeparatorComponent } from "../../helpers/separator/separator.component"; +import { HeaderSearchComponent } from "./header-search/header-search.component"; +import { HeaderBottomComponent } from "./header-bottom/header-bottom.component"; +import { FormFieldComponent } from "../../form/form-field/form-field.component"; +import { LabelComponent } from "../../form/label/label.component"; +import { TextFieldComponent } from "../../form/text-field/text-field.component"; +// TODO: replace with TEDI-Ready Search component once it lands. Community Search is +// used here only to demo HeaderSearch consumption — do NOT mirror this import from +// any non-story file inside `tedi/`. +import { SearchComponent } from "community/components/form"; +import { TagComponent } from "../../tags/tag/tag.component"; +import { ToggleComponent } from "../../form/toggle/toggle.component"; + +const profileTranslations = { + myData: { et: "Minu andmed", en: "My data", ru: "Мои данные" }, + representatives: { + et: "Esindatavad", + en: "Representatives", + ru: "Представители", + }, + contacts: { et: "Kontaktid", en: "Contacts", ru: "Контакты" }, + darkMode: { et: "Tume režiim", en: "Dark mode", ru: "Тёмная тема" }, + notifications: { + et: "Riiklikud teated", + en: "National notifications", + ru: "Государственные уведомления", + }, + accessibility: { + et: "Ligipääsetavus", + en: "Accessibility", + ru: "Доступность", + }, + home: { et: "Avaleht", en: "Home", ru: "Главная" }, + services: { et: "Teenused", en: "Services", ru: "Услуги" }, + blog: { et: "Blogi", en: "Blog", ru: "Блог" }, + contact: { et: "Kontakt", en: "Contact", ru: "Контакт" }, +} as const satisfies Record>; + +type ProfileTranslationKey = keyof typeof profileTranslations; + +/** + * Story-local translation pipe that resolves `profileTranslations` keys against + * the current `TediTranslationService` language. + */ +@Pipe({ name: "storyTranslate", standalone: true, pure: false }) +class StoryTranslatePipe implements PipeTransform { + private translation = inject(TediTranslationService); + + transform(key: ProfileTranslationKey): string { + const lang = this.translation.getLanguage(); + return profileTranslations[key][lang] ?? profileTranslations[key].et; + } +} + +@Component({ + selector: "story-theme-toggle", + standalone: true, + imports: [LabelComponent, ToggleComponent, StoryTranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + +
+ `, +}) +class StoryThemeToggleComponent { + private themeService = inject(ThemeService); + + isDark = computed(() => this.themeService.theme() === "dark"); + + handleToggle(checked: boolean) { + this.themeService.theme.set(checked ? "dark" : "default"); + } +} + +@Directive({ + selector: "tedi-header-logo[storyResponsive]", + exportAs: "storyResponsive", + standalone: true, +}) +class StoryResponsiveLogoDirective { + readonly show = signal(true); + + constructor() { + if (typeof window === "undefined") return; + + const mql = window.matchMedia("(min-width: 420px)"); + this.show.set(mql.matches); + + const handler = (event: MediaQueryListEvent) => + this.show.set(event.matches); + mql.addEventListener("change", handler); + inject(DestroyRef).onDestroy(() => + mql.removeEventListener("change", handler), + ); + } +} /** *
Figma ↗
* Zeroheight ↗ - * - * To test the mobile layout, either resize your browser window or use Storybook's built-in viewport tools. - * The header component is used to display the header at the top of the page. It can contain SideNav menu toggle, logo, content links, language select, role select, profile menu, login and logout buttons and more. - * This component is responsive and adapts to mobile layout when at certain screen size. But it is needed to use showAt and hideAt directives also, to show and hide some components, since their location changes in different breakpoints. - * For an example, HeaderRoleComponent is in main header in desktop, but in mobile it is under HeaderProfileComponent menu. - * Header consists of several sub-components: - * - `HeaderContentComponent`: Used for showing links in desktop view. - * - `HeaderActionsComponent`: Used for showing and styling actions in header (placed at the right side). - * - `HeaderRoleComponent`: Used for showing role selection. - * - `HeaderLanguageComponent`: Used for selecting language. - * - `HeaderProfileComponent`: Used for showing profile menu. - * - `HeaderLoginComponent`: Used for showing login button. - * - `HeaderLogoutComponent`: Used for showing logout button. */ - export default { title: "TEDI-Ready/Layout/Header", component: HeaderComponent, @@ -54,6 +162,8 @@ export default { HeaderLanguageComponent, HeaderProfileComponent, HeaderLoginComponent, + HeaderLogoComponent, + HeaderLogoDarkDirective, HeaderLogoutComponent, SeparatorComponent, LinkComponent, @@ -64,7 +174,20 @@ export default { SideNavComponent, SideNavItemComponent, SideNavToggleComponent, - SideNavOverlayComponent, + SideNavDropdownComponent, + SideNavDropdownItemComponent, + HeaderSearchComponent, + HeaderBottomComponent, + FormFieldComponent, + LabelComponent, + TextFieldComponent, + SearchComponent, + TagComponent, + HeaderRoleTitleDirective, + ToggleComponent, + StoryThemeToggleComponent, + StoryResponsiveLogoDirective, + StoryTranslatePipe, ], }), ], @@ -73,25 +196,131 @@ export default { docs: { toc: false }, }, argTypes: { - role: { - description: "Role text", - control: "text", + logoHref: { + name: "href", + description: + "URL to wrap the logo with an anchor. When omitted, the logo renders without a link.", + table: { category: "header-logo", type: { summary: "string" } }, + }, + showLogo: { + description: + "Controls visibility of the logo. Useful for conditionally hiding the logo based on application state, feature flags, or custom media queries that fall between standard breakpoints (e.g. 420px). For responsive hiding at standard breakpoints, prefer wrapping `` with the `*hideAt` / `*showAt` directives.", table: { - category: "header-role", - type: { summary: "string" }, + category: "header-logo", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, }, }, - description: { - description: "Description text", - control: "text", + alignment: { + description: "Horizontal alignment of content area.", table: { - category: "header-role", - type: { summary: "string" }, + category: "header-content", + type: { summary: "'flex-start' | 'center' | 'space-between'" }, + defaultValue: { summary: "'center'" }, + }, + }, + languages: { + description: + "Languages object. Key is the value in `Language` type; value is the text shown in the UI.", + table: { + category: "header-language", + type: { summary: "HeaderLanguage" }, + }, + }, + languageChange: { + description: "Emitted when the active language is changed.", + table: { + category: "header-language", + type: { summary: "EventEmitter" }, + }, + }, + loginSize: { + name: "size", + description: + "Visual size of the login button. Auto-selected from the viewport when omitted.", + table: { + category: "header-login", + type: { summary: "'default' | 'small'" }, }, }, + loginLabel: { + name: "label", + description: + "Custom label text. Falls back to the `header.login` / `header.login-small` translation key.", + table: { category: "header-login", type: { summary: "string" } }, + }, + loginHref: { + name: "href", + description: + "URL — when provided, renders as ``. Otherwise renders as ` + ${logo} + + Link text + Link text + Link text + + + + + + + + +
+ `, + }), +}; + +export const LoggedOut1: StoryObj = { + render: (args) => ({ + props: args, + styles: [mobileSidenavWrapperStyles], + template: ` +
+
+ + ${logo} + + Link text + Link text + Link text + Link text + Link text + + + + + + +
+ +
`, - ], + }), +}; + +export const LoggedOut2: StoryObj = { + render: (args) => ({ + props: args, + styles: [mobileSidenavWrapperStyles], + template: ` +
+
+ + ${responsiveLogo} + + + +
+ +
+
+
+ + + + + + + + + +
+ +
+ `, + }), +}; + +export const LoggedIn1: StoryObj = { + render: (args) => ({ + props: args, template: ` -
- - Logo - - Link text - Link text - +
+ ${logo} - - + + ${accessibilityLink} + + + + + + + + + ${accessibilityLink} + ${profileMenuContent} +
- - `, }), }; -export const LoggedIn1: StoryObj = { +export const LoggedIn2: StoryObj = { render: (args) => ({ props: args, - styles: [ - ` - img { - max-height: 40px; - - @media (min-width: 992px) { - max-height: 52px; - } - } + template: ` +
+ ${logo} + + + ${accessibilityLink} + + + Esindatav: + + + + + + + + Esindatav: + + ${accessibilityLink} + ${profileMenuContent} + + +
`, - ], + }), +}; + +export const WithOrganizationSelection1: StoryObj = { + render: (args) => ({ + props: args, template: ` -
-
- Logo - +
+ ${logo} + + + ${accessibilityLink} + + + - - + + + + + + + - Minu andmed - Esindatavad - Kontaktid - - - - -
-
+ ${accessibilityLink} + + ${profileMenuContent} + + +
`, }), }; -export const LoggedIn2: StoryObj = { +export const WithOrganizationSelection2: StoryObj = { render: (args) => ({ props: args, - styles: [ - ` - img { - max-height: 40px; - - @media (min-width: 992px) { - max-height: 52px; - } - } + template: ` +
+ ${logo} + + + ${accessibilityLink} + + + + + + + + + + ${accessibilityLink} + + ${profileMenuContent} + + +
`, - ], + }), +}; + +export const AlternativeProfileAndLogoutButton1: StoryObj = { + render: (args) => ({ + props: args, template: ` -
-
- Logo - +
+ ${logo} + + + ${accessibilityLink} + + + - - - -
-
+ + + + + + + + + ${accessibilityLink} + + ${profileMenuContent} + + + `, }), }; -export const LoggedIn3: StoryObj = { +export const AlternativeProfileAndLogoutButton2: StoryObj = { render: (args) => ({ props: args, - styles: [ - ` - img { - max-height: 40px; - - @media (min-width: 992px) { - max-height: 52px; - } - } - `, - ], template: ` -
-
- Logo - +
+ ${logo} + + + ${accessibilityLink} + - - + + + + + + + ${accessibilityLink} + + ${profileMenuContent} + + +
+ `, + }), +}; + +export const AlternativeProfileAndLogoutButton3: StoryObj = { + render: (args) => ({ + props: args, + template: ` +
+ ${logo} + + + + + - Minu andmed - Esindatavad - Kontaktid - - - - -
-
+ ${accessibilityLink} + ${profileMenuContent} + + + `, }), }; -export const LoggedOut: StoryObj = { +export const AlternativeProfileAndLogoutButton4: StoryObj = { render: (args) => ({ props: args, - styles: [ - ` - img { - max-height: 40px; - - @media (min-width: 992px) { - max-height: 52px; - } - } + template: ` +
+ ${logo} + + + ${accessibilityLink} + + + + + + + + + + ${accessibilityLink} + + + + + +
`, - ], + }), +}; + +export const WithSearch1: StoryObj = { + render: (args) => ({ + props: args, template: ` -
- Logo +
+ ${logo} + + + + + + + + - + + + + ${accessibilityLink} + ${profileMenuContent} +
`, }), }; -export const WithOrganization: StoryObj = { +export const WithSearch2: StoryObj = { render: (args) => ({ props: args, template: ` -
-
- Logo - - - Ligipääsetavus - - +
+ ${logo} + + + + + + + + + + + + + - - + ${accessibilityLink} + ${profileMenuContent} + + + + + + + + + +
+ `, + }), +}; + +export const LoggedInWithSidenav: StoryObj = { + render: (args) => ({ + props: args, + styles: [mobileSidenavWrapperStyles], + template: ` +
+
+ + ${logo} + + + ${accessibilityLink} + + + + + + - - Ligipääsetavus - - - Minu andmed - Esindatavad - Kontaktid - - + ${accessibilityLink} + ${profileMenuContent}
+
`, }), diff --git a/tedi/components/layout/header/index.ts b/tedi/components/layout/header/index.ts index a7edc44e8..0762f2376 100644 --- a/tedi/components/layout/header/index.ts +++ b/tedi/components/layout/header/index.ts @@ -1,8 +1,14 @@ export * from "./header.component"; export * from "./header-actions/header-actions.component"; +export * from "./header-bottom/header-bottom.component"; export * from "./header-content/header-content.component"; export * from "./header-language/header-language.component"; export * from "./header-login/header-login.component"; +export * from "./header-logo/header-logo.component"; +export * from "./header-logo/header-logo-dark.directive"; export * from "./header-logout/header-logout.component"; +export * from "./header-mobile-button/header-mobile-button.component"; export * from "./header-profile/header-profile.component"; -export * from "./header-role/header-role.component"; \ No newline at end of file +export * from "./header-role/header-role.component"; +export * from "./header-role/header-role-title.directive"; +export * from "./header-search/header-search.component"; \ No newline at end of file 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 f789c922b..0c8df431a 100644 --- a/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss +++ b/tedi/components/layout/sidenav/sidenav-item/sidenav-item.component.scss @@ -45,6 +45,7 @@ min-height: var(--_sidenav-item-min-height); padding: var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-right) var(--_sidenav-item-padding-y) var(--_sidenav-item-padding-left); + font-family: var(--family-default); font-size: inherit; color: var(--navigation-vertical-item-text-default); text-align: start; diff --git a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html index 906bcb577..c178f6f74 100644 --- a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html +++ b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html @@ -1,4 +1 @@ - + diff --git a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss index bf650b0ea..6037c18b1 100644 --- a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss +++ b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss @@ -6,11 +6,16 @@ justify-content: center; width: 3.5rem; height: 3.5rem; + padding: 1rem; cursor: pointer; background: var(--button-main-primary-background-default); border: 0; border-radius: 0; + tedi-icon { + color: var(--button-main-primary-text-default); + } + &--hidden { display: none; } diff --git a/tedi/components/navigation/link/link.component.scss b/tedi/components/navigation/link/link.component.scss index 3a78ff4cc..2ee3cdfa5 100644 --- a/tedi/components/navigation/link/link.component.scss +++ b/tedi/components/navigation/link/link.component.scss @@ -31,6 +31,7 @@ .tedi-link { display: inline-flex; + font-family: var(--family-default); text-decoration: none; cursor: pointer; diff --git a/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts b/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts index 5a0424100..a2bf70445 100644 --- a/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts +++ b/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts @@ -15,8 +15,8 @@ import { PopoverComponent } from "../popover.component"; role: "button", "aria-haspopup": "dialog", "[id]": "popover.containerId() + '_trigger'", - "[attr.aria-expanded]": "popover.floatUiComponent().state", - "[attr.aria-controls]": "popover.containerId()", + "[attr.aria-expanded]": "popover.isOpen()", + "[attr.aria-controls]": "popover.containerId() || null", "[class.tedi-popover-trigger__text]": "underline()", }, }) diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss index eae835a06..10e810e86 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -6,7 +6,8 @@ $popover-max-width: ( ); tedi-popover { - display: block; + display: inline-flex; + align-items: center; } [tedi-popover-trigger] { @@ -22,6 +23,7 @@ float-ui-content { .float-ui-container-popover { z-index: var(--z-index-dropdown); padding: 0; + background: var(--popover-background); border: var(--tedi-borders-01) solid var(--popover-border); border-radius: var(--popover-radius-rounded); box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); @@ -41,17 +43,23 @@ float-ui-content { border-radius: var(--popover-radius-rounded); .float-ui-arrow { - width: 18px; - height: 18px; + box-sizing: border-box; + width: 1.5rem; + height: 1.5rem; + background: var(--popover-background); border-right: 4px solid var(--header-popover-border-top); border-bottom: 4px solid var(--header-popover-border-top); + filter: drop-shadow(0 0 5px var(--tedi-alpha-20)); + clip-path: polygon(100% 0, 100% 100%, 0 100%); + fill: var(--popover-background); } &[data-float-ui-placement="top"] { border-bottom: 4px solid var(--header-popover-border-top); .float-ui-arrow { - bottom: -12px !important; + bottom: -0.75rem; + transform: rotate(45deg); } } @@ -59,7 +67,7 @@ float-ui-content { border-top: 4px solid var(--header-popover-border-top); .float-ui-arrow { - top: -12px !important; + top: -0.75rem; } } @@ -67,7 +75,7 @@ float-ui-content { border-right: 4px solid var(--header-popover-border-top); .float-ui-arrow { - right: -12px !important; + right: -0.75rem; } } @@ -75,7 +83,7 @@ float-ui-content { border-left: 4px solid var(--header-popover-border-top); .float-ui-arrow { - left: -12px !important; + left: -0.75rem; } } } diff --git a/tedi/components/overlay/popover/popover.component.ts b/tedi/components/overlay/popover/popover.component.ts index 21eb8afbb..1527ca69a 100644 --- a/tedi/components/overlay/popover/popover.component.ts +++ b/tedi/components/overlay/popover/popover.component.ts @@ -89,6 +89,13 @@ export class PopoverComponent implements AfterContentChecked { readonly containerId = signal(""); readonly isContentHovered = signal(false); + /** + * Reactive open state. Mirrors `floatUiComponent().state` but is signal-based so + * consumers (e.g. trigger host bindings, parent components on OnPush) re-evaluate + * immediately when the popover opens or closes via any code path — click, + * outside-click dismiss, Escape, scroll-hide, etc. + */ + readonly isOpen = signal(false); hideTimeout?: ReturnType; private keydownListener?: () => void; @@ -118,6 +125,7 @@ export class PopoverComponent implements AfterContentChecked { clearTimeout(this.hideTimeout); this.floatUiComponent().show(); + this.isOpen.set(true); const floatUiEl = this.floatUiComponent().elRef .nativeElement as HTMLElement; @@ -150,6 +158,7 @@ export class PopoverComponent implements AfterContentChecked { this.cleanupScrollListener(); this.cleanupDismissListeners(); this.floatUiComponent().hide(); + this.isOpen.set(false); if (this.lockScroll()) { this.renderer.removeStyle(this.document.body, "overflow"); diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 7665e5351..859f8ff9e 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -109,9 +109,9 @@ export const translationsMap = { "header.role-switch": { description: "Label for role switch button", components: ["HeaderRole"], - et: "Vaheta rolli", - en: "Switch role", - ru: "Сменить роль", + et: "Roll", + en: "Role", + ru: "Роль", }, "header.role-search": { description: "Label for role search input", @@ -132,14 +132,14 @@ export const translationsMap = { components: ["HeaderLogin"], et: "Sisene portaali", en: "Log in", - ru: "авторизоваться", + ru: "Зайти на портал", }, - "header.login-mobile": { + "header.login-small": { description: "Label for login button in mobile view", components: ["HeaderLogin"], et: "Sisene", en: "Log in", - ru: "авторизоваться", + ru: "Войти", }, "header.logout": { description: "Label for logout button", @@ -148,6 +148,13 @@ export const translationsMap = { en: "Log out", ru: "Выйти", }, + "header.logout-small": { + description: "Label for logout button (small)", + components: ["HeaderLogout"], + et: "Välju", + en: "Log out", + ru: "Выйти", + }, "header.logo": { description: "Alt Label for logo", components: ["Header"], @@ -162,6 +169,13 @@ export const translationsMap = { en: "Profile", ru: "Профиль", }, + "header.search": { + description: "Label for search button", + components: ["HeaderSearch"], + et: "Otsing", + en: "Search", + ru: "Поиск", + }, "file-upload.add": { description: "Label for add file button", components: ["FileUpload"], @@ -592,9 +606,12 @@ export const translationsMap = { "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} подменю`), + 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", From 999e94a7c66de49b6d7c8970051894fe00d0749a Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Fri, 15 May 2026 18:34:45 +0300 Subject: [PATCH 2/4] feat(header): refactor #312 --- .../header-login.component.spec.ts | 6 +- .../header-login/header-login.component.ts | 47 +++++++- .../header-logout/header-logout.component.ts | 48 +++++++- .../header-profile.component.html | 18 ++- .../header-profile.component.scss | 5 +- .../header-profile.component.spec.ts | 51 +++++++- .../header-profile.component.ts | 66 +++++++++-- .../header-role/header-role.component.scss | 2 + .../header-role/header-role.component.spec.ts | 111 ++++++++++++++++++ .../header-role/header-role.component.ts | 14 +++ .../layout/header/header.stories.ts | 8 +- tedi/services/translation/translations.ts | 7 ++ 12 files changed, 338 insertions(+), 45 deletions(-) diff --git a/tedi/components/layout/header/header-login/header-login.component.spec.ts b/tedi/components/layout/header/header-login/header-login.component.spec.ts index 149cd9108..9dbefad52 100644 --- a/tedi/components/layout/header/header-login/header-login.component.spec.ts +++ b/tedi/components/layout/header/header-login/header-login.component.spec.ts @@ -7,7 +7,10 @@ import { TediTranslationService } from "../../../../services/translation/transla @Component({ standalone: true, imports: [HeaderLoginComponent], - template: ``, + template: ``, }) class TestHostComponent { href?: string; @@ -27,6 +30,7 @@ describe("HeaderLoginComponent", () => { isMobileSignal = signal(false); mockBreakpointService = { isBelowBreakpoint: () => isMobileSignal, + getBreakpointInputs: (inputs: T) => inputs, } as Partial; mockTranslationService = { translate: jest.fn((key: string) => key), diff --git a/tedi/components/layout/header/header-login/header-login.component.ts b/tedi/components/layout/header/header-login/header-login.component.ts index 37847aacb..866c239e9 100644 --- a/tedi/components/layout/header/header-login/header-login.component.ts +++ b/tedi/components/layout/header/header-login/header-login.component.ts @@ -7,12 +7,25 @@ import { ViewEncapsulation, } from "@angular/core"; import { ButtonComponent } from "../../../buttons/button/button.component"; -import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; +import { + BreakpointInputs, + BreakpointService, +} from "../../../../services/breakpoint/breakpoint.service"; import { TediTranslationService } from "../../../../services/translation/translation.service"; import { HeaderMobileButtonComponent } from "../header-mobile-button/header-mobile-button.component"; export type HeaderLoginSize = "default" | "small"; +/** + * Subset of `HeaderLoginComponent` inputs that can be overridden per breakpoint + * via the `[xs]` / `[sm]` / `[md]` / `[lg]` / `[xl]` / `[xxl]` inputs. Mirrors + * React's `HeaderLoginBreakpointProps`. + */ +export type HeaderLoginInputs = { + size: HeaderLoginSize | undefined; + label: string; +}; + @Component({ selector: "tedi-header-login", standalone: true, @@ -22,7 +35,9 @@ export type HeaderLoginSize = "default" | "small"; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HeaderLoginComponent { +export class HeaderLoginComponent + implements BreakpointInputs +{ breakpointService = inject(BreakpointService); translationService = inject(TediTranslationService); @@ -54,14 +69,36 @@ export class HeaderLoginComponent { */ href = input(); + xs = input(); + sm = input(); + md = input(); + lg = input(); + xl = input(); + xxl = input(); + + protected readonly breakpointInputs = computed(() => + this.breakpointService.getBreakpointInputs({ + size: this.size(), + label: this.label(), + xs: this.xs(), + sm: this.sm(), + md: this.md(), + lg: this.lg(), + xl: this.xl(), + xxl: this.xxl(), + }), + ); + isSmall = computed(() => { - const size = this.size() ?? (this.isMobile() ? "small" : "default"); + const size = + this.breakpointInputs().size ?? (this.isMobile() ? "small" : "default"); return size === "small"; }); resolvedLabel = computed(() => { - if (this.label()) { - return this.label(); + const { label } = this.breakpointInputs(); + if (label) { + return label; } return this.translationService.translate( diff --git a/tedi/components/layout/header/header-logout/header-logout.component.ts b/tedi/components/layout/header/header-logout/header-logout.component.ts index e3280f6ef..b35c58f0b 100644 --- a/tedi/components/layout/header/header-logout/header-logout.component.ts +++ b/tedi/components/layout/header/header-logout/header-logout.component.ts @@ -7,7 +7,10 @@ import { ViewEncapsulation, } from "@angular/core"; import { IconComponent } from "../../../base/icon/icon.component"; -import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; +import { + BreakpointInputs, + BreakpointService, +} from "../../../../services/breakpoint/breakpoint.service"; import { TextComponent } from "../../../base/text/text.component"; import { LinkComponent } from "../../../navigation/link/link.component"; import { TediTranslationService } from "../../../../services/translation/translation.service"; @@ -15,6 +18,16 @@ import { HeaderMobileButtonComponent } from "../header-mobile-button/header-mobi export type HeaderLogoutSize = "default" | "small"; +/** + * Subset of `HeaderLogoutComponent` inputs that can be overridden per + * breakpoint via the `[xs]` / `[sm]` / `[md]` / `[lg]` / `[xl]` / `[xxl]` + * inputs. Mirrors React's `HeaderLogoutBreakpointProps`. + */ +export type HeaderLogoutInputs = { + size: HeaderLogoutSize | undefined; + label: string; +}; + @Component({ selector: "tedi-header-logout", standalone: true, @@ -32,7 +45,9 @@ export type HeaderLogoutSize = "default" | "small"; class: "tedi-header-logout", }, }) -export class HeaderLogoutComponent { +export class HeaderLogoutComponent + implements BreakpointInputs +{ private translationService = inject(TediTranslationService); breakpointService = inject(BreakpointService); private isMobile = this.breakpointService.isBelowBreakpoint("md"); @@ -65,14 +80,37 @@ export class HeaderLogoutComponent { */ href = input(); + xs = input(); + sm = input(); + md = input(); + lg = input(); + xl = input(); + xxl = input(); + + protected readonly breakpointInputs = computed(() => + this.breakpointService.getBreakpointInputs({ + size: this.size(), + label: this.label(), + xs: this.xs(), + sm: this.sm(), + md: this.md(), + lg: this.lg(), + xl: this.xl(), + xxl: this.xxl(), + }), + ); + isSmall = computed(() => { - const size = this.size() ?? (this.isMobile() ? "small" : "default"); + const size = + this.breakpointInputs().size ?? + (this.isMobile() ? "small" : "default"); return size === "small"; }); resolvedLabel = computed(() => { - if (this.label()) { - return this.label(); + const { label } = this.breakpointInputs(); + if (label) { + return label; } return this.translationService.translate( diff --git a/tedi/components/layout/header/header-profile/header-profile.component.html b/tedi/components/layout/header/header-profile/header-profile.component.html index 9f3670426..6f25d17ed 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.html +++ b/tedi/components/layout/header/header-profile/header-profile.component.html @@ -1,8 +1,8 @@ - + - + @@ -41,9 +39,7 @@ tedi-button [attr.data-open]="modalOpen()" [variant]="buttonVariant()" - [attr.aria-label]=" - !label() ? translationService.track('header.profile')() : null - " + [attr.aria-label]="!showLabel() ? resolvedLabel() : null" [attr.aria-haspopup]="'dialog'" [attr.aria-expanded]="modalOpen()" (click)="handleModalOpen()" @@ -59,10 +55,10 @@ name="account_circle" color="brand" class="tedi-header-profile__icon" - [class.tedi-header-profile__icon--small]="label()" + [class.tedi-header-profile__icon--small]="showLabel()" /> - @if (label()) { - {{ label() }} + @if (showLabel()) { + {{ resolvedLabel() }} } diff --git a/tedi/components/layout/header/header-profile/header-profile.component.scss b/tedi/components/layout/header/header-profile/header-profile.component.scss index b0fe3e29a..67f9f84aa 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.scss +++ b/tedi/components/layout/header/header-profile/header-profile.component.scss @@ -31,7 +31,7 @@ tedi-header-profile { left: 0; z-index: calc(var(--z-index-header) - 1); width: 100%; - min-height: calc(100dvh - var(--layout-header-height)); + height: calc(100dvh - var(--layout-header-height)); background: rgb(0 0 0 / 25%); } @@ -44,8 +44,7 @@ tedi-header-profile { flex-direction: column; width: var(--navigation-vertical-item-width-default); max-width: 100%; - min-height: calc(100dvh - var(--layout-header-height)); - max-height: 100%; + height: calc(100dvh - var(--layout-header-height)); overflow-y: auto; background: var(--general-surface-primary); border-top: 1px solid var(--general-border-primary); diff --git a/tedi/components/layout/header/header-profile/header-profile.component.spec.ts b/tedi/components/layout/header/header-profile/header-profile.component.spec.ts index dffd023e1..9316e63c9 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.spec.ts +++ b/tedi/components/layout/header/header-profile/header-profile.component.spec.ts @@ -71,10 +71,16 @@ describe("HeaderProfileComponent", () => { expect(component.resolvedLabel()).toBe("John Doe"); }); - it("falls back to the `header.profile` translation key when `label` is empty", () => { + it("falls back to `header.profile` on desktop when `label` is empty", () => { + jest + .spyOn(component.breakpointService, "isBelowBreakpoint") + .mockReturnValue(signal(false)); const translate = jest .spyOn(component.translationService, "translate") - .mockImplementation(((...args: unknown[]) => `__${args[0]}__`) as unknown as typeof component.translationService.translate); + .mockImplementation( + ((...args: unknown[]) => + `__${args[0]}__`) as unknown as typeof component.translationService.translate, + ); fixture.componentRef.setInput("label", ""); fixture.detectChanges(); @@ -82,6 +88,39 @@ describe("HeaderProfileComponent", () => { expect(component.resolvedLabel()).toBe("__header.profile__"); expect(translate).toHaveBeenCalledWith("header.profile"); }); + + it("falls back to `header.profile.mobile` on mobile when `label` is empty", () => { + jest + .spyOn(component.breakpointService, "isBelowBreakpoint") + .mockReturnValue(signal(true)); + const translate = jest + .spyOn(component.translationService, "translate") + .mockImplementation( + ((...args: unknown[]) => + `__${args[0]}__`) as unknown as typeof component.translationService.translate, + ); + + fixture.componentRef.setInput("label", ""); + fixture.detectChanges(); + + expect(component.resolvedLabel()).toBe("__header.profile.mobile__"); + expect(translate).toHaveBeenCalledWith("header.profile.mobile"); + }); + + it("uses the breakpoint-override label when its tier is active", () => { + jest + .spyOn(component.breakpointService, "getBreakpointInputs") + .mockReturnValue({ + label: "Mari Maasikas", + showPopover: "lg", + }); + + fixture.componentRef.setInput("label", ""); + fixture.componentRef.setInput("md", { label: "Mari Maasikas" }); + fixture.detectChanges(); + + expect(component.resolvedLabel()).toBe("Mari Maasikas"); + }); }); describe("body scroll lock effect", () => { @@ -114,23 +153,23 @@ describe("HeaderProfileComponent", () => { expect(component.buttonVariant()).toBe("neutral"); }); - it("should return 'neutral' when label is empty", () => { + it("should return 'neutral' when showLabel is false", () => { const mockSignal = signal(false); jest .spyOn(component.breakpointService, "isBelowBreakpoint") .mockReturnValue(mockSignal); - fixture.componentRef.setInput("label", ""); + fixture.componentRef.setInput("showLabel", false); fixture.detectChanges(); expect(component.buttonVariant()).toBe("neutral"); }); - it("should return 'secondary' when above 'md' breakpoint and label is provided", () => { + it("should return 'secondary' when above 'md' breakpoint and showLabel is true", () => { const mockSignal = signal(false); jest .spyOn(component.breakpointService, "isBelowBreakpoint") .mockReturnValue(mockSignal); - fixture.componentRef.setInput("label", "John Doe"); + fixture.componentRef.setInput("showLabel", true); fixture.detectChanges(); expect(component.buttonVariant()).toBe("secondary"); diff --git a/tedi/components/layout/header/header-profile/header-profile.component.ts b/tedi/components/layout/header/header-profile/header-profile.component.ts index 3e64f8d5b..677fdc91c 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.ts +++ b/tedi/components/layout/header/header-profile/header-profile.component.ts @@ -23,6 +23,7 @@ import { PopoverComponent } from "../../../overlay/popover/popover.component"; import { PopoverContentComponent } from "../../../overlay/popover/popover-content/popover-content.component"; import { Breakpoint, + BreakpointInputs, BreakpointService, } from "../../../../services/breakpoint/breakpoint.service"; import { PopoverTriggerDirective } from "../../../overlay/popover/popover-trigger/popover-trigger.directive"; @@ -31,6 +32,15 @@ import { HeaderMobileButtonComponent } from "../header-mobile-button/header-mobi export type HeaderProfileSize = "default" | "small"; +/** + * Subset of `HeaderProfileComponent` inputs that can be overridden per + * breakpoint via the `[xs]` / `[sm]` / `[md]` / `[lg]` / `[xl]` / `[xxl]` inputs. + */ +export type HeaderProfileInputs = { + label: string; + showPopover: Breakpoint; +}; + @Component({ selector: "tedi-header-profile", standalone: true, @@ -50,14 +60,26 @@ export type HeaderProfileSize = "default" | "small"; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class HeaderProfileComponent implements AfterContentInit { +export class HeaderProfileComponent + implements BreakpointInputs, AfterContentInit +{ /** - * Custom label text for the profile button. When provided, used as-is — not - * translated. When omitted or empty, the desktop trigger renders as an - * icon-only button (no visible label) and the mobile trigger falls back to - * the `header.profile` translation key. + * Custom label text for the profile button. Falls back to the `header.profile` + * translation key on desktop, or `header.profile.mobile` on mobile. */ label = input(""); + + /** + * Whether to display a text label next to the profile icon on non-mobile + * viewports. When `false` the desktop trigger renders as an icon-only button + * and the label (custom or translated) is used as the `aria-label` only. + * When `true` the label is visible and a chevron is shown next to it. + * + * Has no effect on the mobile/small variant — the mobile button always + * shows its label. + * @default false + */ + showLabel = input(false); /** * Defines the breakpoint from which the profile menu is displayed as a popover. * Below this breakpoint, it is rendered as a modal. @@ -91,6 +113,13 @@ export class HeaderProfileComponent implements AfterContentInit { */ size = input(); + xs = input(); + sm = input(); + md = input(); + lg = input(); + xl = input(); + xxl = input(); + readonly breakpointService = inject(BreakpointService); readonly translationService = inject(TediTranslationService); private readonly document = inject(DOCUMENT); @@ -107,12 +136,28 @@ export class HeaderProfileComponent implements AfterContentInit { return size === "small"; }); + protected readonly breakpointInputs = computed(() => { + return this.breakpointService.getBreakpointInputs({ + label: this.label(), + showPopover: this.showPopover(), + xs: this.xs(), + sm: this.sm(), + md: this.md(), + lg: this.lg(), + xl: this.xl(), + xxl: this.xxl(), + }); + }); + readonly resolvedLabel = computed(() => { - if (this.label()) { - return this.label(); + const { label } = this.breakpointInputs(); + if (label) { + return label; } - return this.translationService.translate("header.profile"); + return this.translationService.translate( + this.isMobile() ? "header.profile.mobile" : "header.profile", + ); }); constructor() { @@ -128,7 +173,7 @@ export class HeaderProfileComponent implements AfterContentInit { modalOpen = signal(false); readonly buttonVariant = computed(() => { - if (this.isSmall() || !this.label()) { + if (this.isSmall() || !this.showLabel()) { return "neutral"; } @@ -151,7 +196,8 @@ export class HeaderProfileComponent implements AfterContentInit { } handleModalOpen() { - if (!this.breakpointService.isAboveBreakpoint(this.showPopover())()) { + const { showPopover } = this.breakpointInputs(); + if (!this.breakpointService.isAboveBreakpoint(showPopover)()) { this.modalOpen.update((prev) => !prev); } } diff --git a/tedi/components/layout/header/header-role/header-role.component.scss b/tedi/components/layout/header/header-role/header-role.component.scss index 1b7c9a519..2ebb3ef8e 100644 --- a/tedi/components/layout/header/header-role/header-role.component.scss +++ b/tedi/components/layout/header/header-role/header-role.component.scss @@ -64,7 +64,9 @@ } button { + gap: var(--link-inner-spacing-x); padding: 0; + border: none; tedi-icon { transition: transform var(--_header-role-transition-duration) diff --git a/tedi/components/layout/header/header-role/header-role.component.spec.ts b/tedi/components/layout/header/header-role/header-role.component.spec.ts index 3757fdb09..60ae6c4da 100644 --- a/tedi/components/layout/header/header-role/header-role.component.spec.ts +++ b/tedi/components/layout/header/header-role/header-role.component.spec.ts @@ -2,8 +2,10 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { Component, NO_ERRORS_SCHEMA, signal } from "@angular/core"; import { HeaderRoleComponent, Representative } from "./header-role.component"; import { HeaderRoleTitleDirective } from "./header-role-title.directive"; +import { HeaderProfileComponent } from "../header-profile/header-profile.component"; import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; function createMobileBreakpointMock(): Partial { const isBelowSignal = signal(true); @@ -11,6 +13,7 @@ function createMobileBreakpointMock(): Partial { return { isBelowBreakpoint: () => isBelowSignal, isAboveBreakpoint: () => isAboveSignal, + getBreakpointInputs: (inputs: T) => inputs, }; } @@ -20,6 +23,7 @@ function createDesktopBreakpointMock(): Partial { return { isBelowBreakpoint: () => isBelowSignal, isAboveBreakpoint: () => isAboveSignal, + getBreakpointInputs: (inputs: T) => inputs, }; } @@ -398,3 +402,110 @@ describe("HeaderRoleComponent desktop popover effects", () => { } }); }); + +describe("HeaderRoleComponent reset on parent profile close", () => { + const mockTranslationService = { + translate: (key: string) => key, + track: (key: string) => () => key, + } as Partial; + const reps: Representative[] = [ + { id: "1", name: "Alice", description: "Lead" }, + { id: "2", name: "Bob", description: "Dev" }, + ]; + + function setup(): { + fixture: ComponentFixture; + profile: HeaderProfileComponent; + role: HeaderRoleComponent; + } { + @Component({ + standalone: true, + imports: [HeaderProfileComponent, HeaderRoleComponent], + template: ` + + + + `, + }) + class HostComponent { + reps = reps; + } + + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [ + { provide: TediTranslationService, useValue: mockTranslationService }, + { provide: BreakpointService, useValue: createMobileBreakpointMock() }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + schemas: [NO_ERRORS_SCHEMA], + }); + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + + const profileEl = fixture.debugElement.query( + (de) => de.componentInstance instanceof HeaderProfileComponent, + ); + const profile = profileEl.componentInstance as HeaderProfileComponent; + + profile.modalOpen.set(true); + fixture.detectChanges(); + + const roleEl = fixture.debugElement.query( + (de) => de.componentInstance instanceof HeaderRoleComponent, + ); + return { + fixture, + profile, + role: roleEl.componentInstance as HeaderRoleComponent, + }; + } + + it("collapses mobileOpen and clears inputValue when the parent profile modal closes", () => { + const { fixture, profile, role } = setup(); + + role.mobileOpen.set(true); + role.inputValue.set("alice"); + fixture.detectChanges(); + + expect(role.mobileOpen()).toBe(true); + expect(role.inputValue()).toBe("alice"); + + // Close profile → role state should reset. + profile.modalOpen.set(false); + fixture.detectChanges(); + + expect(role.mobileOpen()).toBe(false); + expect(role.inputValue()).toBe(""); + }); + + it("does nothing when HeaderRole is rendered outside any HeaderProfile", () => { + TestBed.configureTestingModule({ + imports: [HeaderRoleComponent], + providers: [ + { provide: TediTranslationService, useValue: mockTranslationService }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + schemas: [NO_ERRORS_SCHEMA], + }); + + const fixture = TestBed.createComponent(HeaderRoleComponent); + fixture.componentRef.setInput("label", "Roll:"); + fixture.componentRef.setInput("representatives", reps); + fixture.componentRef.setInput("currentRepresentative", reps[0]); + fixture.detectChanges(); + + fixture.componentInstance.mobileOpen.set(true); + fixture.componentInstance.inputValue.set("alice"); + fixture.detectChanges(); + + expect(fixture.componentInstance.mobileOpen()).toBe(true); + expect(fixture.componentInstance.inputValue()).toBe("alice"); + }); +}); diff --git a/tedi/components/layout/header/header-role/header-role.component.ts b/tedi/components/layout/header/header-role/header-role.component.ts index b2ea4e3bf..93ed47e49 100644 --- a/tedi/components/layout/header/header-role/header-role.component.ts +++ b/tedi/components/layout/header/header-role/header-role.component.ts @@ -28,6 +28,7 @@ import { PopoverTriggerDirective } from "../../../overlay/popover/popover-trigge import { PopoverContentComponent } from "../../../overlay/popover/popover-content/popover-content.component"; import { SeparatorComponent } from "../../../helpers/separator/separator.component"; import { IconSize } from "../../../base/icon/icon.component"; +import { HeaderProfileComponent } from "../header-profile/header-profile.component"; export type RepresentativeIcon = { /** Material Icon name. */ @@ -136,6 +137,10 @@ export class HeaderRoleComponent { private previousPopoverOpen: boolean | undefined; + private readonly parentProfile = inject(HeaderProfileComponent, { + optional: true, + }); + constructor() { effect(() => { if (this.popover()?.isOpen() && this.showInput()) { @@ -155,6 +160,15 @@ export class HeaderRoleComponent { } this.previousPopoverOpen = isOpen; }); + + if (this.parentProfile) { + effect(() => { + if (!this.parentProfile!.modalOpen()) { + this.mobileOpen.set(false); + this.inputValue.set(""); + } + }); + } } translationService = inject(TediTranslationService); diff --git a/tedi/components/layout/header/header.stories.ts b/tedi/components/layout/header/header.stories.ts index ea5e0b8d1..8ae608997 100644 --- a/tedi/components/layout/header/header.stories.ts +++ b/tedi/components/layout/header/header.stories.ts @@ -821,7 +821,7 @@ export const AlternativeProfileAndLogoutButton1: StoryObj = { - + = { - + = { - + = { - + Date: Fri, 15 May 2026 19:49:53 +0300 Subject: [PATCH 3/4] feat(header): add CR fixes #312 --- package-lock.json | 8 ++-- package.json | 2 +- .../header-language.component.html | 8 +++- .../header-language.component.scss | 4 +- .../header-language.component.spec.ts | 4 +- .../header-login/header-login.component.html | 7 +++- .../header-login.component.spec.ts | 4 +- .../header-login/header-login.component.ts | 4 +- .../header-logout.component.spec.ts | 2 +- .../header-logout/header-logout.component.ts | 8 ++-- .../header-profile.component.html | 2 + .../header-profile.component.scss | 5 ++- .../header-profile.component.spec.ts | 39 ++++++++++++------- .../header-role/header-role.component.html | 10 ++++- .../header-role/header-role.component.scss | 4 +- .../header-role/header-role.component.ts | 8 ++++ .../header-search.component.spec.ts | 17 ++++++++ .../header-search/header-search.component.ts | 4 +- .../layout/header/header.component.scss | 2 +- .../layout/header/header.stories.ts | 6 +-- .../sidenav-toggle.component.html | 5 ++- .../sidenav-toggle.component.scss | 2 +- .../overlay/popover/popover.component.scss | 2 +- .../overlay/popover/popover.component.ts | 3 ++ tedi/services/translation/translations.ts | 4 +- 25 files changed, 115 insertions(+), 49 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27a49a042..68668ef3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@tedi-design-system/angular", "version": "0.0.0-semantic-version", "dependencies": { - "@tedi-design-system/core": "^6.0.1" + "@tedi-design-system/core": "^6.1.2" }, "devDependencies": { "@angular-devkit/core": "19.2.15", @@ -9972,9 +9972,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.0.1.tgz", - "integrity": "sha512-SgWbcIofn/LSzGbHYPYZD7i3PPdeL/qTaq99QT+RY6i9ISYViHQcrtNDiLZogx5KrREl/mQcrl3sLZNwmU6bDg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-6.1.2.tgz", + "integrity": "sha512-6kBr4pJ5KL1gfZYJd9WjTejAtnF4hJkCgZ4JR7B4UDnbKC7a7STyJC/umDkNbsZ3Xw2tLDZX1ocwkVhgcxjC0Q==", "engines": { "node": ">=24.0.0", "npm": ">=11.0.0" diff --git a/package.json b/package.json index 38208c54e..45a3177ac 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ngx-float-ui": "^19.0.1 || ^20.0.0 || ^21.0.0" }, "dependencies": { - "@tedi-design-system/core": "^6.0.1" + "@tedi-design-system/core": "^6.1.2" }, "devDependencies": { "@angular-devkit/core": "19.2.15", diff --git a/tedi/components/layout/header/header-language/header-language.component.html b/tedi/components/layout/header/header-language/header-language.component.html index ce950d1db..f5722fa80 100644 --- a/tedi/components/layout/header/header-language/header-language.component.html +++ b/tedi/components/layout/header/header-language/header-language.component.html @@ -16,11 +16,17 @@ class="tedi-link tedi-header__link-button" > {{ languages()[translationService.getLanguage()] }} - + } diff --git a/tedi/components/layout/header/header-login/header-login.component.spec.ts b/tedi/components/layout/header/header-login/header-login.component.spec.ts index 9dbefad52..4ff06888f 100644 --- a/tedi/components/layout/header/header-login/header-login.component.spec.ts +++ b/tedi/components/layout/header/header-login/header-login.component.spec.ts @@ -64,7 +64,7 @@ describe("HeaderLoginComponent", () => { isMobileSignal.set(true); fixture.detectChanges(); expect(mockTranslationService.translate).toHaveBeenCalledWith( - "header.login-small", + "header.login.mobile", ); }); @@ -121,7 +121,7 @@ describe("HeaderLoginComponent", () => { const text = fixture.nativeElement.querySelector( "tedi-header-mobile-button .tedi-header-mobile-button__text", ); - expect(text?.textContent?.trim()).toBe("header.login-small"); + expect(text?.textContent?.trim()).toBe("header.login.mobile"); }); it("forwards `href` to HeaderMobileButton so it renders as an anchor", () => { diff --git a/tedi/components/layout/header/header-login/header-login.component.ts b/tedi/components/layout/header/header-login/header-login.component.ts index 866c239e9..c2577f707 100644 --- a/tedi/components/layout/header/header-login/header-login.component.ts +++ b/tedi/components/layout/header/header-login/header-login.component.ts @@ -57,7 +57,7 @@ export class HeaderLoginComponent /** * Custom label text for the login button. When provided, used as-is — not - * translated. When omitted or empty, falls back to the `header.login-small` + * translated. When omitted or empty, falls back to the `header.login.mobile` * translation key for the compact variant and `header.login` for the full * variant. */ @@ -102,7 +102,7 @@ export class HeaderLoginComponent } return this.translationService.translate( - this.isSmall() ? "header.login-small" : "header.login", + this.isSmall() ? "header.login.mobile" : "header.login", ); }); } diff --git a/tedi/components/layout/header/header-logout/header-logout.component.spec.ts b/tedi/components/layout/header/header-logout/header-logout.component.spec.ts index 88053d910..e1b027377 100644 --- a/tedi/components/layout/header/header-logout/header-logout.component.spec.ts +++ b/tedi/components/layout/header/header-logout/header-logout.component.spec.ts @@ -113,7 +113,7 @@ describe("HeaderLogoutComponent", () => { const label = fixture.nativeElement.querySelector( "tedi-header-mobile-button .tedi-header-mobile-button__text", ); - expect(label?.textContent?.trim()).toBe("header.logout-small"); + expect(label?.textContent?.trim()).toBe("header.logout.mobile"); }); it("forwards `href` so HeaderMobileButton renders as an anchor", () => { diff --git a/tedi/components/layout/header/header-logout/header-logout.component.ts b/tedi/components/layout/header/header-logout/header-logout.component.ts index b35c58f0b..019ef74b7 100644 --- a/tedi/components/layout/header/header-logout/header-logout.component.ts +++ b/tedi/components/layout/header/header-logout/header-logout.component.ts @@ -48,8 +48,8 @@ export type HeaderLogoutInputs = { export class HeaderLogoutComponent implements BreakpointInputs { - private translationService = inject(TediTranslationService); - breakpointService = inject(BreakpointService); + private readonly translationService = inject(TediTranslationService); + private readonly breakpointService = inject(BreakpointService); private isMobile = this.breakpointService.isBelowBreakpoint("md"); /** @@ -68,7 +68,7 @@ export class HeaderLogoutComponent /** * Custom label text for the logout button. When provided, used as-is — not - * translated. When omitted or empty, falls back to the `header.logout-small` + * translated. When omitted or empty, falls back to the `header.logout.mobile` * translation key for the compact variant and `header.logout` for the full * variant. */ @@ -114,7 +114,7 @@ export class HeaderLogoutComponent } return this.translationService.translate( - this.isSmall() ? "header.logout-small" : "header.logout", + this.isSmall() ? "header.logout.mobile" : "header.logout", ); }); } diff --git a/tedi/components/layout/header/header-profile/header-profile.component.html b/tedi/components/layout/header/header-profile/header-profile.component.html index 6f25d17ed..3e4732217 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.html +++ b/tedi/components/layout/header/header-profile/header-profile.component.html @@ -9,6 +9,7 @@ [preventOverflow]="true" >
@if (hasMultipleRepresentatives()) { - @@ -71,7 +76,7 @@ class="tedi-link tedi-header__link-button" > {{ currentRepresentative().name }} - + @@ -98,6 +103,7 @@ }
diff --git a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html index c178f6f74..07e3998d9 100644 --- a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html +++ b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.html @@ -1 +1,4 @@ - + diff --git a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss index 6037c18b1..8211c8400 100644 --- a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss +++ b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss @@ -12,7 +12,7 @@ border: 0; border-radius: 0; - tedi-icon { + &__icon { color: var(--button-main-primary-text-default); } diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss index 10e810e86..ef1496e27 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -5,7 +5,7 @@ $popover-max-width: ( "large": 840px, ); -tedi-popover { +.tedi-popover { display: inline-flex; align-items: center; } diff --git a/tedi/components/overlay/popover/popover.component.ts b/tedi/components/overlay/popover/popover.component.ts index 1527ca69a..6c8953813 100644 --- a/tedi/components/overlay/popover/popover.component.ts +++ b/tedi/components/overlay/popover/popover.component.ts @@ -31,6 +31,9 @@ export type PopoverPosition = `${NgxFloatUiPlacements}`; styleUrl: "./popover.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-popover", + }, }) export class PopoverComponent implements AfterContentChecked { /** diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 4b964e30b..462a02aff 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -134,7 +134,7 @@ export const translationsMap = { en: "Log in", ru: "Зайти на портал", }, - "header.login-small": { + "header.login.mobile": { description: "Label for login button in mobile view", components: ["HeaderLogin"], et: "Sisene", @@ -148,7 +148,7 @@ export const translationsMap = { en: "Log out", ru: "Выйти", }, - "header.logout-small": { + "header.logout.mobile": { description: "Label for logout button (small)", components: ["HeaderLogout"], et: "Välju", From 90698dea01aad54f9cd19e854bddb7e1e1c71d71 Mon Sep 17 00:00:00 2001 From: Ly Tempel Date: Mon, 18 May 2026 12:19:01 +0300 Subject: [PATCH 4/4] feat(header): add CR fixes #312 --- .../header/header-login/header-login.component.spec.ts | 2 ++ .../header/header-profile/header-profile.component.html | 2 +- .../header/header-profile/header-profile.component.scss | 8 ++++++++ .../layout/header/header-role/header-role.component.ts | 7 +++---- .../sidenav/sidenav-toggle/sidenav-toggle.component.scss | 2 +- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tedi/components/layout/header/header-login/header-login.component.spec.ts b/tedi/components/layout/header/header-login/header-login.component.spec.ts index 4ff06888f..957a74cf2 100644 --- a/tedi/components/layout/header/header-login/header-login.component.spec.ts +++ b/tedi/components/layout/header/header-login/header-login.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { HeaderLoginComponent } from "./header-login.component"; import { BreakpointService } from "../../../../services/breakpoint/breakpoint.service"; import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; @Component({ standalone: true, @@ -42,6 +43,7 @@ describe("HeaderLoginComponent", () => { providers: [ { provide: BreakpointService, useValue: mockBreakpointService }, { provide: TediTranslationService, useValue: mockTranslationService }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, ], }).compileComponents(); diff --git a/tedi/components/layout/header/header-profile/header-profile.component.html b/tedi/components/layout/header/header-profile/header-profile.component.html index 3e4732217..b0ea647af 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.html +++ b/tedi/components/layout/header/header-profile/header-profile.component.html @@ -19,7 +19,7 @@ > - + diff --git a/tedi/components/layout/header/header-profile/header-profile.component.scss b/tedi/components/layout/header/header-profile/header-profile.component.scss index f0c9f6c54..dbf705ed3 100644 --- a/tedi/components/layout/header/header-profile/header-profile.component.scss +++ b/tedi/components/layout/header/header-profile/header-profile.component.scss @@ -87,5 +87,13 @@ tedi-header-profile { font-size: var(--icon-02); } } + + tedi-header-logout .tedi-header-logout__button tedi-icon { + font-size: var(--icon-02); + } } } + +.tedi-header-profile__popover tedi-header-logout tedi-icon { + font-size: var(--icon-02); +} diff --git a/tedi/components/layout/header/header-role/header-role.component.ts b/tedi/components/layout/header/header-role/header-role.component.ts index 7e4b913cc..536970314 100644 --- a/tedi/components/layout/header/header-role/header-role.component.ts +++ b/tedi/components/layout/header/header-role/header-role.component.ts @@ -1,5 +1,4 @@ import { NgFor, NgIf, NgTemplateOutlet } from "@angular/common"; -import { _IdGenerator } from "@angular/cdk/a11y"; import { ChangeDetectionStrategy, Component, @@ -55,6 +54,8 @@ export type Representative = { description?: string; }; +let nextHeaderRoleInputId = 0; + @Component({ selector: "tedi-header-role", standalone: true, @@ -117,9 +118,7 @@ export class HeaderRoleComponent { mobileOpen = signal(false); inputValue = signal(""); - protected readonly inputId = inject(_IdGenerator).getId( - "tedi-header-role-input-", - ); + protected readonly inputId = `tedi-header-role-input-${nextHeaderRoleInputId++}`; readonly breakpointService = inject(BreakpointService); readonly isTabletView = computed(() => diff --git a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss index 8211c8400..888ef6caa 100644 --- a/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss +++ b/tedi/components/layout/sidenav/sidenav-toggle/sidenav-toggle.component.scss @@ -12,7 +12,7 @@ border: 0; border-radius: 0; - &__icon { + &__icon.tedi-icon { color: var(--button-main-primary-text-default); }