From 391ea0f3c238bf526ac991a8c21a0e155b41092d Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Wed, 19 Nov 2025 08:54:53 +0200 Subject: [PATCH 1/4] feat(modal): add tedi ready modal #189 --- .../modal/footer/modal-footer.component.scss | 2 +- .../modal/header/modal-header.component.scss | 2 +- .../overlay/modal/modal.component.scss | 2 +- package-lock.json | 8 +- package.json | 2 +- tedi/components/overlay/index.ts | 3 +- tedi/components/overlay/modal/index.ts | 4 + .../modal-content.component.html | 1 + .../modal-content/modal-content.component.ts | 16 + .../modal-footer/modal-footer.component.ts | 15 + .../modal-header/modal-header.component.html | 7 + .../modal-header/modal-header.component.ts | 29 ++ .../overlay/modal/modal.component.html | 15 + .../overlay/modal/modal.component.scss | 129 ++++++ .../overlay/modal/modal.component.spec.ts | 98 +++++ .../overlay/modal/modal.component.ts | 118 ++++++ .../components/overlay/modal/modal.stories.ts | 375 ++++++++++++++++++ 17 files changed, 817 insertions(+), 9 deletions(-) create mode 100644 tedi/components/overlay/modal/index.ts create mode 100644 tedi/components/overlay/modal/modal-content/modal-content.component.html create mode 100644 tedi/components/overlay/modal/modal-content/modal-content.component.ts create mode 100644 tedi/components/overlay/modal/modal-footer/modal-footer.component.ts create mode 100644 tedi/components/overlay/modal/modal-header/modal-header.component.html create mode 100644 tedi/components/overlay/modal/modal-header/modal-header.component.ts create mode 100644 tedi/components/overlay/modal/modal.component.html create mode 100644 tedi/components/overlay/modal/modal.component.scss create mode 100644 tedi/components/overlay/modal/modal.component.spec.ts create mode 100644 tedi/components/overlay/modal/modal.component.ts create mode 100644 tedi/components/overlay/modal/modal.stories.ts diff --git a/community/components/overlay/modal/footer/modal-footer.component.scss b/community/components/overlay/modal/footer/modal-footer.component.scss index 44c1ca059..ae936e7d2 100644 --- a/community/components/overlay/modal/footer/modal-footer.component.scss +++ b/community/components/overlay/modal/footer/modal-footer.component.scss @@ -4,7 +4,7 @@ --_modal-footer-gap: 0.5rem; display: flex; gap: var(--_modal-footer-gap); - border-top: var(--_modal-border); + border-top: var(--borders-01) solid var(--modal-border-inner); padding: var(--_tedi-modal-footer-padding-y) var(--_tedi-modal-footer-padding-x); diff --git a/community/components/overlay/modal/header/modal-header.component.scss b/community/components/overlay/modal/header/modal-header.component.scss index a5adcacc2..29c7635a5 100644 --- a/community/components/overlay/modal/header/modal-header.component.scss +++ b/community/components/overlay/modal/header/modal-header.component.scss @@ -1,7 +1,7 @@ @use "@tedi-design-system/core/mixins"; .tedi-modal-header { - border-bottom: var(--_modal-border); + border-bottom: var(--borders-01) solid var(--modal-border-inner); padding: var(--_tedi-modal-heading-padding-y) var(--_tedi-modal-heading-padding-x); diff --git a/community/components/overlay/modal/modal.component.scss b/community/components/overlay/modal/modal.component.scss index 7db1b677a..ffd164257 100644 --- a/community/components/overlay/modal/modal.component.scss +++ b/community/components/overlay/modal/modal.component.scss @@ -27,7 +27,7 @@ $modal-breapoints: (xs, sm, md, lg, xl); } .tedi-modal { - --_modal-border: var(--borders-01) solid var(--modal-border); + --_modal-border: var(--borders-01) solid var(--modal-border-outer); --_modal-padding: var(--dimensions-13); overflow: auto; diff --git a/package-lock.json b/package-lock.json index d6b600d61..523739655 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": "^2.0.0" + "@tedi-design-system/core": "^2.3.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", @@ -9423,9 +9423,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-2.0.0.tgz", - "integrity": "sha512-ODuJgRoQYxyvs702iphOuRd68vTBpmIEe/ol4rFssSVDAZ+pZOCbfd87qvoEHW+iDWDlsxHJIOrzMTl3qsTHAQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-2.3.0.tgz", + "integrity": "sha512-mbx8V13nXzGkI/qOO54GtNt5IdIcPQBe9elx4lS1lCkJjSKHEmrxBSWXMX59KyY8oPjyMpQ0uXjVyK6XaMDK/Q==", "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" diff --git a/package.json b/package.json index d0436c693..461bd063f 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ngx-float-ui": "^19.0.1 || ^20.0.0" }, "dependencies": { - "@tedi-design-system/core": "^2.0.0" + "@tedi-design-system/core": "^2.3.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", diff --git a/tedi/components/overlay/index.ts b/tedi/components/overlay/index.ts index 7439217de..7b55370d8 100644 --- a/tedi/components/overlay/index.ts +++ b/tedi/components/overlay/index.ts @@ -1,2 +1,3 @@ +export * from "./modal"; export * from "./tooltip"; -export * from "./popover"; \ No newline at end of file +export * from "./popover"; diff --git a/tedi/components/overlay/modal/index.ts b/tedi/components/overlay/modal/index.ts new file mode 100644 index 000000000..c510b7b2b --- /dev/null +++ b/tedi/components/overlay/modal/index.ts @@ -0,0 +1,4 @@ +export * from "./modal.component"; +export * from "./modal-content/modal-content.component"; +export * from "./modal-footer/modal-footer.component"; +export * from "./modal-header/modal-header.component"; diff --git a/tedi/components/overlay/modal/modal-content/modal-content.component.html b/tedi/components/overlay/modal/modal-content/modal-content.component.html new file mode 100644 index 000000000..40b372640 --- /dev/null +++ b/tedi/components/overlay/modal/modal-content/modal-content.component.html @@ -0,0 +1 @@ + diff --git a/tedi/components/overlay/modal/modal-content/modal-content.component.ts b/tedi/components/overlay/modal/modal-content/modal-content.component.ts new file mode 100644 index 000000000..12679a1a3 --- /dev/null +++ b/tedi/components/overlay/modal/modal-content/modal-content.component.ts @@ -0,0 +1,16 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, +} from "@angular/core"; + +@Component({ + standalone: true, + selector: "tedi-modal-content", + imports: [], + templateUrl: "./modal-content.component.html", + styleUrl: "../modal.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalContentComponent {} diff --git a/tedi/components/overlay/modal/modal-footer/modal-footer.component.ts b/tedi/components/overlay/modal/modal-footer/modal-footer.component.ts new file mode 100644 index 000000000..89f2e9539 --- /dev/null +++ b/tedi/components/overlay/modal/modal-footer/modal-footer.component.ts @@ -0,0 +1,15 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, +} from "@angular/core"; + +@Component({ + standalone: true, + selector: "tedi-modal-footer", + template: "", + styleUrl: "../modal.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalFooterComponent {} diff --git a/tedi/components/overlay/modal/modal-header/modal-header.component.html b/tedi/components/overlay/modal/modal-header/modal-header.component.html new file mode 100644 index 000000000..6898a52dc --- /dev/null +++ b/tedi/components/overlay/modal/modal-header/modal-header.component.html @@ -0,0 +1,7 @@ +
+ + @if (showClose()) { + + } +
+ diff --git a/tedi/components/overlay/modal/modal-header/modal-header.component.ts b/tedi/components/overlay/modal/modal-header/modal-header.component.ts new file mode 100644 index 000000000..bd98567fa --- /dev/null +++ b/tedi/components/overlay/modal/modal-header/modal-header.component.ts @@ -0,0 +1,29 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + input, + inject, +} from "@angular/core"; +import { ClosingButtonComponent } from "../../../buttons/closing-button/closing-button.component"; +import { ModalComponent } from "../modal.component"; + +@Component({ + standalone: true, + selector: "tedi-modal-header", + imports: [ClosingButtonComponent], + templateUrl: "./modal-header.component.html", + styleUrl: "../modal.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModalHeaderComponent { + /** Should show closing button? */ + readonly showClose = input(true); + + private readonly modal = inject(ModalComponent); + + closeModal() { + this.modal.open.set(false); + } +} diff --git a/tedi/components/overlay/modal/modal.component.html b/tedi/components/overlay/modal/modal.component.html new file mode 100644 index 000000000..99e05205f --- /dev/null +++ b/tedi/components/overlay/modal/modal.component.html @@ -0,0 +1,15 @@ +@if (open()) { +
+ +} diff --git a/tedi/components/overlay/modal/modal.component.scss b/tedi/components/overlay/modal/modal.component.scss new file mode 100644 index 000000000..79d2c7a75 --- /dev/null +++ b/tedi/components/overlay/modal/modal.component.scss @@ -0,0 +1,129 @@ +@use "@tedi-design-system/core/typography"; + +@mixin modal-heading($size) { + .tedi-modal-header__head { + h1, + h2, + h3, + h4, + h5, + h6 { + @include typography.heading-styles($size); + } + } +} + +$modal-widths: (xs, sm, md, lg, xl); + +.tedi-modal { + position: fixed; + inset: 0; + display: none; + z-index: 1000; + + &--open { + display: block; + } + + &--default { + --_tedi-modal-heading-padding-x: var(--modal-heading-padding-x); + --_tedi-modal-heading-padding-y: var(--modal-heading-padding-y); + --_tedi-modal-body-padding: var(--modal-body-padding); + --_tedi-modal-footer-padding-x: var(--modal-footer-padding-x); + --_tedi-modal-footer-padding-y: var(--modal-footer-padding-y); + + @include modal-heading(h3); + } + + &--small { + --_tedi-modal-heading-padding-x: var(--modal-heading-padding-x-sm); + --_tedi-modal-heading-padding-y: var(--modal-heading-padding-y-sm); + --_tedi-modal-body-padding: var(--modal-body-padding-sm); + --_tedi-modal-footer-padding-x: var(--modal-footer-padding-x-sm); + --_tedi-modal-footer-padding-y: var(--modal-footer-padding-y-sm); + + @include modal-heading(h4); + } + + @each $width in $modal-widths { + &--#{$width} { + .tedi-modal__dialog { + max-width: var(--modal-max-width-#{$width}); + } + } + } + + &--center { + .tedi-modal__dialog { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-height: 95dvh; + } + } + + &--left { + .tedi-modal__dialog { + top: 0; + left: 0; + height: 100%; + } + } + + &--right { + .tedi-modal__dialog { + top: 0; + right: 0; + height: 100%; + } + } + + &__dialog { + position: fixed; + width: 100%; + display: flex; + flex-direction: column; + background-color: var(--modal-background); + border: var(--borders-01) solid var(--modal-border-outer); + border-radius: var(--modal-radius); + } + + &__backdrop { + position: fixed; + inset: 0; + background: var(--general-surface-overlay); + } + + tedi-modal-header { + border-bottom: var(--borders-01) solid var(--modal-border-inner); + padding: var(--_tedi-modal-heading-padding-y) + var(--_tedi-modal-heading-padding-x); + + .tedi-modal-header__head { + display: flex; + align-items: center; + gap: var(--layout-grid-gutters-08); + + button[tedi-closing-button] { + margin-left: auto; + } + } + } + + tedi-modal-content { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-16); + padding: var(--_tedi-modal-body-padding); + overflow-y: auto; + } + + tedi-modal-footer { + display: flex; + gap: var(--layout-grid-gutters-16); + + border-top: var(--borders-01) solid var(--modal-border-inner); + padding: var(--_tedi-modal-footer-padding-y) + var(--_tedi-modal-footer-padding-x); + } +} diff --git a/tedi/components/overlay/modal/modal.component.spec.ts b/tedi/components/overlay/modal/modal.component.spec.ts new file mode 100644 index 000000000..914fbd27c --- /dev/null +++ b/tedi/components/overlay/modal/modal.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ModalComponent } from "./modal.component"; +import { DOCUMENT } from "@angular/common"; + +describe("ModalComponent", () => { + let fixture: ComponentFixture; + let component: ModalComponent; + let hostEl: HTMLElement; + let documentRef: Document; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ModalComponent], + }); + + fixture = TestBed.createComponent(ModalComponent); + component = fixture.componentInstance; + hostEl = fixture.nativeElement; + documentRef = TestBed.inject(DOCUMENT); + + fixture.detectChanges(); + component.ngAfterViewInit(); + }); + + afterEach(() => { + component.ngOnDestroy(); + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should have default inputs", () => { + expect(component.size()).toBe("default"); + expect(component.width()).toBe("sm"); + expect(component.position()).toBe("center"); + expect(component.open()).toBe(false); + }); + + it("should apply correct default classes", () => { + const classes = hostEl.getAttribute("class")!; + expect(classes).toContain("tedi-modal--default"); + expect(classes).toContain("tedi-modal--sm"); + expect(classes).toContain("tedi-modal--center"); + expect(classes).not.toContain("tedi-modal--open"); + }); + + it("should add 'tedi-modal--open' class when opened", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + const classes = hostEl.getAttribute("class")!; + expect(classes).toContain("tedi-modal--open"); + }); + + it("should lock body scroll when opened", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + expect(documentRef.body.style.overflow).toBe("hidden"); + }); + + it("should restore body scroll when closed", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + fixture.componentRef.setInput("open", false); + fixture.detectChanges(); + + expect(documentRef.body.style.overflow).toBe(""); + }); + + it("should close modal on Escape key", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + const escEvent = new KeyboardEvent("keydown", { key: "Escape" }); + documentRef.dispatchEvent(escEvent); + + expect(component.open()).toBe(false); + }); + + it("should restore focus to previously focused element on close", () => { + const button = documentRef.createElement("button"); + documentRef.body.appendChild(button); + button.focus(); + + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + fixture.componentRef.setInput("open", false); + fixture.detectChanges(); + + expect(documentRef.activeElement).toBe(button); + + button.remove(); + }); +}); diff --git a/tedi/components/overlay/modal/modal.component.ts b/tedi/components/overlay/modal/modal.component.ts new file mode 100644 index 000000000..1342a75b4 --- /dev/null +++ b/tedi/components/overlay/modal/modal.component.ts @@ -0,0 +1,118 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + input, + computed, + model, + inject, + AfterViewInit, + ElementRef, + OnDestroy, + PLATFORM_ID, + effect, +} from "@angular/core"; +import { DOCUMENT, isPlatformBrowser } from "@angular/common"; +import { CdkTrapFocus } from "@angular/cdk/a11y"; + +export type ModalSize = "default" | "small"; +export type ModalWidth = "xs" | "sm" | "md" | "lg" | "xl"; +export type ModalPosition = "center" | "left" | "right"; + +@Component({ + standalone: true, + selector: "tedi-modal", + imports: [CdkTrapFocus], + templateUrl: "./modal.component.html", + styleUrl: "./modal.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "classes()", + }, +}) +export class ModalComponent implements AfterViewInit, OnDestroy { + /** Is modal open? */ + readonly open = model(false); + + /** Modal size */ + readonly size = input("default"); + + /** Modal width */ + readonly width = input("sm"); + + /** Position of the modal */ + readonly position = input("center"); + + private readonly document = inject(DOCUMENT); + private readonly host = inject>(ElementRef); + private readonly platformId = inject(PLATFORM_ID); + + private prevBodyOverflow: string = ""; + private prevFocusedElement: HTMLElement | null = null; + + readonly classes = computed(() => { + const classList = [ + "tedi-modal", + `tedi-modal--${this.size()}`, + `tedi-modal--${this.width()}`, + `tedi-modal--${this.position()}`, + ]; + + if (this.open()) { + classList.push("tedi-modal--open"); + } + + return classList.join(" "); + }); + + constructor() { + effect(() => { + if (!isPlatformBrowser(this.platformId)) return; + + if (this.open()) { + this.onOpen(); + } else { + this.onClose(); + } + }); + } + + ngAfterViewInit(): void { + if (!isPlatformBrowser(this.platformId)) return; + + this.document.body.appendChild(this.host.nativeElement); + } + + ngOnDestroy() { + if (!isPlatformBrowser(this.platformId)) return; + + const element = this.host.nativeElement; + if (element.parentNode) { + element.parentNode.removeChild(element); + } + } + + private onOpen() { + this.prevFocusedElement = this.document.activeElement as HTMLElement; + this.prevBodyOverflow = this.document.body.style.overflow; + this.document.body.style.overflow = "hidden"; + this.document.addEventListener("keydown", this.handleKeydown); + } + + private onClose() { + this.document.body.style.overflow = this.prevBodyOverflow; + + if (this.prevFocusedElement) { + this.prevFocusedElement.focus({ preventScroll: true }); + } + + this.document.removeEventListener("keydown", this.handleKeydown); + } + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.open.set(false); + } + }; +} diff --git a/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts new file mode 100644 index 000000000..9ef1cee4d --- /dev/null +++ b/tedi/components/overlay/modal/modal.stories.ts @@ -0,0 +1,375 @@ +import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; +import { ModalComponent } from "./modal.component"; +import { ModalHeaderComponent } from "./modal-header/modal-header.component"; +import { ModalContentComponent } from "./modal-content/modal-content.component"; +import { ModalFooterComponent } from "./modal-footer/modal-footer.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { LabelComponent } from "../../form/label/label.component"; +import { SelectComponent } from "community/components/form/select/select.component"; +import { SelectOptionComponent } from "community/components/form/select/select-option.component"; +import { IconComponent } from "../../base/icon/icon.component"; + +export default { + title: "TEDI-Ready/Components/Overlay/Modal", + component: ModalComponent, + decorators: [ + moduleMetadata({ + imports: [ + ModalComponent, + ModalHeaderComponent, + ModalContentComponent, + ModalFooterComponent, + ButtonComponent, + LabelComponent, + SelectComponent, + SelectOptionComponent, + IconComponent, + ], + }), + ], + argTypes: { + open: { + control: "boolean", + description: "Is modal open?", + table: { + category: "modal inputs", + type: { + summary: "boolean", + }, + defaultValue: { + summary: "false", + }, + }, + }, + size: { + control: "radio", + options: ["default", "small"], + description: "Modal size", + table: { + category: "modal inputs", + type: { + summary: "ModalSize", + detail: "default \nsmall", + }, + defaultValue: { + summary: "default", + }, + }, + }, + width: { + control: "radio", + options: ["xs", "sm", "md", "lg", "xl"], + description: "Modal width", + table: { + category: "modal inputs", + type: { + summary: "ModalWidth", + detail: "xs \nsm \nmd \nlg \nxl", + }, + defaultValue: { + summary: "sm", + }, + }, + }, + position: { + control: "radio", + options: ["center", "left", "right"], + description: "Position of the modal", + table: { + category: "modal inputs", + type: { + summary: "ModalPosition", + detail: "center \nleft \nright", + }, + defaultValue: { + summary: "center", + }, + }, + }, + showClose: { + control: "boolean", + description: "Should show closing button?", + table: { + category: "modal header inputs", + type: { + summary: "boolean", + }, + defaultValue: { + summary: "true", + }, + }, + }, + }, +} as Meta; + +type DefaultStory = StoryObj< + ModalComponent & { + showClose: boolean; + } +>; + +export const Default: DefaultStory = { + args: { + open: false, + size: "default", + width: "sm", + position: "center", + showClose: true, + }, + render: (args) => ({ + props: { + ...args, + options: [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, + { value: "4", label: "Option 4" }, + { value: "5", label: "Option 5" }, + ], + }, + template: ` + + + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + + +
+ `, + }), +}; + +export const Size: StoryObj = { + render: (args) => ({ + props: { + ...args, + openDefault: false, + openSmall: false, + options: [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, + { value: "4", label: "Option 4" }, + { value: "5", label: "Option 5" }, + ], + }, + template: ` +
+ + +
+ + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + + +
+ + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + + +
+ `, + }), +}; + +export const FooterVariants: StoryObj = { + render: (args) => ({ + props: { + ...args, + openDefault: false, + openLeftRight: false, + openThreeButtons: false, + openNoFooter: false, + options: [ + { value: "1", label: "Option 1" }, + { value: "2", label: "Option 2" }, + { value: "3", label: "Option 3" }, + { value: "4", label: "Option 4" }, + { value: "5", label: "Option 5" }, + ], + }, + template: ` +
+ + + + +
+ + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + + +
+ + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + + +
+ + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+ + +
+ + +
+
+
+ + +

Title

+
+ +
+ + + @for (option of options; track option.value) { + + } + +
+
+ + + @for (option of options; track option.value) { + + } + +
+
+
+ `, + }), +}; From 2e0e9aa4f95b614ea0e85fb079bb6f520a714e55 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Thu, 20 Nov 2025 09:43:54 +0200 Subject: [PATCH 2/4] feat(modal): add tests #189 --- .../modal-header.component.spec.ts | 85 +++++++++++++++++++ .../overlay/modal/modal.component.spec.ts | 40 +++++++++ .../overlay/modal/modal.component.ts | 3 + 3 files changed, 128 insertions(+) create mode 100644 tedi/components/overlay/modal/modal-header/modal-header.component.spec.ts diff --git a/tedi/components/overlay/modal/modal-header/modal-header.component.spec.ts b/tedi/components/overlay/modal/modal-header/modal-header.component.spec.ts new file mode 100644 index 000000000..06072a375 --- /dev/null +++ b/tedi/components/overlay/modal/modal-header/modal-header.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; +import { ModalHeaderComponent } from "./modal-header.component"; +import { ModalComponent } from "../modal.component"; +import { viewChild } from "@angular/core"; + +class MockModalComponent { + open = { + value: false, + set: jest.fn(function (val: boolean) { + this.value = val; + }), + }; +} + +@Component({ + standalone: true, + imports: [ModalHeaderComponent], + template: ` + + Header Content + + `, +}) +class TestHostComponent { + showClose = true; + header = viewChild.required(ModalHeaderComponent); +} + +describe("ModalHeaderComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let component: ModalHeaderComponent; + let modal: MockModalComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [{ provide: ModalComponent, useClass: MockModalComponent }], + }); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + host = fixture.componentInstance; + component = host.header(); + modal = TestBed.inject(ModalComponent) as unknown as MockModalComponent; + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should show close button when showClose = true", () => { + const btn = fixture.nativeElement.querySelector("button"); + expect(btn).not.toBeNull(); + }); + + it("should hide close button when showClose = false", () => { + host.showClose = false; + fixture.detectChanges(); + + const btn = fixture.nativeElement.querySelector("button"); + expect(btn).toBeNull(); + }); + + it("should close modal when close button is clicked", () => { + const btn = fixture.nativeElement.querySelector("button")!; + modal.open.value = true; + + btn.click(); + fixture.detectChanges(); + + expect(modal.open.set).toHaveBeenCalledWith(false); + }); + + it("should close modal via closeModal() method", () => { + modal.open.value = true; + + component.closeModal(); + fixture.detectChanges(); + + expect(modal.open.set).toHaveBeenCalledWith(false); + }); +}); diff --git a/tedi/components/overlay/modal/modal.component.spec.ts b/tedi/components/overlay/modal/modal.component.spec.ts index 914fbd27c..72adbe1b9 100644 --- a/tedi/components/overlay/modal/modal.component.spec.ts +++ b/tedi/components/overlay/modal/modal.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ModalComponent } from "./modal.component"; import { DOCUMENT } from "@angular/common"; +import { PLATFORM_ID } from "@angular/core"; describe("ModalComponent", () => { let fixture: ComponentFixture; @@ -96,3 +97,42 @@ describe("ModalComponent", () => { button.remove(); }); }); + +describe("ModalComponent (server platform)", () => { + let fixture: ComponentFixture; + let component: ModalComponent; + let documentRef: Document; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ModalComponent], + providers: [{ provide: PLATFORM_ID, useValue: "server" }], + }); + + fixture = TestBed.createComponent(ModalComponent); + component = fixture.componentInstance; + documentRef = TestBed.inject(DOCUMENT); + + fixture.detectChanges(); + }); + + it("should NOT run browser-only effect in constructor", () => { + expect(documentRef.body.style.overflow).toBe(""); + }); + + it("should NOT append modal to body in ngAfterViewInit on server", () => { + const initialChildren = documentRef.body.childElementCount; + component.ngAfterViewInit(); + expect(documentRef.body.childElementCount).toBe(initialChildren); + }); + + it("should NOT remove element from body in ngOnDestroy on server", () => { + const el = fixture.nativeElement; + documentRef.body.appendChild(el); + + const initialChildren = documentRef.body.childElementCount; + component.ngOnDestroy(); + expect(documentRef.body.childElementCount).toBe(initialChildren); + el.remove(); + }); +}); diff --git a/tedi/components/overlay/modal/modal.component.ts b/tedi/components/overlay/modal/modal.component.ts index 1342a75b4..41c36fb09 100644 --- a/tedi/components/overlay/modal/modal.component.ts +++ b/tedi/components/overlay/modal/modal.component.ts @@ -88,9 +88,12 @@ export class ModalComponent implements AfterViewInit, OnDestroy { if (!isPlatformBrowser(this.platformId)) return; const element = this.host.nativeElement; + if (element.parentNode) { element.parentNode.removeChild(element); } + + this.document.removeEventListener("keydown", this.handleKeydown); } private onOpen() { From 70df5a682f3497c36f7d7571dd2afe2641f51d94 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Thu, 20 Nov 2025 10:02:31 +0200 Subject: [PATCH 3/4] feat(modal): update stories #189 --- .../components/overlay/modal/modal.stories.ts | 5 ++++ .../components/overlay/modal/modal.stories.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/community/components/overlay/modal/modal.stories.ts b/community/components/overlay/modal/modal.stories.ts index 9bee2a71b..c4236c4bf 100644 --- a/community/components/overlay/modal/modal.stories.ts +++ b/community/components/overlay/modal/modal.stories.ts @@ -114,6 +114,11 @@ const meta: Meta = { imports: [ModalOpenComponent, StorybookModalComponent], }), ], + parameters: { + status: { + type: ["existsInTediReady"], + }, + }, argTypes: { maxWidth: { control: { diff --git a/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts index 9ef1cee4d..e8e91dd7f 100644 --- a/tedi/components/overlay/modal/modal.stories.ts +++ b/tedi/components/overlay/modal/modal.stories.ts @@ -9,6 +9,30 @@ import { SelectComponent } from "community/components/form/select/select.compone import { SelectOptionComponent } from "community/components/form/select/select-option.component"; import { IconComponent } from "../../base/icon/icon.component"; +/** + * Figma ↗
+ * Zeroheight ↗ + * + * --- + * + * The modal can be opened or closed using the `open` input (set it to `true` or `false`). + * You can also control it programmatically using `viewChild`: + * + * ```ts + * modal = viewChild(ModalComponent); + * + * toggleModal() { + * this.modal.open.update(prev => !prev); + * } + * ``` + * + * The modal layout is composed of the following subcomponents: + * + * - ModalHeaderComponent + * - ModalContentComponent + * - ModalFooterComponent + */ + export default { title: "TEDI-Ready/Components/Overlay/Modal", component: ModalComponent, From ef9b99c109c87c64c8b2bbac8c485a25c0869947 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Mon, 24 Nov 2025 09:05:30 +0200 Subject: [PATCH 4/4] feat(modal): update tedi core version #189 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 523739655..71aeed9fb 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": "^2.3.0" + "@tedi-design-system/core": "^2.4.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15", @@ -9423,9 +9423,9 @@ } }, "node_modules/@tedi-design-system/core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-2.3.0.tgz", - "integrity": "sha512-mbx8V13nXzGkI/qOO54GtNt5IdIcPQBe9elx4lS1lCkJjSKHEmrxBSWXMX59KyY8oPjyMpQ0uXjVyK6XaMDK/Q==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@tedi-design-system/core/-/core-2.4.0.tgz", + "integrity": "sha512-tLr2Yf/LwGDCBnaqO/Ar2XEYPpZkcBC/K42hxHReN+EY4BbQyzcbU1W8egQJlgfvHjaKSxXSsnZ8SNC0PMe3vA==", "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" diff --git a/package.json b/package.json index 461bd063f..384f6cf20 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ngx-float-ui": "^19.0.1 || ^20.0.0" }, "dependencies": { - "@tedi-design-system/core": "^2.3.0" + "@tedi-design-system/core": "^2.4.0" }, "devDependencies": { "@angular-devkit/core": "19.2.15",