diff --git a/.stylelintrc.json b/.stylelintrc.json index 1b294f597..9bf4fccb9 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -6,7 +6,7 @@ ], "rules": { "selector-class-pattern": [ - "^(tedi-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*|float-ui-[a-z]+(?:-[a-z]+)*)$", + "^((tedi|cdk)-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*|float-ui-[a-z]+(?:-[a-z]+)*)$", { "message": "Class selector must start with 'tedi-' prefix and follow BEM naming (e.g., .tedi-button, .tedi-button__icon, .tedi-button--primary). Selector: \"%s\"", "resolveNestedSelectors": true diff --git a/jest-jsdom-env.ts b/jest-jsdom-env.ts new file mode 100644 index 000000000..75bb564c7 --- /dev/null +++ b/jest-jsdom-env.ts @@ -0,0 +1,25 @@ +import JestEnvironment from "jest-preset-angular/environments/jest-jsdom-env"; +import type { JestEnvironmentConfig, EnvironmentContext } from "@jest/environment"; + +/** + * Custom jest environment that suppresses jsdom "Could not parse CSS stylesheet" + * errors. These occur because jsdom <22 does not support CSS @layer rules used + * by Angular CDK overlay styles. + */ +export default class PatchedJsdomEnvironment extends JestEnvironment { + constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { + const originalError = context.console.error.bind(context.console); + context.console.error = (...args: Parameters) => { + const first = args[0]; + if ( + first instanceof Error && + first.message === "Could not parse CSS stylesheet" + ) { + return; + } + originalError(...args); + }; + + super(config, context); + } +} diff --git a/jest.config.ts b/jest.config.ts index 80a5af5df..5ef6b469a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -12,7 +12,7 @@ export default { testPathIgnorePatterns: ["/node_modules/", "/dist/"], moduleFileExtensions: ["ts", "html", "js", "json"], resolver: "jest-preset-angular/build/resolvers/ng-jest-resolver.js", - testEnvironment: "jsdom", + testEnvironment: "/jest-jsdom-env.ts", collectCoverage: true, collectCoverageFrom: ["./tedi/components/**/*.{js,ts,tsx}"], coveragePathIgnorePatterns: ["\\.stories\\.ts$"], diff --git a/tedi/components/helpers/index.ts b/tedi/components/helpers/index.ts index fd3d12ef3..4911cf597 100644 --- a/tedi/components/helpers/index.ts +++ b/tedi/components/helpers/index.ts @@ -1,3 +1,4 @@ export * from "./grid"; +export * from "./scroll-fade"; export * from "./separator/separator.component"; export * from "./timeline"; diff --git a/tedi/components/helpers/scroll-fade/index.ts b/tedi/components/helpers/scroll-fade/index.ts new file mode 100644 index 000000000..73c11eabe --- /dev/null +++ b/tedi/components/helpers/scroll-fade/index.ts @@ -0,0 +1,2 @@ +export { ScrollFadeComponent } from "./scroll-fade.component"; +export type { ScrollFadeSize, ScrollFadePosition, ScrollFadeScrollbar } from "./scroll-fade.component"; diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.component.html b/tedi/components/helpers/scroll-fade/scroll-fade.component.html new file mode 100644 index 000000000..1fb437d00 --- /dev/null +++ b/tedi/components/helpers/scroll-fade/scroll-fade.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.component.scss b/tedi/components/helpers/scroll-fade/scroll-fade.component.scss new file mode 100644 index 000000000..4891af7da --- /dev/null +++ b/tedi/components/helpers/scroll-fade/scroll-fade.component.scss @@ -0,0 +1,56 @@ +$percentages: (0, 10, 20); + +.tedi-scroll-fade { + --_tedi-scroll-fade-top: 0%; + --_tedi-scroll-fade-bottom: 0%; + + position: relative; + display: flex; + flex-direction: column; + max-height: inherit; + overflow: hidden; + + &__inner { + flex: 1; + min-height: 0; + max-height: inherit; + overflow: auto; + mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--_tedi-scroll-fade-top), + black calc(100% - var(--_tedi-scroll-fade-bottom)), + transparent 100% + ); + + &--custom-scroll { + &::-webkit-scrollbar { + width: 6px; + background-color: var(--general-surface-primary); + } + + &::-webkit-scrollbar-thumb { + background: var(--general-border-primary); + border-radius: 100px; + + &:hover { + background-color: var(--general-border-secondary); + } + } + + &::-webkit-scrollbar-track { + background-color: var(--general-surface-primary); + } + } + } + + @each $percentage in $percentages { + &--top-#{$percentage} { + --_tedi-scroll-fade-top: calc(#{$percentage} * 1%); + } + + &--bottom-#{$percentage} { + --_tedi-scroll-fade-bottom: calc(#{$percentage} * 1%); + } + } +} diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.component.spec.ts b/tedi/components/helpers/scroll-fade/scroll-fade.component.spec.ts new file mode 100644 index 000000000..63911046d --- /dev/null +++ b/tedi/components/helpers/scroll-fade/scroll-fade.component.spec.ts @@ -0,0 +1,185 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component } from "@angular/core"; +import { ScrollFadeComponent } from "./scroll-fade.component"; + +@Component({ + standalone: true, + imports: [ScrollFadeComponent], + template: ` + +
Content
+
+ `, +}) +class TestHostComponent { + fadeSize: 0 | 10 | 20 = 20; + fadePosition: "top" | "bottom" | "both" = "both"; + scrollBar: "default" | "custom" = "custom"; + contentHeight = 100; + onScrolledToTop = jest.fn(); + onScrolledToBottom = jest.fn(); +} + +describe("ScrollFadeComponent", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let scrollFadeEl: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + scrollFadeEl = fixture.nativeElement.querySelector("tedi-scroll-fade"); + }); + + it("should render with default classes", () => { + expect(scrollFadeEl.classList.contains("tedi-scroll-fade")).toBe(true); + }); + + it("should render children content", () => { + expect(scrollFadeEl.textContent).toContain("Content"); + }); + + it("should have inner wrapper element", () => { + const inner = scrollFadeEl.querySelector(".tedi-scroll-fade__inner"); + expect(inner).toBeTruthy(); + }); + + it("should apply custom scrollbar class by default", () => { + const inner = scrollFadeEl.querySelector(".tedi-scroll-fade__inner"); + expect(inner?.classList.contains("tedi-scroll-fade__inner--custom-scroll")).toBe(true); + }); + + it("should not apply custom scrollbar class when scrollBar is default", () => { + host.scrollBar = "default"; + fixture.detectChanges(); + const inner = scrollFadeEl.querySelector(".tedi-scroll-fade__inner"); + expect(inner?.classList.contains("tedi-scroll-fade__inner--custom-scroll")).toBe(false); + }); + + it("should not show fade when content does not overflow", () => { + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(false); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(false); + }); + + it("should respect fadePosition top — never adds bottom fade class", () => { + host.fadePosition = "top"; + fixture.detectChanges(); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(false); + }); + + it("should respect fadePosition bottom — never adds top fade class", () => { + host.fadePosition = "bottom"; + fixture.detectChanges(); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(false); + }); + + it("should use fadeSize 10 in class names", () => { + host.fadeSize = 10; + fixture.detectChanges(); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-10")).toBe(false); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-10")).toBe(false); + }); + + it("should use fadeSize 0 in class names", () => { + host.fadeSize = 0; + fixture.detectChanges(); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-0")).toBe(false); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-0")).toBe(false); + }); + + describe("with overflowing content", () => { + let innerEl: HTMLElement; + + beforeEach(() => { + innerEl = scrollFadeEl.querySelector(".tedi-scroll-fade__inner")!; + + Object.defineProperty(innerEl, "scrollHeight", { value: 500, configurable: true }); + Object.defineProperty(innerEl, "clientHeight", { value: 200, configurable: true }); + Object.defineProperty(innerEl, "scrollTop", { value: 0, writable: true, configurable: true }); + }); + + it("should show bottom fade when content overflows and scrolled to top", () => { + innerEl.dispatchEvent(new Event("scroll")); + fixture.detectChanges(); + + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(false); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(true); + }); + + it("should show both fades when scrolled to middle", () => { + Object.defineProperty(innerEl, "scrollTop", { value: 100, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + fixture.detectChanges(); + + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(true); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(true); + }); + + it("should show only top fade when scrolled to bottom", () => { + Object.defineProperty(innerEl, "scrollTop", { value: 300, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + fixture.detectChanges(); + + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(true); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(false); + }); + + it("should emit scrolledToTop when at top", () => { + innerEl.dispatchEvent(new Event("scroll")); + expect(host.onScrolledToTop).toHaveBeenCalled(); + }); + + it("should emit scrolledToBottom when at bottom", () => { + Object.defineProperty(innerEl, "scrollTop", { value: 300, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + expect(host.onScrolledToBottom).toHaveBeenCalled(); + }); + + it("should not emit scrolledToTop when in middle", () => { + host.onScrolledToTop.mockClear(); + Object.defineProperty(innerEl, "scrollTop", { value: 100, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + expect(host.onScrolledToTop).not.toHaveBeenCalled(); + }); + + it("should not emit scrolledToBottom when in middle", () => { + host.onScrolledToBottom.mockClear(); + Object.defineProperty(innerEl, "scrollTop", { value: 100, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + expect(host.onScrolledToBottom).not.toHaveBeenCalled(); + }); + + it("should only show top fade when fadePosition is top and scrolled to middle", () => { + host.fadePosition = "top"; + fixture.detectChanges(); + Object.defineProperty(innerEl, "scrollTop", { value: 100, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + fixture.detectChanges(); + + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(true); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(false); + }); + + it("should only show bottom fade when fadePosition is bottom and scrolled to middle", () => { + host.fadePosition = "bottom"; + fixture.detectChanges(); + Object.defineProperty(innerEl, "scrollTop", { value: 100, configurable: true }); + innerEl.dispatchEvent(new Event("scroll")); + fixture.detectChanges(); + + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--top-20")).toBe(false); + expect(scrollFadeEl.classList.contains("tedi-scroll-fade--bottom-20")).toBe(true); + }); + }); +}); diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.component.ts b/tedi/components/helpers/scroll-fade/scroll-fade.component.ts new file mode 100644 index 000000000..66c23eab2 --- /dev/null +++ b/tedi/components/helpers/scroll-fade/scroll-fade.component.ts @@ -0,0 +1,100 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + ViewEncapsulation, + computed, + input, + output, + signal, + viewChild, +} from "@angular/core"; + +export type ScrollFadeSize = 0 | 10 | 20; +export type ScrollFadePosition = "top" | "bottom" | "both"; +export type ScrollFadeScrollbar = "default" | "custom"; + +@Component({ + standalone: true, + selector: "tedi-scroll-fade", + templateUrl: "./scroll-fade.component.html", + styleUrl: "./scroll-fade.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "classes()", + }, +}) +export class ScrollFadeComponent implements AfterViewInit { + /** Size of the fade gradient in percentages. */ + readonly fadeSize = input(20); + + /** Which edges show the fade. */ + readonly fadePosition = input("both"); + + /** Scrollbar style. */ + readonly scrollBar = input("custom"); + + /** Emitted when scrolled to the top. */ + readonly scrolledToTop = output(); + + /** Emitted when scrolled to the bottom. */ + readonly scrolledToBottom = output(); + + private readonly innerRef = viewChild.required>("inner"); + + private readonly fade = signal({ top: false, bottom: false }); + + readonly classes = computed(() => { + const classList = ["tedi-scroll-fade"]; + const { top, bottom } = this.fade(); + const pos = this.fadePosition(); + const size = this.fadeSize(); + + if (top && (pos === "both" || pos === "top")) { + classList.push(`tedi-scroll-fade--top-${size}`); + } + + if (bottom && (pos === "both" || pos === "bottom")) { + classList.push(`tedi-scroll-fade--bottom-${size}`); + } + + return classList.join(" "); + }); + + readonly innerClasses = computed(() => { + const classList = ["tedi-scroll-fade__inner"]; + + if (this.scrollBar() === "custom") { + classList.push("tedi-scroll-fade__inner--custom-scroll"); + } + + return classList.join(" "); + }); + + onScroll(): void { + const el = this.innerRef().nativeElement; + this.updateFade(el.scrollTop, el.scrollHeight, el.clientHeight); + } + + ngAfterViewInit(): void { + const el = this.innerRef().nativeElement; + this.updateFade(el.scrollTop, el.scrollHeight, el.clientHeight); + } + + private updateFade(scrollTop: number, scrollHeight: number, clientHeight: number): void { + const atTop = scrollTop === 0; + const atBottom = Math.abs(scrollHeight - scrollTop - clientHeight) <= 1; + + if (atTop) { + this.scrolledToTop.emit(); + } + + if (atBottom) { + this.scrolledToBottom.emit(); + } + + this.fade.set({ top: !atTop, bottom: !atBottom }); + } +} diff --git a/tedi/components/helpers/scroll-fade/scroll-fade.stories.ts b/tedi/components/helpers/scroll-fade/scroll-fade.stories.ts new file mode 100644 index 000000000..90f91826b --- /dev/null +++ b/tedi/components/helpers/scroll-fade/scroll-fade.stories.ts @@ -0,0 +1,145 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { ScrollFadeComponent } from "./scroll-fade.component"; +import { ColComponent } from "../grid/col/col.component"; +import { RowComponent } from "../grid/row/row.component"; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +const loremIpsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris Lorem ipsum dolor sit amet, + consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`; + +export default { + title: "TEDI-Ready/Components/Helpers/ScrollFade", + component: ScrollFadeComponent, + decorators: [ + moduleMetadata({ + imports: [ScrollFadeComponent, RowComponent, ColComponent], + }), + ], + parameters: { + status: { + type: ["devComponent"], + }, + design: { + type: "figma", + url: "https://www.figma.com/design/jWiRIXhHRxwVdMSimKX2FF/TEDI-READY-(work-in-progress)?node-id=10758-111142&m=dev", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: { ...args, content: loremIpsum }, + template: ` +
+ + {{ content }} + +
+ `, + }), + args: { + scrollBar: "custom", + fadeSize: 20, + fadePosition: "both", + }, +}; + +export const Scrollbar: Story = { + render: (args) => ({ + props: { ...args, content: loremIpsum }, + template: ` + + + Default Scrollbar +
+ {{ content }} +
+
+ + Custom Scrollbar +
+ {{ content }} +
+
+
+ `, + }), +}; + +export const FadeSize: Story = { + render: (args) => ({ + props: { ...args, content: loremIpsum }, + template: ` + + + No Fade (0%) +
+ {{ content }} +
+
+ + Small Fade (10%) +
+ {{ content }} +
+
+ + Large Fade (20%) +
+ {{ content }} +
+
+
+ `, + }), +}; + +export const FadePosition: Story = { + render: (args) => ({ + props: { ...args, content: loremIpsum }, + template: ` + + + Top +
+ {{ content }} +
+
+ + Bottom +
+ {{ content }} +
+
+ + Both +
+ {{ content }} +
+
+
+ `, + }), +}; + +export const NoFadeWithoutScrollbar: Story = { + render: (args) => ({ + props: { ...args, content: loremIpsum }, + template: ` + + +
+ {{ content }} +
+
+
+ `, + }), +}; diff --git a/tedi/components/overlay/modal/index.ts b/tedi/components/overlay/modal/index.ts index c510b7b2b..98114273d 100644 --- a/tedi/components/overlay/modal/index.ts +++ b/tedi/components/overlay/modal/index.ts @@ -1,4 +1,7 @@ export * from "./modal.component"; +export * from "./modal.types"; +export * from "./modal-ref"; +export * from "./modal.service"; 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.ts b/tedi/components/overlay/modal/modal-content/modal-content.component.ts index 12679a1a3..46f52100a 100644 --- a/tedi/components/overlay/modal/modal-content/modal-content.component.ts +++ b/tedi/components/overlay/modal/modal-content/modal-content.component.ts @@ -12,5 +12,8 @@ import { styleUrl: "../modal.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-modal-content", + }, }) 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 index 89f2e9539..4d9addf0c 100644 --- a/tedi/components/overlay/modal/modal-footer/modal-footer.component.ts +++ b/tedi/components/overlay/modal/modal-footer/modal-footer.component.ts @@ -11,5 +11,8 @@ import { styleUrl: "../modal.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-modal-footer", + }, }) 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 index 6898a52dc..47dddf471 100644 --- a/tedi/components/overlay/modal/modal-header/modal-header.component.html +++ b/tedi/components/overlay/modal/modal-header/modal-header.component.html @@ -4,4 +4,5 @@ } + 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 index 289903890..d4b2bbb01 100644 --- a/tedi/components/overlay/modal/modal-header/modal-header.component.spec.ts +++ b/tedi/components/overlay/modal/modal-header/modal-header.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { Component } from "@angular/core"; import { ModalHeaderComponent } from "./modal-header.component"; import { ModalComponent } from "../modal.component"; +import { ModalRef } from "../modal-ref"; import { viewChild } from "@angular/core"; import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; @@ -87,3 +88,34 @@ describe("ModalHeaderComponent", () => { expect(modal.open.set).toHaveBeenCalledWith(false); }); }); + +describe("ModalHeaderComponent (service mode)", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let component: ModalHeaderComponent; + let mockModalRef: { close: jest.Mock }; + + beforeEach(() => { + mockModalRef = { close: jest.fn() }; + + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: ModalComponent, useValue: null }, + { provide: ModalRef, useValue: mockModalRef }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + host = fixture.componentInstance; + component = host.header(); + }); + + it("should close via ModalRef when in service mode", () => { + component.closeModal(); + expect(mockModalRef.close).toHaveBeenCalled(); + }); +}); diff --git a/tedi/components/overlay/modal/modal-header/modal-header.component.ts b/tedi/components/overlay/modal/modal-header/modal-header.component.ts index bd98567fa..e7ecb1fa4 100644 --- a/tedi/components/overlay/modal/modal-header/modal-header.component.ts +++ b/tedi/components/overlay/modal/modal-header/modal-header.component.ts @@ -7,6 +7,7 @@ import { } from "@angular/core"; import { ClosingButtonComponent } from "../../../buttons/closing-button/closing-button.component"; import { ModalComponent } from "../modal.component"; +import { ModalRef } from "../modal-ref"; @Component({ standalone: true, @@ -16,14 +17,25 @@ import { ModalComponent } from "../modal.component"; styleUrl: "../modal.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: "tedi-modal-header", + }, }) export class ModalHeaderComponent { /** Should show closing button? */ readonly showClose = input(true); - private readonly modal = inject(ModalComponent); + /** @deprecated Injected when used inside the old template-based tedi-modal. */ + private readonly modal = inject(ModalComponent, { optional: true }); + + /** Injected when opened via ModalService. */ + private readonly modalRef = inject(ModalRef, { optional: true }); closeModal() { - this.modal.open.set(false); + if (this.modalRef) { + this.modalRef.close(); + } else if (this.modal) { + this.modal.open.set(false); + } } } diff --git a/tedi/components/overlay/modal/modal-ref.ts b/tedi/components/overlay/modal/modal-ref.ts new file mode 100644 index 000000000..e6912a0d4 --- /dev/null +++ b/tedi/components/overlay/modal/modal-ref.ts @@ -0,0 +1,36 @@ +import { Observable } from "rxjs"; +import { DialogRef } from "@angular/cdk/dialog"; + +/** + * Reference to a modal opened via `ModalService`. + * Provides methods to close the modal and observe its lifecycle. + */ +export class ModalRef { + constructor(private readonly dialogRef: DialogRef) {} + + /** Close the modal, optionally returning a result. */ + close(result?: R): void { + this.dialogRef.close(result); + } + + /** Observable that emits when the modal is closed, with the optional result value. */ + get closed(): Observable { + return this.dialogRef.closed; + } + + /** Observable that emits when the backdrop is clicked. */ + get backdropClick(): Observable { + return this.dialogRef.backdropClick; + } + + /** Observable that emits on keyboard events within the modal. */ + get keydownEvents(): Observable { + return this.dialogRef.keydownEvents; + } + + /** Update the modal's width and height. */ + updateSize(width?: string, height?: string): this { + this.dialogRef.updateSize(width, height); + return this; + } +} diff --git a/tedi/components/overlay/modal/modal.component.html b/tedi/components/overlay/modal/modal.component.html index 99e05205f..1df2e4959 100644 --- a/tedi/components/overlay/modal/modal.component.html +++ b/tedi/components/overlay/modal/modal.component.html @@ -1,15 +1,17 @@ -@if (open()) { -
- +@if (!serviceMode && open()) { +
} +
+ + + +
diff --git a/tedi/components/overlay/modal/modal.component.scss b/tedi/components/overlay/modal/modal.component.scss index 649ed9772..5a460e734 100644 --- a/tedi/components/overlay/modal/modal.component.scss +++ b/tedi/components/overlay/modal/modal.component.scss @@ -1,4 +1,5 @@ @use "@tedi-design-system/core/typography"; +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; @mixin modal-heading($size) { .tedi-modal-header__head { @@ -14,6 +15,94 @@ } } +@mixin modal-size-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); + --_tedi-modal-footer-gap: var(--button-gutter-x); + + @include modal-heading(h3); + + button[tedi-closing-button] { + width: var(--button-sm-icon-size); + height: var(--button-sm-icon-size); + } +} + +@mixin modal-size-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); + --_tedi-modal-footer-gap: var(--button-gutter-x-sm); + + @include modal-heading(h4); + + button[tedi-closing-button] { + width: var(--button-xs-icon-size); + height: var(--button-xs-icon-size); + } +} + +@mixin modal-dialog-box { + display: flex; + flex-direction: column; + width: 100%; + background-color: var(--modal-background); + border: var(--borders-01) solid var(--modal-border-outer); + border-radius: var(--modal-radius); +} + +@mixin modal-content-styles { + .tedi-modal-header { + padding: var(--_tedi-modal-heading-padding-y) var(--_tedi-modal-heading-padding-x); + border-bottom: var(--borders-01) solid var(--modal-border-inner); + + .tedi-modal-header__head { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + + button[tedi-closing-button] { + margin-left: auto; + } + } + + [tedi-modal-description] { + margin-top: var(--layout-grid-gutters-08); + } + } + + .tedi-modal-content { + display: flex; + flex: 1; + flex-direction: column; + gap: var(--layout-grid-gutters-16); + padding: var(--_tedi-modal-body-padding); + overflow-y: auto; + + &:has(.tedi-scroll-fade) { + overflow: hidden; + } + + .tedi-scroll-fade { + flex: 1; + min-height: 0; + } + } + + .tedi-modal-footer { + display: flex; + gap: var(--_tedi-modal-footer-gap); + justify-content: flex-end; + padding: var(--_tedi-modal-footer-padding-y) var(--_tedi-modal-footer-padding-x); + border-top: var(--borders-01) solid var(--modal-border-inner); + } +} + $modal-widths: ( xs, sm, @@ -22,7 +111,14 @@ $modal-widths: ( xl ); +// ============================================================================= +// Legacy template-based modal (deprecated) +// ============================================================================= + .tedi-modal { + --_tedi-modal-max-width: 95vw; + --_tedi-modal-max-height: 95dvh; + position: fixed; inset: 0; z-index: var(--z-index-modal); @@ -33,29 +129,17 @@ $modal-widths: ( } &--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); + @include modal-size-default; } &--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); + @include modal-size-small; } @each $width in $modal-widths { &--#{$width} { .tedi-modal__dialog { - max-width: var(--modal-max-width-#{$width}); + max-width: min(var(--modal-max-width-#{$width}), var(--_tedi-modal-max-width)); } } } @@ -64,16 +148,25 @@ $modal-widths: ( .tedi-modal__dialog { top: 50%; left: 50%; - max-height: 95dvh; + max-height: var(--_tedi-modal-max-height); transform: translate(-50%, -50%); } + + &.tedi-modal--top { + .tedi-modal__dialog { + top: var(--modal-top-margin); + transform: translateX(-50%); + } + } } &--left { .tedi-modal__dialog { top: 0; left: 0; - height: 100%; + min-height: 100dvh; + border-radius: 0; + animation: tedi-modal-slide-left 200ms ease-out; } } @@ -81,18 +174,25 @@ $modal-widths: ( .tedi-modal__dialog { top: 0; right: 0; - height: 100%; + min-height: 100dvh; + border-radius: 0; + animation: tedi-modal-slide-right 200ms ease-out; } } - &__dialog { - position: fixed; + &--service { + position: static; + inset: auto; + z-index: auto; display: flex; flex-direction: column; - width: 100%; - background-color: var(--modal-background); - border: var(--borders-01) solid var(--modal-border-outer); - border-radius: var(--modal-radius); + } + + &__dialog { + position: fixed; + max-width: var(--_tedi-modal-max-width); + + @include modal-dialog-box; } &__backdrop { @@ -101,34 +201,167 @@ $modal-widths: ( background: var(--general-surface-overlay); } - tedi-modal-header { - padding: var(--_tedi-modal-heading-padding-y) var(--_tedi-modal-heading-padding-x); - border-bottom: var(--borders-01) solid var(--modal-border-inner); + @include modal-content-styles; +} - .tedi-modal-header__head { +// ============================================================================= +// CDK Dialog-based modal +// ============================================================================= + +.cdk-overlay-container { + z-index: var(--z-index-modal); +} + +.tedi-modal-backdrop { + background: var(--general-surface-overlay); +} + +.tedi-modal-dialog { + --_tedi-modal-max-width: 95vw; + --_tedi-modal-max-height: 95dvh; + + width: 100%; + max-width: var(--_tedi-modal-max-width); + + &--default { + @include modal-size-default; + } + + &--small { + @include modal-size-small; + } + + @each $width in $modal-widths { + &--#{$width} { + max-width: min(var(--modal-max-width-#{$width}), var(--_tedi-modal-max-width)); + } + } + + &--center { + max-height: var(--_tedi-modal-max-height); + } + + &--left, + &--right { + min-height: 100dvh; + + // cdk-dialog-container: third-party, can't add host class + cdk-dialog-container { + min-height: 100dvh; + } + + .tedi-modal { + min-height: 100dvh; + } + } + + &--left { + animation: tedi-modal-slide-left 200ms ease-out; + } + + &--right { + animation: tedi-modal-slide-right 200ms ease-out; + } + + &--center { + // cdk-dialog-container: third-party, can't add host class + cdk-dialog-container { display: flex; - gap: var(--layout-grid-gutters-08); - align-items: center; + flex-direction: column; + max-height: var(--_tedi-modal-max-height); + } - button[tedi-closing-button] { - margin-left: auto; + .tedi-modal { + max-height: var(--_tedi-modal-max-height); + overflow: hidden; + } + } + + .tedi-modal { + @include modal-dialog-box; + } + + &--left, + &--right { + .tedi-modal { + border-radius: 0; + } + } + + &--scroll-page { + --_tedi-modal-max-height: none; + + cdk-dialog-container { + max-height: none; + } + + .tedi-modal { + max-height: none; + overflow: visible; + } + + .tedi-modal-content { + overflow-y: visible; + } + } + + &--fullscreen { + --_tedi-modal-max-width: 100vw; + --_tedi-modal-max-height: 100dvh; + + max-width: 100vw; + min-height: 100dvh; + + cdk-dialog-container, + .tedi-modal { + min-height: 100dvh; + } + + .tedi-modal { + border-radius: 0; + } + } + + @each $bp in (sm, md, lg, xl) { + &--fullscreen-#{$bp} { + @include breakpoints.media-breakpoint-down($bp) { + --_tedi-modal-max-width: 100vw; + --_tedi-modal-max-height: 100dvh; + + max-width: 100vw; + min-height: 100dvh; + + cdk-dialog-container, + .tedi-modal { + min-height: 100dvh; + } + + .tedi-modal { + border-radius: 0; + } } } } - tedi-modal-content { - display: flex; - flex-direction: column; - gap: var(--layout-grid-gutters-16); - padding: var(--_tedi-modal-body-padding); - overflow-y: auto; + @include modal-content-styles; +} + +@keyframes tedi-modal-slide-left { + from { + transform: translateX(-100%); } - tedi-modal-footer { - display: flex; - gap: var(--layout-grid-gutters-16); - justify-content: flex-end; - padding: var(--_tedi-modal-footer-padding-y) var(--_tedi-modal-footer-padding-x); - border-top: var(--borders-01) solid var(--modal-border-inner); + to { + transform: translateX(0); + } +} + +@keyframes tedi-modal-slide-right { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); } } diff --git a/tedi/components/overlay/modal/modal.component.spec.ts b/tedi/components/overlay/modal/modal.component.spec.ts index 72adbe1b9..8ce281106 100644 --- a/tedi/components/overlay/modal/modal.component.spec.ts +++ b/tedi/components/overlay/modal/modal.component.spec.ts @@ -36,6 +36,7 @@ describe("ModalComponent", () => { expect(component.width()).toBe("sm"); expect(component.position()).toBe("center"); expect(component.open()).toBe(false); + expect(component.closeOnBackdropClick()).toBe(true); }); it("should apply correct default classes", () => { @@ -43,7 +44,11 @@ describe("ModalComponent", () => { expect(classes).toContain("tedi-modal--default"); expect(classes).toContain("tedi-modal--sm"); expect(classes).toContain("tedi-modal--center"); + expect(classes).toContain("tedi-modal--sm"); + expect(classes).toContain("tedi-modal--center"); + expect(classes).not.toContain("tedi-modal--top"); expect(classes).not.toContain("tedi-modal--open"); + expect(classes).not.toContain("tedi-modal--service"); }); it("should add 'tedi-modal--open' class when opened", () => { @@ -96,6 +101,73 @@ describe("ModalComponent", () => { button.remove(); }); + + it("should apply top and center classes when position is top", () => { + fixture.componentRef.setInput("position", "top"); + fixture.detectChanges(); + + const classes = hostEl.getAttribute("class")!; + expect(classes).toContain("tedi-modal--top"); + expect(classes).toContain("tedi-modal--center"); + }); + + it("should not apply width class for custom width values", () => { + fixture.componentRef.setInput("width", "80%"); + fixture.detectChanges(); + + const classes = hostEl.getAttribute("class")!; + expect(classes).not.toContain("tedi-modal--80%"); + expect(classes).toContain("tedi-modal--default"); + }); + + it("should set width style on dialog for custom width", () => { + fixture.componentRef.setInput("width", "80%"); + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + const dialog = hostEl.querySelector(".tedi-modal__dialog") as HTMLElement; + expect(dialog.style.width).toBe("80%"); + }); + + it("should not set width style for preset widths", () => { + fixture.componentRef.setInput("width", "lg"); + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + const dialog = hostEl.querySelector(".tedi-modal__dialog") as HTMLElement; + expect(dialog.style.width).toBe(""); + }); + + it("should not apply top class for side positions", () => { + fixture.componentRef.setInput("position", "left"); + fixture.detectChanges(); + + const classes = hostEl.getAttribute("class")!; + expect(classes).not.toContain("tedi-modal--top"); + }); + + it("should close on backdrop click by default", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + + component.onBackdropClick(); + + expect(component.open()).toBe(false); + }); + + it("should not close on backdrop click when closeOnBackdropClick is false", () => { + fixture.componentRef.setInput("open", true); + fixture.componentRef.setInput("closeOnBackdropClick", false); + fixture.detectChanges(); + + component.onBackdropClick(); + + expect(component.open()).toBe(true); + + // Cleanup: close modal so body overflow is restored + fixture.componentRef.setInput("open", false); + fixture.detectChanges(); + }); }); describe("ModalComponent (server platform)", () => { diff --git a/tedi/components/overlay/modal/modal.component.ts b/tedi/components/overlay/modal/modal.component.ts index 41c36fb09..55aec459a 100644 --- a/tedi/components/overlay/modal/modal.component.ts +++ b/tedi/components/overlay/modal/modal.component.ts @@ -14,11 +14,23 @@ import { } 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"; - +import { ModalRef } from "./modal-ref"; +import type { + ModalSize, + ModalWidth, + ModalPosition +} from "./modal.types"; + +/** + * Modal component that works in two modes: + * + * **Service mode** When opened via `ModalService.open()`, acts as a + * lightweight layout wrapper. CDK Dialog handles overlay, backdrop, focus trap, + * scroll blocking, and keyboard events. + * + * **Standalone mode** (deprecated): When used directly in a template with `[(open)]`, + * manages its own overlay, scroll lock, and focus. Migrate to `ModalService.open()`. + */ @Component({ standalone: true, selector: "tedi-modal", @@ -32,7 +44,7 @@ export type ModalPosition = "center" | "left" | "right"; }, }) export class ModalComponent implements AfterViewInit, OnDestroy { - /** Is modal open? */ + /** @deprecated Is modal open? Only used in standalone (deprecated) mode. */ readonly open = model(false); /** Modal size */ @@ -44,29 +56,60 @@ export class ModalComponent implements AfterViewInit, OnDestroy { /** Position of the modal */ readonly position = input("center"); + /** @deprecated Whether clicking the backdrop closes the modal. Only used in standalone mode. */ + readonly closeOnBackdropClick = input(true); + private readonly document = inject(DOCUMENT); private readonly host = inject>(ElementRef); private readonly platformId = inject(PLATFORM_ID); + /** + * When a ModalRef is available, this component is inside a CDK Dialog + * and should act as a layout-only wrapper. + */ + readonly serviceMode = !!inject(ModalRef, { optional: true }); + private prevBodyOverflow: string = ""; private prevFocusedElement: HTMLElement | null = null; + private readonly isPresetWidth = computed(() => + (["xs", "sm", "md", "lg", "xl"] as string[]).includes(this.width()), + ); + + /** Custom width for non-preset widths (legacy mode only). */ + readonly customWidth = computed(() => + !this.serviceMode && !this.isPresetWidth() ? this.width() : 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"); + const classList = ["tedi-modal"]; + + if (this.serviceMode) { + classList.push("tedi-modal--service"); + } else { + classList.push(`tedi-modal--${this.size()}`); + + if (this.isPresetWidth()) { + classList.push(`tedi-modal--${this.width()}`); + } + + if (this.position() === "top") { + classList.push("tedi-modal--center", "tedi-modal--top"); + } else { + classList.push(`tedi-modal--${this.position()}`); + } + + if (this.open()) { + classList.push("tedi-modal--open"); + } } return classList.join(" "); }); constructor() { + if (this.serviceMode) return; + effect(() => { if (!isPlatformBrowser(this.platformId)) return; @@ -79,12 +122,14 @@ export class ModalComponent implements AfterViewInit, OnDestroy { } ngAfterViewInit(): void { + if (this.serviceMode) return; if (!isPlatformBrowser(this.platformId)) return; this.document.body.appendChild(this.host.nativeElement); } ngOnDestroy() { + if (this.serviceMode) return; if (!isPlatformBrowser(this.platformId)) return; const element = this.host.nativeElement; @@ -113,6 +158,13 @@ export class ModalComponent implements AfterViewInit, OnDestroy { this.document.removeEventListener("keydown", this.handleKeydown); } + /** @internal */ + onBackdropClick(): void { + if (this.closeOnBackdropClick()) { + this.open.set(false); + } + } + private handleKeydown = (e: KeyboardEvent) => { if (e.key === "Escape") { this.open.set(false); diff --git a/tedi/components/overlay/modal/modal.service.spec.ts b/tedi/components/overlay/modal/modal.service.spec.ts new file mode 100644 index 000000000..75b14247b --- /dev/null +++ b/tedi/components/overlay/modal/modal.service.spec.ts @@ -0,0 +1,300 @@ +import { TestBed } from "@angular/core/testing"; +import { + Component, + inject, +} from "@angular/core"; +import { ModalService } from "./modal.service"; +import { ModalRef } from "./modal-ref"; +import { MODAL_DATA } from "./modal.types"; + +@Component({ + standalone: true, + template: `
{{ data?.message }}
`, +}) +class TestModalContentComponent { + data = inject(MODAL_DATA, { optional: true }) as { message: string } | null; + ref = inject(ModalRef); +} + +describe("ModalService", () => { + let service: ModalService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ModalService); + }); + + afterEach(() => { + service.closeAll(); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should open a modal and return a ModalRef", () => { + const ref = service.open(TestModalContentComponent); + expect(ref).toBeInstanceOf(ModalRef); + }); + + it("should pass data to the modal content via MODAL_DATA", () => { + const ref = service.open(TestModalContentComponent, { + data: { message: "Hello" }, + }); + + expect(ref).toBeTruthy(); + ref.close(); + }); + + it("should close the modal via ModalRef.close()", (done) => { + const ref = service.open(TestModalContentComponent); + + ref.closed.subscribe((result) => { + expect(result).toBeUndefined(); + done(); + }); + + ref.close(); + }); + + it("should return a result when closing", (done) => { + const ref = service.open(TestModalContentComponent); + + ref.closed.subscribe((result) => { + expect(result).toBe("confirmed"); + done(); + }); + + ref.close("confirmed"); + }); + + it("should apply correct panel classes for default config", () => { + const ref = service.open(TestModalContentComponent); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--default")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--sm")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--center")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--top")).toBe(false); + + ref.close(); + }); + + it("should apply correct panel classes for custom config", () => { + const ref = service.open(TestModalContentComponent, { + size: "small", + width: "lg", + position: "right", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--small")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--lg")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--right")).toBe(true); + + ref.close(); + }); + + it("should apply top and center classes when position is top", () => { + const ref = service.open(TestModalContentComponent, { + position: "top", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--top")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--center")).toBe(true); + + ref.close(); + }); + + it("should not apply width class for custom width values", () => { + const ref = service.open(TestModalContentComponent, { + width: "80%", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--80%")).toBe(false); + + ref.close(); + }); + + it("should set width on overlay element for custom width", () => { + const ref = service.open(TestModalContentComponent, { + width: "80%", + }); + + const overlayElement = document.querySelector(".cdk-global-overlay-wrapper .cdk-overlay-pane") as HTMLElement; + expect(overlayElement?.style.width).toBe("80%"); + + ref.close(); + }); + + it("should apply fullscreen class when fullscreen is true", () => { + const ref = service.open(TestModalContentComponent, { + fullscreen: true, + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--fullscreen")).toBe(true); + + ref.close(); + }); + + it("should apply fullscreen-md class when fullscreen is 'md'", () => { + const ref = service.open(TestModalContentComponent, { + fullscreen: "md", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--fullscreen-md")).toBe(true); + + ref.close(); + }); + + it("should not apply fullscreen class by default", () => { + const ref = service.open(TestModalContentComponent); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--fullscreen")).toBe(false); + + ref.close(); + }); + + it("should apply backdrop class", () => { + const ref = service.open(TestModalContentComponent); + + const backdrop = document.querySelector(".cdk-overlay-backdrop"); + expect(backdrop?.classList.contains("tedi-modal-backdrop")).toBe(true); + + ref.close(); + }); + + it("should close on Escape by default", () => { + const ref = service.open(TestModalContentComponent); + + const event = new KeyboardEvent("keydown", { key: "Escape" }); + document.querySelector("cdk-dialog-container")?.dispatchEvent(event); + + // The dialog should be closing/closed + expect(ref).toBeTruthy(); + ref.close(); + }); + + it("should not close on Escape when closeOnEscape is false", () => { + service.open(TestModalContentComponent, { + closeOnEscape: false, + }); + + const event = new KeyboardEvent("keydown", { key: "Escape" }); + document.querySelector("cdk-dialog-container")?.dispatchEvent(event); + + // Modal should still be open - overlay pane should exist + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane).toBeTruthy(); + + service.closeAll(); + }); + + it("should apply scroll-page class when scrollBehavior is page", () => { + const ref = service.open(TestModalContentComponent, { + scrollBehavior: "page", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--scroll-page")).toBe(true); + + ref.close(); + }); + + it("should set overflow and padding on host element for page scroll", () => { + const ref = service.open(TestModalContentComponent, { + scrollBehavior: "page", + }); + + const hostElement = document.querySelector(".cdk-global-overlay-wrapper") as HTMLElement; + expect(hostElement?.style.overflow).toBe("auto"); + + ref.close(); + }); + + it("should apply left position strategy", () => { + const ref = service.open(TestModalContentComponent, { + position: "left", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--left")).toBe(true); + + ref.close(); + }); + + it("should apply page scroll position strategy", () => { + const ref = service.open(TestModalContentComponent, { + scrollBehavior: "page", + }); + + const overlayPane = document.querySelector(".cdk-overlay-pane"); + expect(overlayPane?.classList.contains("tedi-modal-dialog--scroll-page")).toBe(true); + expect(overlayPane?.classList.contains("tedi-modal-dialog--center")).toBe(true); + + ref.close(); + }); + + it("should expose backdropClick observable on ModalRef", () => { + const ref = service.open(TestModalContentComponent); + + expect(ref.backdropClick).toBeDefined(); + expect(ref.backdropClick.subscribe).toBeDefined(); + + ref.close(); + }); + + it("should expose keydownEvents observable on ModalRef", () => { + const ref = service.open(TestModalContentComponent); + + expect(ref.keydownEvents).toBeDefined(); + expect(ref.keydownEvents.subscribe).toBeDefined(); + + ref.close(); + }); + + it("should support updateSize on ModalRef", () => { + const ref = service.open(TestModalContentComponent); + + const result = ref.updateSize("500px", "300px"); + expect(result).toBe(ref); + + ref.close(); + }); + + it("should set --_tedi-modal-max-width CSS variable when maxWidth is provided", () => { + const ref = service.open(TestModalContentComponent, { + maxWidth: "800px", + }); + + const overlayElement = document.querySelector(".cdk-global-overlay-wrapper .cdk-overlay-pane") as HTMLElement; + expect(overlayElement?.style.getPropertyValue("--_tedi-modal-max-width")).toBe("800px"); + + ref.close(); + }); + + it("should not set --_tedi-modal-max-width CSS variable by default", () => { + const ref = service.open(TestModalContentComponent); + + const overlayElement = document.querySelector(".cdk-global-overlay-wrapper .cdk-overlay-pane") as HTMLElement; + expect(overlayElement?.style.getPropertyValue("--_tedi-modal-max-width")).toBe(""); + + ref.close(); + }); + + it("should close all modals via closeAll()", () => { + service.open(TestModalContentComponent); + service.open(TestModalContentComponent); + + service.closeAll(); + + const overlayPanes = document.querySelectorAll(".tedi-modal-dialog"); + expect(overlayPanes.length).toBe(0); + }); +}); diff --git a/tedi/components/overlay/modal/modal.service.ts b/tedi/components/overlay/modal/modal.service.ts new file mode 100644 index 000000000..cde35472a --- /dev/null +++ b/tedi/components/overlay/modal/modal.service.ts @@ -0,0 +1,175 @@ +import { Injectable, inject } from "@angular/core"; +import { Dialog, DialogRef } from "@angular/cdk/dialog"; +import { GlobalPositionStrategy, Overlay } from "@angular/cdk/overlay"; +import { ComponentType } from "@angular/cdk/portal"; +import { ModalRef } from "./modal-ref"; +import { ModalConfig, ModalFullscreen, ModalPosition, ModalScrollBehavior, MODAL_DATA } from "./modal.types"; + +const WIDTH_PRESETS: readonly string[] = ["xs", "sm", "md", "lg", "xl"]; + +@Injectable({ providedIn: "root" }) +export class ModalService { + private readonly dialog = inject(Dialog); + private readonly overlay = inject(Overlay); + + /** + * Open a modal dialog with the given component as content. + * + * @param component Component to render inside the modal. + * @param config Modal configuration (size, width, position, data, etc.). + * @returns A `ModalRef` to control and observe the modal. + * + * @example + * ```ts + * const ref = this.modalService.open(MyFormComponent, { + * data: { userId: 123 }, + * width: 'md', + * position: 'center', + * }); + * + * ref.closed.subscribe(result => console.log('Modal closed with:', result)); + * ``` + */ + open( + component: ComponentType, + config: ModalConfig = {}, + ): ModalRef { + const { + data, + size = "default", + width = "sm", + position = "center", + scrollBehavior = "content", + closeOnBackdropClick = true, + closeOnEscape = true, + fullscreen = false, + maxWidth, + ariaLabel, + ariaLabelledBy, + } = config; + + const panelClasses = this.buildPanelClasses(size, width, position, scrollBehavior, fullscreen); + const isPresetWidth = WIDTH_PRESETS.includes(width); + + const dialogRef = this.dialog.open(component, { + data, + panelClass: panelClasses, + backdropClass: "tedi-modal-backdrop", + hasBackdrop: true, + disableClose: true, + ariaLabel, + ariaLabelledBy, + ariaModal: true, + autoFocus: "first-tabbable", + restoreFocus: true, + positionStrategy: this.buildPositionStrategy(position, scrollBehavior), + scrollStrategy: this.overlay.scrollStrategies.block(), + providers: (ref) => [ + { provide: ModalRef, useValue: new ModalRef(ref) }, + { provide: MODAL_DATA, useValue: data }, + ], + }); + + if (!isPresetWidth) { + dialogRef.overlayRef.overlayElement.style.width = width; + } + + if (maxWidth) { + dialogRef.overlayRef.overlayElement.style.setProperty("--_tedi-modal-max-width", maxWidth); + } + + this.setupDialogBehavior(dialogRef, scrollBehavior, closeOnBackdropClick, closeOnEscape); + + return new ModalRef(dialogRef); + } + + /** Close all open modals. */ + closeAll(): void { + this.dialog.closeAll(); + } + + private buildPanelClasses( + size: string, + width: string, + position: ModalPosition, + scrollBehavior: ModalScrollBehavior, + fullscreen: ModalFullscreen, + ): string[] { + const classes = [ + "tedi-modal-dialog", + `tedi-modal-dialog--${size}`, + ]; + + if (WIDTH_PRESETS.includes(width)) { + classes.push(`tedi-modal-dialog--${width}`); + } + + if (position === "top") { + classes.push("tedi-modal-dialog--center", "tedi-modal-dialog--top"); + } else { + classes.push(`tedi-modal-dialog--${position}`); + } + + if (scrollBehavior === "page") { + classes.push("tedi-modal-dialog--scroll-page"); + } + + if (fullscreen === true) { + classes.push("tedi-modal-dialog--fullscreen"); + } else if (typeof fullscreen === "string") { + classes.push(`tedi-modal-dialog--fullscreen-${fullscreen}`); + } + + return classes; + } + + private setupDialogBehavior( + dialogRef: DialogRef, + scrollBehavior: ModalScrollBehavior, + closeOnBackdropClick: boolean, + closeOnEscape: boolean, + ): void { + if (scrollBehavior === "page") { + const host = dialogRef.overlayRef.hostElement; + host.style.overflow = "auto"; + host.style.paddingBlock = "var(--layout-grid-gutters-16)"; + } + + if (closeOnBackdropClick) { + dialogRef.backdropClick.subscribe(() => dialogRef.close()); + } + + if (closeOnEscape) { + dialogRef.keydownEvents.subscribe((event) => { + if (event.key === "Escape") { + dialogRef.close(); + } + }); + } + } + + private buildPositionStrategy( + position: ModalPosition, + scrollBehavior: ModalScrollBehavior, + ): GlobalPositionStrategy { + const global = this.overlay.position().global(); + + if (position === "left") { + return global.left("0").top("0"); + } + + if (position === "right") { + return global.right("0").top("0"); + } + + if (position === "top") { + return global.centerHorizontally().top("var(--modal-top-margin)"); + } + + if (scrollBehavior === "page") { + return global.centerHorizontally().top("0"); + } + + return global.centerHorizontally().centerVertically(); + } +} diff --git a/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts index f687aef03..2123b5358 100644 --- a/tedi/components/overlay/modal/modal.stories.ts +++ b/tedi/components/overlay/modal/modal.stories.ts @@ -1,12 +1,393 @@ import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; +import { Component, inject, Input } from "@angular/core"; 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 { ModalService } from "./modal.service"; +import { ModalRef } from "./modal-ref"; +import { MODAL_DATA } from "./modal.types"; import { ButtonComponent } from "../../buttons/button/button.component"; import { LabelComponent } from "../../form/label/label.component"; -import { SelectComponent, SelectOptionComponent } from "@tedi-design-system/angular/community"; import { IconComponent } from "../../base/icon/icon.component"; +import { ScrollFadeComponent } from "../../helpers/scroll-fade/scroll-fade.component"; +import { TextFieldComponent } from "../../form/text-field/text-field.component"; +import { FormFieldComponent } from "../../form/form-field/form-field.component"; +import { DatePickerComponent } from "../../form/date-picker/date-picker.component"; + +interface StoryModalData { + title: string; + description?: string; + showClose?: boolean; +} + +const sharedModalImports = [ + ModalComponent, + ModalHeaderComponent, + ModalContentComponent, + ModalFooterComponent, + ButtonComponent, + LabelComponent, + TextFieldComponent, + FormFieldComponent, +]; + +@Component({ + standalone: true, + selector: "story-modal-content", + imports: sharedModalImports, + template: ` + + +

{{ data.title }}

+ @if (data.description) { +

{{ data.description }}

+ } +
+ + + + + + + + + + + + + + +
+ `, +}) +class StoryModalContentComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); +} + +@Component({ + standalone: true, + selector: "story-scrollable-content", + imports: [...sharedModalImports, DatePickerComponent], + template: ` + + +

{{ data.title }}

+
+ +

Teenus

+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + +
+
+
+

Kontaktisik

+
+ + + + + + + + +
+ + + + +
+ + + + + + + + +
+ + + + +
+

Esindaja

+
+ + + + + + + + +
+ + + + +
+ + + + + + + + +
+ + + + + + + + +
+ + + + +
+ `, +}) +class StoryScrollableContentComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); +} + +@Component({ + standalone: true, + selector: "story-scrollable-fade-content", + imports: [...sharedModalImports, ScrollFadeComponent, DatePickerComponent], + template: ` + + +

{{ data.title }}

+
+ + +
+

Teenus

+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + +
+
+
+

Kontaktisik

+
+ + + + + + + + +
+ + + + +
+ + + + + + + + +
+ + + + +
+

Esindaja

+
+ + + + + + + + +
+ + + + +
+ + + + + + + + +
+ + + + + + + + +
+
+
+ + + + +
+ `, +}) +class StoryScrollableFadeContentComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); +} + +@Component({ + standalone: true, + selector: "story-footer-left-right", + imports: sharedModalImports, + template: ` + + +

{{ data.title }}

+
+ + + + + + + + + + +
+ `, +}) +class StoryFooterLeftRightComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); +} + +@Component({ + standalone: true, + selector: "story-footer-three-buttons", + imports: [...sharedModalImports, IconComponent], + template: ` + + +

{{ data.title }}

+
+ + + + + + + + +
+ + +
+
+
+ `, +}) +class StoryFooterThreeButtonsComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); +} + +@Component({ + standalone: true, + selector: "story-no-footer", + imports: sharedModalImports, + template: ` + + +

{{ data.title }}

+
+ + + + + + +
+ `, +}) +class StoryNoFooterComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); +} /** * Figma ↗
@@ -14,22 +395,71 @@ import { IconComponent } from "../../base/icon/icon.component"; * * --- * - * 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`: + * ## Service-based modal (ModalService) + * + * Open modals programmatically via `ModalService.open()`. This uses Angular CDK Dialog + * under the hood and handles focus trapping, scroll blocking, backdrop, and keyboard + * events automatically. * * ```ts - * modal = viewChild(ModalComponent); + * private modalService = inject(ModalService); + * + * openModal() { + * const ref = this.modalService.open(MyContentComponent, { + * data: { title: 'Hello' }, + * width: 'md', + * position: 'center', + * }); + * + * ref.closed.subscribe(result => console.log(result)); + * } + * ``` + * + * The content component wraps everything in `` and injects `MODAL_DATA` / `ModalRef`: * - * toggleModal() { - * this.modal.open.update(prev => !prev); + * ```ts + * @Component({ + * imports: [ModalComponent, ModalHeaderComponent, ModalContentComponent, ModalFooterComponent, ButtonComponent], + * template: ` + * + * + *

{{ data.title }}

+ *
+ * + * + * + * + * + * + *
+ * `, + * }) + * class MyContentComponent { + * data = inject(MODAL_DATA); + * ref = inject(ModalRef); * } * ``` * - * The modal layout is composed of the following subcomponents: + * ## Template-based modal (deprecated) + * + * The `` component with `[(open)]` binding is deprecated. + * Migrate to `ModalService.open()` for new code. * - * - ModalHeaderComponent - * - ModalContentComponent - * - ModalFooterComponent + * ```html + * + * + * + *

Title

+ *
+ * + * + * + * + * + * + * + *
+ * ``` */ export default { @@ -38,361 +468,1000 @@ export default { decorators: [ moduleMetadata({ imports: [ - ModalComponent, - ModalHeaderComponent, - ModalContentComponent, - ModalFooterComponent, - ButtonComponent, - LabelComponent, - SelectComponent, - SelectOptionComponent, + ...sharedModalImports, IconComponent, ], }), ], + parameters: {}, 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", - }, - }, + table: { type: { summary: "'default' | 'small'" }, defaultValue: { summary: "'default'" }, category: "ModalConfig" }, + description: "Modal size variant. Controls padding, heading size, and close button size.", }, 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", - }, - }, + table: { type: { summary: "'xs' | 'sm' | 'md' | 'lg' | 'xl' | string" }, defaultValue: { summary: "'sm'" }, category: "ModalConfig" }, + description: "Modal width — preset token or custom CSS value (e.g. `'800px'`, `'60%'`).", + }, + maxWidth: { + table: { type: { summary: "string" }, defaultValue: { summary: "'95vw'" }, category: "ModalConfig" }, + description: "Max-width cap (e.g. `'75%'`, `'60vw'`). Overrides the default 95vw limit.", }, 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", - }, - }, + table: { type: { summary: "'center' | 'top' | 'left' | 'right'" }, defaultValue: { summary: "'center'" }, category: "ModalConfig" }, + description: "Position of the modal on screen. `'left'` and `'right'` create side/drawer modals.", + }, + scrollBehavior: { + table: { type: { summary: "'content' | 'page'" }, defaultValue: { summary: "'content'" }, category: "ModalConfig" }, + description: "Scroll behavior when content overflows. `'content'` scrolls inside the modal, `'page'` scrolls the full overlay.", + }, + fullscreen: { + table: { type: { summary: "boolean | 'sm' | 'md' | 'lg' | 'xl'" }, defaultValue: { summary: "false" }, category: "ModalConfig" }, + description: "Fullscreen mode. `true` = always fullscreen. A breakpoint string (e.g. `'md'`) makes the modal fullscreen below that breakpoint.", + }, + closeOnBackdropClick: { + table: { type: { summary: "boolean" }, defaultValue: { summary: "true" }, category: "ModalConfig" }, + description: "Whether clicking the backdrop closes the modal.", + }, + closeOnEscape: { + table: { type: { summary: "boolean" }, defaultValue: { summary: "true" }, category: "ModalConfig" }, + description: "Whether pressing Escape closes the modal.", }, showClose: { - control: "boolean", - description: "Should show closing button?", - table: { - category: "modal header inputs", - type: { - summary: "boolean", - }, - defaultValue: { - summary: "true", - }, - }, + table: { type: { summary: "boolean" }, defaultValue: { summary: "true" }, category: "ModalConfig" }, + description: "Whether to show a close button in the header. Set via `[showClose]` on ``.", + }, + data: { + table: { type: { summary: "unknown" }, category: "ModalConfig" }, + description: "Data passed to the modal content component. Accessible via `inject(MODAL_DATA)`.", + }, + ariaLabel: { + table: { type: { summary: "string" }, category: "ModalConfig" }, + description: "ARIA label for the dialog element.", + }, + ariaLabelledBy: { + table: { type: { summary: "string" }, category: "ModalConfig" }, + description: "ID of the element that labels the dialog.", }, }, } as Meta; -type DefaultStory = StoryObj< - ModalComponent & { - showClose: boolean; - } ->; - -export const Default: DefaultStory = { +export const Default: StoryObj = { args: { - open: false, size: "default", - width: "sm", + width: "md", + maxWidth: "", position: "center", + scrollBehavior: "content", + fullscreen: false, + closeOnBackdropClick: true, + closeOnEscape: true, 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" }, - ], + argTypes: { + size: { control: "select", options: ["default", "small"] }, + width: { control: "text" }, + maxWidth: { control: "text" }, + position: { control: "select", options: ["center", "top", "left", "right"] }, + scrollBehavior: { control: "select", options: ["content", "page"] }, + fullscreen: { control: "text", description: "Set `true` for always fullscreen or a breakpoint string (`sm`, `md`, `lg`, `xl`)." }, + closeOnBackdropClick: { control: "boolean" }, + closeOnEscape: { control: "boolean" }, + showClose: { control: "boolean" }, + }, + parameters: { + docs: { + source: { + code: ` +private modalService = inject(ModalService); + +this.modalService.open(MyModalContent, { + data: { title: 'Modal title' }, + size: 'default', + width: 'md', + position: 'center', + scrollBehavior: 'content', + fullscreen: false, + closeOnBackdropClick: true, + closeOnEscape: true, +}); + +// --- Modal content component --- +@Component({ + imports: [ModalComponent, ModalHeaderComponent, ModalContentComponent, ModalFooterComponent, ButtonComponent], + template: \` + + +

{{ data.title }}

+
+ + + + + + + +
+ \`, +}) +class MyModalContent { + data = inject(MODAL_DATA); + ref = inject(ModalRef); +}`, + language: "typescript", + type: "code", + }, }, - template: ` - - - -

Title

-
- -
- - - @for (option of options; track option.value) { - - } - -
-
- - - @for (option of options; track option.value) { - - } - -
-
- - - - -
- `, - }), + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: (args) => { + @Component({ + standalone: true, + selector: "story-default-demo", + imports: [ButtonComponent], + template: ` + + `, + }) + class DefaultDemoComponent { + private readonly modalService = inject(ModalService); + + + @Input() size!: string; + @Input() width!: string; + @Input() maxWidth!: string; + @Input() position!: string; + @Input() scrollBehavior!: string; + @Input() fullscreen!: boolean | string; + @Input() closeOnBackdropClick!: boolean; + @Input() closeOnEscape!: boolean; + @Input() showClose!: boolean; + + private parseFullscreen(): boolean | string { + if (this.fullscreen === "true" || this.fullscreen === true) return true; + if (this.fullscreen === "false" || this.fullscreen === false) return false; + return this.fullscreen; + } + + open() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Modal title", showClose: this.showClose }, + size: this.size as "default" | "small", + width: this.width, + maxWidth: this.maxWidth || undefined, + position: this.position as "center" | "top" | "left" | "right", + scrollBehavior: this.scrollBehavior as "content" | "page", + fullscreen: this.parseFullscreen() as boolean | "sm" | "md" | "lg" | "xl", + closeOnBackdropClick: this.closeOnBackdropClick, + closeOnEscape: this.closeOnEscape, + }); + } + } + + return { + template: ``, + moduleMetadata: { + imports: [DefaultDemoComponent], + }, + props: args, + }; + }, }; -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" }, - ], +export const Position: StoryObj = { + parameters: { + docs: { + source: { + code: ` +// Center (default) +this.modalService.open(MyModalContent, { + data: { title: 'Center modal' }, + width: 'md', +}); + +// Top-aligned +this.modalService.open(MyModalContent, { + data: { title: 'Top-aligned modal' }, + width: 'md', + position: 'top', +}); + +// Side (right or left) +this.modalService.open(MyModalContent, { + data: { title: 'Side modal' }, + width: 'sm', + position: 'right', // or 'left' +});`, + language: "typescript", + type: "code", + }, }, - 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) { - - } - -
-
- - - - -
- `, - }), + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-position-demo", + imports: [ButtonComponent], + template: ` +
+ + + + +
+ `, + }) + class PositionDemoComponent { + private readonly modalService = inject(ModalService); + + + openCenter() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Center modal" }, + width: "md", + }); + } + + openTop() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Top-aligned modal" }, + width: "md", + position: "top", + }); + } + + openRight() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Side modal (right)" }, + width: "sm", + position: "right", + }); + } + + openLeft() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Side modal (left)" }, + width: "sm", + position: "left", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [PositionDemoComponent], + }, + }; + }, +}; + +export const Size: StoryObj = { + parameters: { + docs: { + source: { + code: ` +// Small size — compact padding, smaller heading and close button +this.modalService.open(MyModalContent, { + data: { title: 'Small modal' }, + size: 'small', + width: 'sm', +}); + +// Default size — standard padding and heading +this.modalService.open(MyModalContent, { + data: { title: 'Default modal' }, + size: 'default', // default, can be omitted + width: 'sm', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-size-demo", + imports: [ButtonComponent], + template: ` +
+ + +
+ `, + }) + class SizeDemoComponent { + private readonly modalService = inject(ModalService); + + + openSmall() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Small modal" }, + size: "small", + width: "sm", + }); + } + + openDefault() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Default modal" }, + size: "default", + width: "sm", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [SizeDemoComponent], + }, + }; + }, +}; + +export const Width: StoryObj = { + parameters: { + docs: { + source: { + code: ` +// Preset widths: 'xs' | 'sm' | 'md' | 'lg' | 'xl' +this.modalService.open(MyModalContent, { + data: { title: 'Width: md' }, + width: 'md', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-width-demo", + imports: [ButtonComponent], + template: ` +
+ + + + + +
+ `, + }) + class WidthDemoComponent { + private readonly modalService = inject(ModalService); + + + openWidth(width: string) { + this.modalService.open(StoryModalContentComponent, { + data: { title: `Width: ${width}` }, + width, + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [WidthDemoComponent], + }, + }; + }, }; -export const FooterVariants: StoryObj = { +export const CustomWidth: StoryObj = { + name: "Custom width", + parameters: { + docs: { + source: { + code: ` +// Custom width with maxWidth cap — responsive via CSS +this.modalService.open(MyModalContent, { + data: { title: 'Custom width modal' }, + position: 'left', + width: '800px', + maxWidth: '75%', +});`, + language: "typescript", + type: "code", + }, + }, + }, + args: { + width: "800px", + maxWidth: "75%", + position: "left", + }, + argTypes: { + width: { + control: "text", + description: "Custom CSS width value (e.g. '800px', '50vw', '60%').", + }, + maxWidth: { + control: "text", + description: "Max-width cap (e.g. '75%', '60vw'). Overrides the default 95vw limit.", + }, + position: { + control: "select", + options: ["center", "top", "left", "right"], + description: "Position of the modal.", + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: (args) => { + @Component({ + standalone: true, + selector: "story-custom-width-demo", + imports: [ButtonComponent], + template: ` + + `, + }) + class CustomWidthDemoComponent { + private readonly modalService = inject(ModalService); + + + @Input() width!: string; + @Input() maxWidth!: string; + @Input() position!: "center" | "top" | "left" | "right"; + + open() { + this.modalService.open(StoryModalContentComponent, { + data: { title: `Width: ${this.width}, max: ${this.maxWidth}` }, + position: this.position, + width: this.width, + maxWidth: this.maxWidth || undefined, + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [CustomWidthDemoComponent], + }, + props: args, + }; + }, +}; + +export const Fullscreen: StoryObj = { + parameters: { + docs: { + source: { + code: ` +// Always fullscreen +this.modalService.open(MyModalContent, { + data: { title: 'Fullscreen modal' }, + width: 'md', + fullscreen: true, +}); + +// Fullscreen below 'md' breakpoint +this.modalService.open(MyModalContent, { + data: { title: 'Fullscreen below md' }, + width: 'md', + fullscreen: 'md', +}); + +// Fullscreen below 'sm' breakpoint (mobile only) +this.modalService.open(MyModalContent, { + data: { title: 'Fullscreen on mobile' }, + width: 'md', + fullscreen: 'sm', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-fullscreen-demo", + imports: [ButtonComponent], + template: ` +
+ + + +
+ `, + }) + class FullscreenDemoComponent { + private readonly modalService = inject(ModalService); + + + openAlways() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Fullscreen modal" }, + width: "md", + fullscreen: true, + }); + } + + openMd() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Fullscreen below md" }, + width: "md", + fullscreen: "md", + }); + } + + openSm() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "Fullscreen on mobile" }, + width: "md", + fullscreen: "sm", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [FullscreenDemoComponent], + }, + }; + }, +}; + +export const ScrollableContent: StoryObj = { + parameters: { + docs: { + source: { + code: ` +// --- Content scrollbar (default) --- +this.modalService.open(MyModalContent, { + data: { title: 'Scrollable modal' }, + width: 'sm', +}); + +// --- Content fade --- +// Wrap content in inside the content component. +// +// @Component({ +// imports: [..., ScrollFadeComponent], +// template: \` +// +// +// +// +// +// +// +// \`, +// }) +this.modalService.open(MyFadeContent, { + data: { title: 'Fade modal' }, + width: 'sm', +}); + +// --- Page scroll --- +// The whole page scrolls instead of modal content. +this.modalService.open(MyModalContent, { + data: { title: 'Page scroll modal' }, + width: 'sm', + scrollBehavior: 'page', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryScrollableContentComponent, StoryScrollableFadeContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-scrollable-demo", + imports: [ButtonComponent], + template: ` +
+ + + +
+ `, + }) + class ScrollableDemoComponent { + private readonly modalService = inject(ModalService); + + + openScrollbar() { + this.modalService.open(StoryScrollableContentComponent, { + data: { title: "Uus toiming" }, + width: "md", + }); + } + + openFade() { + this.modalService.open(StoryScrollableFadeContentComponent, { + data: { title: "Uus toiming" }, + width: "md", + }); + } + + openPageScroll() { + this.modalService.open(StoryScrollableContentComponent, { + data: { title: "Uus toiming" }, + width: "md", + scrollBehavior: "page", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [ScrollableDemoComponent], + }, + }; + }, +}; + +export const WithDescription: StoryObj = { + name: "With header description", + parameters: { + docs: { + source: { + code: ` +// Add a

inside : +// +//

Title

+//

Description text

+//
+this.modalService.open(MyModalContent, { + data: { title: 'With description', description: 'Additional description in the header.' }, + width: 'md', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-description-demo", + imports: [ButtonComponent], + template: ` + + `, + }) + class DescriptionDemoComponent { + private readonly modalService = inject(ModalService); + + + open() { + this.modalService.open(StoryModalContentComponent, { + data: { + title: "With description", + description: "This modal has additional description text in the header.", + }, + width: "md", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [DescriptionDemoComponent], + }, + }; + }, +}; + +export const NoBackdropClose: StoryObj = { + name: "No backdrop close", + parameters: { + docs: { + source: { + code: ` +this.modalService.open(MyModalContent, { + data: { title: 'No backdrop close' }, + width: 'md', + closeOnBackdropClick: false, +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-no-backdrop-demo", + imports: [ButtonComponent], + template: ` + + `, + }) + class NoBackdropDemoComponent { + private readonly modalService = inject(ModalService); + + + open() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "No backdrop close" }, + width: "md", + closeOnBackdropClick: false, + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [NoBackdropDemoComponent], + }, + }; + }, +}; + +export const NoCloseButton: StoryObj = { + name: "No close button", + parameters: { + docs: { + source: { + code: ` +// Set showClose on the modal content component's : +// +this.modalService.open(MyModalContent, { + data: { title: 'No close button', showClose: false }, + width: 'md', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ButtonComponent, StoryModalContentComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-no-close-demo", + imports: [ButtonComponent], + template: ` + + `, + }) + class NoCloseDemoComponent { + private readonly modalService = inject(ModalService); + + + open() { + this.modalService.open(StoryModalContentComponent, { + data: { title: "No close button", showClose: false }, + width: "md", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [NoCloseDemoComponent], + }, + }; + }, +}; + +export const FooterVariants: StoryObj = { + parameters: { + docs: { + source: { + code: ` +// Default footer — buttons aligned to the right +// +// +// +// + +// Left-right footer — space-between alignment +// +// +// +// + +// Three buttons — back button on the left, cancel + continue on the right +// +// +//
+// +// +//
+//
+ +// No footer — simply omit + +this.modalService.open(MyModalContent, { + data: { title: 'Title' }, + size: 'small', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + moduleMetadata({ + imports: [ + ButtonComponent, + StoryModalContentComponent, + StoryFooterLeftRightComponent, + StoryFooterThreeButtonsComponent, + StoryNoFooterComponent, + ], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-footer-demo", + imports: [ButtonComponent], + template: ` +
+ + + + +
+ `, + }) + class FooterDemoComponent { + private readonly modalService = inject(ModalService); + + private readonly data: StoryModalData = { title: "Title" }; + + openDefault() { + this.modalService.open(StoryModalContentComponent, { + data: this.data, + size: "small", + }); + } + + openLeftRight() { + this.modalService.open(StoryFooterLeftRightComponent, { + data: this.data, + }); + } + + openThreeButtons() { + this.modalService.open(StoryFooterThreeButtonsComponent, { + data: this.data, + }); + } + + openNoFooter() { + this.modalService.open(StoryNoFooterComponent, { + data: this.data, + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [FooterDemoComponent], + }, + }; + }, +}; + +export const TemplateBased: StoryObj = { + name: "Template-based (deprecated)", + parameters: { + docs: { + source: { + code: ` + + + + +

Title

+
+ + + + + + + +
`, + language: "html", + type: "code", + }, + }, + }, 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" }, - ], + open: false, }, 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) { - - } - -
-
-
`, }), }; diff --git a/tedi/components/overlay/modal/modal.types.ts b/tedi/components/overlay/modal/modal.types.ts new file mode 100644 index 000000000..69906b8f1 --- /dev/null +++ b/tedi/components/overlay/modal/modal.types.ts @@ -0,0 +1,38 @@ +import { InjectionToken } from "@angular/core"; + +export type ModalSize = "default" | "small"; +export type ModalWidthPreset = "xs" | "sm" | "md" | "lg" | "xl"; +export type ModalWidth = ModalWidthPreset | (string & {}); +export type ModalPosition = "center" | "top" | "left" | "right"; +export type ModalScrollBehavior = "content" | "page"; +export type ModalFullscreen = boolean | "sm" | "md" | "lg" | "xl"; + +export interface ModalConfig { + /** Data to pass to the modal content component. Accessible via `inject(MODAL_DATA)`. */ + data?: D; + /** Modal size variant. @default 'default' */ + size?: ModalSize; + /** Modal width — preset ('xs'–'xl') or custom CSS value (e.g. '800px'). @default 'sm' */ + width?: ModalWidth; + /** Position of the modal. @default 'center' */ + position?: ModalPosition; + /** Scroll behavior when content overflows. 'content' scrolls inside the modal, 'page' scrolls the overlay. @default 'content' */ + scrollBehavior?: ModalScrollBehavior; + /** Whether clicking the backdrop closes the modal. @default true */ + closeOnBackdropClick?: boolean; + /** Whether pressing Escape closes the modal. @default true */ + closeOnEscape?: boolean; + /** Whether to show a close button in the header. @default true */ + showClose?: boolean; + /** Fullscreen mode. `true` = always fullscreen, `'sm'`/`'md'`/etc = fullscreen below that breakpoint. @default false */ + fullscreen?: ModalFullscreen; + /** Max-width cap (e.g. '75%', '60vw'). Overrides the default 95vw limit. */ + maxWidth?: string; + /** ARIA label for the dialog. */ + ariaLabel?: string; + /** ID of the element that labels the dialog. */ + ariaLabelledBy?: string; +} + +/** Injection token for data passed to a modal opened via ModalService. */ +export const MODAL_DATA = new InjectionToken("TediModalData");