diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.html b/tedi/components/content/carousel/carousel-content/carousel-content.component.html new file mode 100644 index 000000000..a784470cf --- /dev/null +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.html @@ -0,0 +1,21 @@ + diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.scss b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss new file mode 100644 index 000000000..5ecffae1e --- /dev/null +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss @@ -0,0 +1,37 @@ +.tedi-carousel__content { + width: 100%; + position: relative; + overflow: hidden; + touch-action: pan-y; + cursor: grab; + + &:focus-visible { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: calc(-1 * var(--borders-03)); + } + + &:active { + cursor: grabbing; + } + + &--fade-right { + mask-image: linear-gradient(to right, black 90%, transparent 100%); + } + + &--fade-x { + mask-image: + linear-gradient(to right, black 85%, transparent 100%), + linear-gradient(to left, black 85%, transparent 100%); + mask-composite: intersect; + } +} + +.tedi-carousel__track { + display: flex; + will-change: transform; +} + +.tedi-carousel__slide { + user-select: none; + -webkit-user-drag: none; +} diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts new file mode 100644 index 000000000..36c11c0e4 --- /dev/null +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -0,0 +1,436 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + ViewEncapsulation, + computed, + contentChildren, + signal, + viewChild, + AfterViewInit, + OnDestroy, + input, + inject, + HostListener, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { CarouselSlideDirective } from "../carousel-slide.directive"; +import { + breakpointInput, + BreakpointInput, + BreakpointService, +} from "../../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../../services"; + +@Component({ + standalone: true, + selector: "tedi-carousel-content", + templateUrl: "./carousel-content.component.html", + styleUrls: ["./carousel-content.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [NgTemplateOutlet], + host: { + tabindex: "0", + role: "region", + "aria-roledescription": "carousel", + "[attr.aria-label]": "translationService.track('carousel')()", + "aria-live": "off", + "[class]": "classes()", + }, +}) +export class CarouselContentComponent implements AfterViewInit, OnDestroy { + /** Slides per view (minimum 1, can be fractional, e.g. 1.25 for peeking) */ + readonly slidesPerView = input( + { xs: 1 }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + + /** Gap between slides in px */ + readonly gap = input( + { xs: 16 }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + + /** Should carousel have fade? In mobile both left and right, in desktop only right. */ + readonly fade = input(false); + + /** Transition duration in ms */ + readonly transitionMs = input(400); + + readonly translationService = inject(TediTranslationService); + private readonly breakpointService = inject(BreakpointService); + private readonly host = inject>(ElementRef); + + readonly track = viewChild.required>("track"); + readonly slides = contentChildren(CarouselSlideDirective); + + readonly trackIndex = signal(0); + readonly animate = signal(false); + readonly viewportWidth = signal(0); + private readonly windowBase = signal(0); + + readonly currentSlidesPerView = computed(() => { + if ( + this.slidesPerView().xxl && + this.breakpointService.isAboveBreakpoint("xxl")() + ) { + return this.slidesPerView().xxl as number; + } else if ( + this.slidesPerView().xl && + this.breakpointService.isAboveBreakpoint("xl")() + ) { + return this.slidesPerView().xl as number; + } else if ( + this.slidesPerView().lg && + this.breakpointService.isAboveBreakpoint("lg")() + ) { + return this.slidesPerView().lg as number; + } else if ( + this.slidesPerView().md && + this.breakpointService.isAboveBreakpoint("md")() + ) { + return this.slidesPerView().md as number; + } else if ( + this.slidesPerView().sm && + this.breakpointService.isAboveBreakpoint("sm")() + ) { + return this.slidesPerView().sm as number; + } else { + return this.slidesPerView().xs; + } + }); + + readonly currentGap = computed(() => { + if (this.gap().xxl && this.breakpointService.isAboveBreakpoint("xxl")()) { + return this.gap().xxl as number; + } else if ( + this.gap().xl && + this.breakpointService.isAboveBreakpoint("xl")() + ) { + return this.gap().xl as number; + } else if ( + this.gap().lg && + this.breakpointService.isAboveBreakpoint("lg")() + ) { + return this.gap().lg as number; + } else if ( + this.gap().md && + this.breakpointService.isAboveBreakpoint("md")() + ) { + return this.gap().md as number; + } else if ( + this.gap().sm && + this.breakpointService.isAboveBreakpoint("sm")() + ) { + return this.gap().sm as number; + } else { + return this.gap().xs; + } + }); + + readonly buffer = computed(() => this.slides().length); + + readonly slideIndex = computed(() => { + const slidesCount = this.slides().length; + + if (slidesCount === 0) { + return 0; + } + + const i = Math.floor(this.trackIndex()); + return ((i % slidesCount) + slidesCount) % slidesCount; + }); + + readonly renderedIndices = computed(() => { + const slidesCount = this.slides().length; + + if (!slidesCount) { + return []; + } + + const total = 2 * this.buffer() + Math.ceil(this.currentSlidesPerView()); + const start = this.windowBase() - this.buffer(); + + return Array.from( + { length: total }, + (_, i) => (((start + i) % slidesCount) + slidesCount) % slidesCount, + ); + }); + + readonly slideFlex = computed(() => { + const slidesPerView = this.currentSlidesPerView(); + const gap = this.currentGap(); + return `0 0 calc((100% - ${(slidesPerView - 1) * gap}px) / ${slidesPerView})`; + }); + + readonly classes = computed(() => { + const classList = ["tedi-carousel__content"]; + + if (this.fade() && this.currentSlidesPerView() > 1) { + classList.push("tedi-carousel__content--fade-right"); + } else if (this.fade() && this.currentSlidesPerView() <= 1) { + classList.push("tedi-carousel__content--fade-x"); + } + + return classList.join(" "); + }); + + readonly trackStyle = computed(() => { + const slidesPerView = this.currentSlidesPerView(); + const gap = this.currentGap(); + const viewportWidth = this.viewportWidth(); + + if (!viewportWidth) { + return { + gap: `${gap}px`, + transform: "translate3d(0,0,0)", + transition: "none", + }; + } + + const totalGapWidth = gap * (slidesPerView - 1); + const slideWidth = (viewportWidth - totalGapWidth) / slidesPerView; + + const offsetSlides = this.trackIndex() - this.windowBase() + this.buffer(); + const translateX = -offsetSlides * (slideWidth + gap); + + return { + gap: `${gap}px`, + transform: `translate3d(${translateX}px, 0, 0)`, + transition: this.animate() + ? `transform ${this.transitionMs()}ms ease` + : "none", + }; + }); + + locked = false; + dragging = false; + private startX = 0; + private startIndex = 0; + private ro?: ResizeObserver; + private wheelTimeout?: ReturnType; + private scrollDelta = 0; + + @HostListener("wheel", ["$event"]) + onWheel(event: WheelEvent) { + const slidesCount = this.slides().length; + + if (!slidesCount) { + return; + } + + const delta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.shiftKey + ? event.deltaY + : 0; + + if (!delta) { + return; + } + + event.preventDefault(); + + const cellWidth = + (this.viewportWidth() - + this.currentGap() * (this.currentSlidesPerView() - 1)) / + this.currentSlidesPerView() + + this.currentGap(); + + if (!cellWidth) { + return; + } + + const deltaSlides = delta / cellWidth; + this.scrollDelta += deltaSlides; + + const maxDelta = this.buffer() * 0.9; + const min = this.windowBase() - maxDelta; + const max = this.windowBase() + maxDelta; + + const unclamped = this.trackIndex() + deltaSlides; + const clamped = Math.min(Math.max(unclamped, min), max); + const wasClamped = clamped !== unclamped; + + this.animate.set(false); + this.trackIndex.set(clamped); + + clearTimeout(this.wheelTimeout); + + this.wheelTimeout = setTimeout(() => { + this.animate.set(true); + + const direction = Math.sign(this.scrollDelta); + const current = this.trackIndex(); + + let snapIndex = Math.round(current); + + if (Math.abs(this.scrollDelta) > 0.3) { + snapIndex = direction > 0 ? Math.ceil(current) : Math.floor(current); + } + + if (wasClamped) { + if (current <= min) snapIndex = Math.ceil(min); + if (current >= max) snapIndex = Math.floor(max); + } + + const finalIndex = Math.min(Math.max(snapIndex, min), max); + this.trackIndex.set(finalIndex); + this.scrollDelta = 0; + }, 120); + } + + @HostListener("keydown", ["$event"]) + onKeyDown(event: KeyboardEvent) { + switch (event.key) { + case "ArrowRight": + case "PageDown": + event.preventDefault(); + this.next(); + break; + + case "ArrowLeft": + case "PageUp": + event.preventDefault(); + this.prev(); + break; + + case "Home": + event.preventDefault(); + this.goToIndex(0); + break; + + case "End": { + event.preventDefault(); + this.goToIndex(this.slides().length - 1); + break; + } + + default: + break; + } + } + + @HostListener("pointerdown", ["$event"]) + onPointerDown(ev: PointerEvent) { + if (!this.slides().length) { + return; + } + + this.host.nativeElement.setPointerCapture(ev.pointerId); + this.dragging = true; + this.animate.set(false); + this.startX = ev.clientX; + this.startIndex = this.trackIndex(); + this.windowBase.set(Math.floor(this.startIndex)); + } + + @HostListener("pointermove", ["$event"]) + onPointerMove(ev: PointerEvent) { + if (!this.dragging) { + return; + } + + const dx = ev.clientX - this.startX; + const cellWidth = + (this.viewportWidth() - + this.currentGap() * (this.currentSlidesPerView() - 1)) / + this.currentSlidesPerView() + + this.currentGap(); + + if (!cellWidth) { + return; + } + + const deltaSlides = dx / cellWidth; + const targetIndex = this.startIndex - deltaSlides; + + const maxDelta = this.buffer() * 0.9; + const min = this.windowBase() - maxDelta; + const max = this.windowBase() + maxDelta; + const clamped = Math.min(Math.max(targetIndex, min), max); + this.trackIndex.set(clamped); + } + + @HostListener("pointerup") + @HostListener("pointercancel") + @HostListener("lostpointercapture") + onPointerUp() { + if (!this.dragging) { + return; + } + + this.dragging = false; + this.animate.set(true); + this.trackIndex.set(Math.round(this.trackIndex())); + } + + ngAfterViewInit(): void { + const viewport = this.host.nativeElement; + this.viewportWidth.set(viewport.clientWidth); + + this.ro = new ResizeObserver(() => { + this.viewportWidth.set(viewport.clientWidth); + }); + + this.ro.observe(viewport); + } + + ngOnDestroy(): void { + this.ro?.disconnect(); + } + + next(): void { + if (!this.slides().length || this.locked) { + return; + } + + this.animate.set(true); + this.trackIndex.update((i) => i + 1); + this.lockNavigation(); + } + + prev(): void { + if (!this.slides().length || this.locked) { + return; + } + + this.animate.set(true); + this.trackIndex.update((i) => i - 1); + this.lockNavigation(); + } + + goToIndex(index: number) { + const slidesCount = this.slides().length; + + if (!slidesCount || this.locked) { + return; + } + + const current = this.slideIndex(); + const normalized = ((index % slidesCount) + slidesCount) % slidesCount; + const delta = normalized - current; + this.animate.set(true); + this.trackIndex.update((i) => i + delta); + } + + onTransitionEnd(e: TransitionEvent) { + if ( + e.target !== this.track().nativeElement || + e.propertyName !== "transform" || + this.dragging + ) { + return; + } + + this.animate.set(false); + this.windowBase.set(Math.floor(this.trackIndex())); + } + + lockNavigation() { + this.locked = true; + setTimeout(() => (this.locked = false), this.transitionMs()); + } +} diff --git a/tedi/components/content/carousel/carousel-footer/carousel-footer.component.scss b/tedi/components/content/carousel/carousel-footer/carousel-footer.component.scss new file mode 100644 index 000000000..3cb9091fd --- /dev/null +++ b/tedi/components/content/carousel/carousel-footer/carousel-footer.component.scss @@ -0,0 +1,7 @@ +@use "@tedi-design-system/core/mixins"; + +tedi-carousel-footer { + display: flex; + align-items: center; + justify-content: space-between; +} diff --git a/tedi/components/content/carousel/carousel-footer/carousel-footer.component.ts b/tedi/components/content/carousel/carousel-footer/carousel-footer.component.ts new file mode 100644 index 000000000..289e665cb --- /dev/null +++ b/tedi/components/content/carousel/carousel-footer/carousel-footer.component.ts @@ -0,0 +1,15 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from "@angular/core"; + +@Component({ + standalone: true, + selector: "tedi-carousel-footer", + template: "", + styleUrl: "./carousel-footer.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class CarouselFooterComponent {} diff --git a/tedi/components/content/carousel/carousel-header/carousel-header.component.scss b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss new file mode 100644 index 000000000..179163e66 --- /dev/null +++ b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss @@ -0,0 +1,10 @@ +@use "@tedi-design-system/core/mixins"; + +tedi-carousel-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + + @include mixins.responsive-styles(gap, layout-grid-gutters-16); + @include mixins.responsive-styles(padding-bottom, layout-grid-gutters-08); +} diff --git a/tedi/components/content/carousel/carousel-header/carousel-header.component.ts b/tedi/components/content/carousel/carousel-header/carousel-header.component.ts new file mode 100644 index 000000000..513ec8f07 --- /dev/null +++ b/tedi/components/content/carousel/carousel-header/carousel-header.component.ts @@ -0,0 +1,15 @@ +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from "@angular/core"; + +@Component({ + standalone: true, + selector: "tedi-carousel-header", + template: "", + styleUrl: "./carousel-header.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class CarouselHeaderComponent {} diff --git a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html new file mode 100644 index 000000000..83b4c0be4 --- /dev/null +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html @@ -0,0 +1,42 @@ +@if (withArrows()) { + +} + +@if (variant() === "dots") { + @for (indicator of indicatorsArray(); track indicator.index) { + + } +} @else if (activeSlideNumber()) { +
+ {{ activeSlideNumber() }} + / {{ indicatorsArray().length }} +
+} + +@if (withArrows()) { + +} diff --git a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss new file mode 100644 index 000000000..186681b0c --- /dev/null +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss @@ -0,0 +1,36 @@ +@use "@tedi-design-system/core/mixins"; + +tedi-carousel-indicators { + display: flex; + align-items: center; + min-height: 24px; + + @include mixins.responsive-styles(gap, layout-grid-gutters-04); +} + +.tedi-carousel__indicator { + width: 22px; + height: 8px; + border-radius: 100px; + border: 1px solid var(--primary-600); + background-color: transparent; + cursor: pointer; + + &:hover { + border-color: var(--primary-700); + } + + &:active { + border-color: var(--primary-800); + background-color: var(--primary-800); + } + + &:focus-visible { + outline: var(--borders-02) solid var(--primary-500); + outline-offset: var(--borders-01); + } + + &--active { + background-color: var(--primary-600); + } +} diff --git a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts new file mode 100644 index 000000000..e18874c82 --- /dev/null +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts @@ -0,0 +1,62 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + ViewEncapsulation, +} from "@angular/core"; +import { CarouselComponent } from "../carousel.component"; +import { NgClass } from "@angular/common"; +import { ButtonComponent } from "../../../buttons/button/button.component"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TextComponent } from "../../../base/text/text.component"; +import { TediTranslationService } from "../../../../services"; + +export type CarouselIndicatorsVariant = "dots" | "numbers"; + +@Component({ + standalone: true, + selector: "tedi-carousel-indicators", + templateUrl: "./carousel-indicators.component.html", + styleUrl: "./carousel-indicators.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [NgClass, ButtonComponent, IconComponent, TextComponent], +}) +export class CarouselIndicatorsComponent { + /** Should show indicators with arrows? If yes, don't use carousel-navigation component */ + readonly withArrows = input(false); + + /** Variant of indicators (dots and numbers) */ + readonly variant = input("dots"); + + readonly translationService = inject(TediTranslationService); + readonly carousel = inject(CarouselComponent); + + readonly indicatorsArray = computed(() => + Array.from( + { length: this.carousel.carouselContent().slides().length }, + (_, i) => ({ + index: i, + active: this.carousel.carouselContent().slideIndex() === i, + }), + ), + ); + + readonly activeSlideNumber = computed( + () => this.carousel.carouselContent().slideIndex() + 1, + ); + + handleNext() { + this.carousel.carouselContent().next(); + } + + handlePrev() { + this.carousel.carouselContent().prev(); + } + + handleIndicatorClick(index: number) { + this.carousel.carouselContent().goToIndex(index); + } +} diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html new file mode 100644 index 000000000..c0f1019b1 --- /dev/null +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html @@ -0,0 +1,18 @@ + + diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss new file mode 100644 index 000000000..cea8af608 --- /dev/null +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss @@ -0,0 +1,8 @@ +@use "@tedi-design-system/core/mixins"; + +tedi-carousel-navigation { + display: flex; + align-items: center; + + @include mixins.responsive-styles(gap, layout-grid-gutters-08); +} diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts new file mode 100644 index 000000000..309bb60b7 --- /dev/null +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts @@ -0,0 +1,32 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + ViewEncapsulation, +} from "@angular/core"; +import { ButtonComponent } from "../../../buttons"; +import { IconComponent } from "../../../base"; +import { CarouselComponent } from "../carousel.component"; +import { TediTranslationService } from "../../../../services"; + +@Component({ + standalone: true, + selector: "tedi-carousel-navigation", + templateUrl: "./carousel-navigation.component.html", + styleUrl: "./carousel-navigation.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ButtonComponent, IconComponent], +}) +export class CarouselNavigationComponent { + readonly translationService = inject(TediTranslationService); + private readonly carousel = inject(CarouselComponent); + + handleNext() { + this.carousel.carouselContent().next(); + } + + handlePrev() { + this.carousel.carouselContent().prev(); + } +} diff --git a/tedi/components/content/carousel/carousel-slide.directive.ts b/tedi/components/content/carousel/carousel-slide.directive.ts new file mode 100644 index 000000000..5cb433ba8 --- /dev/null +++ b/tedi/components/content/carousel/carousel-slide.directive.ts @@ -0,0 +1,9 @@ +import { Directive, inject, TemplateRef } from "@angular/core"; + +@Directive({ + selector: "[tediCarouselSlide]", + standalone: true, +}) +export class CarouselSlideDirective { + template = inject(TemplateRef); +} diff --git a/tedi/components/content/carousel/carousel-slide/carousel-slide.component.scss b/tedi/components/content/carousel/carousel-slide/carousel-slide.component.scss new file mode 100644 index 000000000..7643b64a0 --- /dev/null +++ b/tedi/components/content/carousel/carousel-slide/carousel-slide.component.scss @@ -0,0 +1,4 @@ +tedi-carousel-slide { + user-select: none; + -webkit-user-drag: none; +} diff --git a/tedi/components/content/carousel/carousel.component.html b/tedi/components/content/carousel/carousel.component.html new file mode 100644 index 000000000..ef1258231 --- /dev/null +++ b/tedi/components/content/carousel/carousel.component.html @@ -0,0 +1,3 @@ + + + diff --git a/tedi/components/content/carousel/carousel.component.scss b/tedi/components/content/carousel/carousel.component.scss new file mode 100644 index 000000000..6918eccd4 --- /dev/null +++ b/tedi/components/content/carousel/carousel.component.scss @@ -0,0 +1,8 @@ +@use "@tedi-design-system/core/mixins"; + +tedi-carousel { + display: flex; + flex-direction: column; + + @include mixins.responsive-styles(gap, layout-grid-gutters-08); +} diff --git a/tedi/components/content/carousel/carousel.component.spec.ts b/tedi/components/content/carousel/carousel.component.spec.ts new file mode 100644 index 000000000..594a0cf9e --- /dev/null +++ b/tedi/components/content/carousel/carousel.component.spec.ts @@ -0,0 +1,538 @@ +import { Component, ElementRef, signal } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { CarouselContentComponent } from "./carousel-content/carousel-content.component"; +import { + Breakpoint, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { CarouselIndicatorsComponent } from "./carousel-indicators/carousel-indicators.component"; +import { CarouselComponent } from "./carousel.component"; +import { CarouselNavigationComponent } from "./carousel-navigation/carousel-navigation.component"; + +function dispatchPointerLike( + el: HTMLElement, + type: "pointerdown" | "pointermove" | "pointerup" | "lostpointercapture", + props: { clientX?: number; pointerId?: number } = {}, +) { + const ev = new Event(type, { bubbles: true, cancelable: true }); + if (props.clientX !== undefined) { + Object.defineProperty(ev, "clientX", { value: props.clientX }); + } + if (props.pointerId !== undefined) { + Object.defineProperty(ev, "pointerId", { value: props.pointerId }); + } + el.dispatchEvent(ev); + return ev; +} + +describe("CarouselContentComponent", () => { + let fixture: ComponentFixture; + let component: CarouselContentComponent; + let hostElement: HTMLElement; + + let mockBreakpointService: { + isAboveBreakpoint: () => ReturnType; + }; + let mockTranslationService: { track: jest.Mock }; + let fakeViewport: HTMLDivElement; + + beforeEach(async () => { + class MockResizeObserver { + callback: ResizeObserverCallback; + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + constructor(cb: ResizeObserverCallback) { + this.callback = cb; + } + } + + global.ResizeObserver = + MockResizeObserver as unknown as typeof ResizeObserver; + + fakeViewport = document.createElement("div"); + fakeViewport.style.width = "1000px"; + + mockBreakpointService = { + isAboveBreakpoint: () => signal(false), + }; + + mockTranslationService = { + track: jest.fn((key: string) => () => key), + }; + + await TestBed.configureTestingModule({ + imports: [CarouselContentComponent], + providers: [ + { provide: BreakpointService, useValue: mockBreakpointService }, + { provide: TediTranslationService, useValue: mockTranslationService }, + { provide: ElementRef, useValue: new ElementRef(fakeViewport) }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CarouselContentComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + hostElement = fixture.nativeElement; + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should have correct base aria attributes", () => { + expect(hostElement.getAttribute("role")).toBe("region"); + expect(hostElement.getAttribute("aria-roledescription")).toBe("carousel"); + expect(hostElement.getAttribute("aria-live")).toBe("off"); + }); + + it("should call translationService.track for aria label", () => { + expect(mockTranslationService.track).toHaveBeenCalledWith("carousel"); + }); + + it("should compute correct flex style for slides", () => { + const flex = component.slideFlex(); + expect(flex).toContain("calc("); + expect(flex).toContain("100%"); + }); + + it("should clamp slideIndex when no slides exist", () => { + expect(component.slideIndex()).toBe(0); + }); + + it("should compute trackStyle correctly when viewportWidth is 0", () => { + const style = component.trackStyle(); + expect(style.transform).toBe("translate3d(0,0,0)"); + expect(style.transition).toBe("none"); + }); + + it("should not fail if ngOnDestroy called without ResizeObserver", () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + + it("should handle wheel event and update trackIndex", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + const event = new WheelEvent("wheel", { deltaX: 120 }); + const preventDefaultSpy = jest.spyOn(event, "preventDefault"); + component.onWheel(event); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(component.trackIndex()).not.toBe(0); + }); + + it("should reset animation on transition end for track transform", () => { + const fakeNative = {}; + Object.defineProperty(component, "track", { + configurable: true, + value: () => ({ nativeElement: fakeNative }), + }); + + component.animate.set(true); + component.trackIndex.set(2); + + const evt = { + target: fakeNative, + propertyName: "transform", + } as TransitionEvent; + + component.onTransitionEnd(evt); + expect(component.animate()).toBe(false); + }); + + it("should handle onTransitionEnd and reset animation flags", () => { + const fakeNative = {}; + Object.defineProperty(component, "track", { + configurable: true, + value: () => ({ nativeElement: fakeNative }), + }); + + component.animate.set(true); + component.trackIndex.set(2); + + const event = { + target: fakeNative, + propertyName: "transform", + } as TransitionEvent; + + component.onTransitionEnd(event); + expect(component.animate()).toBe(false); + }); + + it("should call next and increase trackIndex", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + const initial = component.trackIndex(); + component.next(); + expect(component.trackIndex()).toBeGreaterThan(initial); + }); + + it("should call prev and decrease trackIndex", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + component.trackIndex.set(2); + component.prev(); + expect(component.trackIndex()).toBeLessThan(2); + }); + + it("should not navigate when locked", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + component.locked = true; + const before = component.trackIndex(); + component.next(); + expect(component.trackIndex()).toBe(before); + }); + + it("should goToIndex and update trackIndex correctly", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + component.trackIndex.set(0); + component.goToIndex(2); + expect(component.trackIndex()).not.toBe(0); + }); + + it("should handle ArrowRight and call next()", () => { + const spy = jest.spyOn(component, "next"); + const event = new KeyboardEvent("keydown", { key: "ArrowRight" }); + component.onKeyDown(event); + expect(spy).toHaveBeenCalled(); + }); + + it("should handle ArrowLeft and call prev()", () => { + const spy = jest.spyOn(component, "prev"); + const event = new KeyboardEvent("keydown", { key: "ArrowLeft" }); + component.onKeyDown(event); + expect(spy).toHaveBeenCalled(); + }); + + it("should handle Home key and go to first slide", () => { + const spy = jest.spyOn(component, "goToIndex"); + const event = new KeyboardEvent("keydown", { key: "Home" }); + component.onKeyDown(event); + expect(spy).toHaveBeenCalledWith(0); + }); + + it("should handle End key and go to last slide", () => { + const spy = jest.spyOn(component, "goToIndex"); + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + const event = new KeyboardEvent("keydown", { key: "End" }); + component.onKeyDown(event); + expect(spy).toHaveBeenCalledWith(2); + }); + + it("starts dragging on pointerdown and uses setPointerCapture", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + hostElement.setPointerCapture = jest.fn(); + + dispatchPointerLike(hostElement, "pointerdown", { + clientX: 120, + pointerId: 42, + }); + + expect(hostElement.setPointerCapture).toHaveBeenCalledWith(42); + expect(component.dragging).toBe(true); + expect(component.animate()).toBe(false); + }); + + it("should handle pointer up and stop dragging", () => { + component.dragging = true; + component.animate.set(false); + component.trackIndex.set(1.6); + component.onPointerUp(); + expect(component.dragging).toBe(false); + expect(component.animate()).toBe(true); + expect(component.trackIndex()).toBe(Math.round(1.6)); + }); + + it("should compute trackStyle correctly with viewportWidth set", () => { + component.viewportWidth.set(1000); + component.trackIndex.set(2); + const style = component.trackStyle(); + expect(style.transform).toContain("translate3d("); + expect(style.gap).toContain("px"); + }); + + it("should compute slideIndex properly when slides exist", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + + component.trackIndex.set(5); + const index = component.slideIndex(); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(3); + }); + + it("should apply fade-right class when fade true and slidesPerView > 1", () => { + fixture.componentRef.setInput("slidesPerView", { xs: 2 }); + fixture.componentRef.setInput("fade", true); + fixture.detectChanges(); + expect(component.classes()).toContain("tedi-carousel__content--fade-right"); + }); + + it("should apply fade-x class when fade true and slidesPerView <= 1", () => { + fixture.componentRef.setInput("slidesPerView", { xs: 1 }); + fixture.componentRef.setInput("fade", true); + fixture.detectChanges(); + expect(component.classes()).toContain("tedi-carousel__content--fade-x"); + }); + + it("should respect breakpoint priority for currentSlidesPerView", () => { + const slidesPerView: Record = { + xs: 1, + sm: 2, + md: 3, + lg: 4, + xl: 5, + xxl: 6, + }; + fixture.componentRef.setInput("slidesPerView", slidesPerView); + fixture.detectChanges(); + + const breakpoints = Object.keys(slidesPerView) as Breakpoint[]; + breakpoints.forEach((bp) => { + fixture.whenStable().then(() => { + mockBreakpointService.isAboveBreakpoint().set(true); + expect(component.currentSlidesPerView()).toBe(slidesPerView[bp]); + }); + }); + }); + + it("should respect breakpoint priority for currentGap", () => { + const gaps: Record = { + xs: 2, + sm: 4, + md: 6, + lg: 8, + xl: 10, + xxl: 12, + }; + fixture.componentRef.setInput("gap", gaps); + fixture.detectChanges(); + + const breakpoints = Object.keys(gaps) as Breakpoint[]; + breakpoints.forEach((bp) => { + mockBreakpointService.isAboveBreakpoint().set(true); + fixture.whenStable().then(() => { + expect(component.currentGap()).toBe(gaps[bp]); + }); + }); + }); + + it("should unlock navigation after transition timeout", () => { + jest.useFakeTimers(); + component.locked = false; + component.lockNavigation(); + expect(component.locked).toBe(true); + jest.advanceTimersByTime(component.transitionMs()); + expect(component.locked).toBe(false); + jest.useRealTimers(); + }); + + it("should handle wheel deltaY when shiftKey is pressed", () => { + Object.defineProperty(component, "slides", { + configurable: true, + value: () => [{}, {}, {}], + }); + component.viewportWidth.set(1000); + const event = new WheelEvent("wheel", { deltaY: 200, shiftKey: true }); + const preventDefaultSpy = jest.spyOn(event, "preventDefault"); + component.onWheel(event); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(component.trackIndex()).not.toBe(0); + }); +}); + +@Component({ + standalone: true, + imports: [CarouselIndicatorsComponent], + template: ` + + `, +}) +class TestIndicatorsHostComponent { + withArrows = false; + variant: "dots" | "numbers" = "dots"; +} + +describe("CarouselIndicatorsComponent", () => { + let fixture: ComponentFixture; + let component: CarouselIndicatorsComponent; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockCarouselContent: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockCarousel: any; + let mockTranslationService: { track: jest.Mock }; + + beforeEach(async () => { + mockCarouselContent = { + slides: jest.fn().mockReturnValue([{}, {}, {}]), + slideIndex: jest.fn().mockReturnValue(1), + next: jest.fn(), + prev: jest.fn(), + goToIndex: jest.fn(), + }; + + mockCarousel = { + carouselContent: jest.fn().mockReturnValue(mockCarouselContent), + }; + + mockTranslationService = { + track: jest.fn((key: string) => () => key), + }; + + await TestBed.configureTestingModule({ + imports: [TestIndicatorsHostComponent], + providers: [ + { provide: CarouselComponent, useValue: mockCarousel }, + { provide: TediTranslationService, useValue: mockTranslationService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestIndicatorsHostComponent); + fixture.detectChanges(); + + const indicatorsDebug = fixture.debugElement.query( + By.directive(CarouselIndicatorsComponent), + ); + component = indicatorsDebug.componentInstance; + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should inject CarouselComponent and TranslationService", () => { + expect(component.carousel).toBe(mockCarousel); + expect(component.translationService).toBe(mockTranslationService); + }); + + it("should have default input values", () => { + expect(component.withArrows()).toBe(false); + expect(component.variant()).toBe("dots"); + }); + + it("should compute correct indicatorsArray", () => { + const arr = component.indicatorsArray(); + expect(arr.length).toBe(3); + expect(arr[1].active).toBe(true); + }); + + it("should compute correct activeSlideNumber", () => { + expect(component.activeSlideNumber()).toBe(2); + }); + + it("should call carouselContent.next() when handleNext() is triggered", () => { + component.handleNext(); + expect(mockCarouselContent.next).toHaveBeenCalled(); + }); + + it("should call carouselContent.prev() when handlePrev() is triggered", () => { + component.handlePrev(); + expect(mockCarouselContent.prev).toHaveBeenCalled(); + }); + + it("should call carouselContent.goToIndex() when handleIndicatorClick() is triggered", () => { + component.handleIndicatorClick(2); + expect(mockCarouselContent.goToIndex).toHaveBeenCalledWith(2); + }); +}); + +@Component({ + standalone: true, + imports: [CarouselNavigationComponent], + template: ` `, +}) +class TestNavigationHostComponent {} + +describe("CarouselNavigationComponent", () => { + let fixture: ComponentFixture; + let component: CarouselNavigationComponent; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockCarouselContent: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockCarousel: any; + let mockTranslationService: { track: jest.Mock }; + + beforeEach(async () => { + mockCarouselContent = { + next: jest.fn(), + prev: jest.fn(), + }; + + mockCarousel = { + carouselContent: jest.fn().mockReturnValue(mockCarouselContent), + }; + + mockTranslationService = { + track: jest.fn((key: string) => () => key), + }; + + await TestBed.configureTestingModule({ + imports: [TestNavigationHostComponent], + providers: [ + { provide: CarouselComponent, useValue: mockCarousel }, + { provide: TediTranslationService, useValue: mockTranslationService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestNavigationHostComponent); + fixture.detectChanges(); + + const navDebug = fixture.debugElement.query( + By.directive(CarouselNavigationComponent), + ); + component = navDebug.componentInstance; + }); + + it("should create component", () => { + expect(component).toBeTruthy(); + }); + + it("should inject CarouselComponent and TranslationService", () => { + expect(component["carousel"]).toBe(mockCarousel); + expect(component.translationService).toBe(mockTranslationService); + }); + + it("should call carouselContent.next() when handleNext() is called", () => { + component.handleNext(); + expect(mockCarouselContent.next).toHaveBeenCalledTimes(1); + }); + + it("should call carouselContent.prev() when handlePrev() is called", () => { + component.handlePrev(); + expect(mockCarouselContent.prev).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tedi/components/content/carousel/carousel.component.ts b/tedi/components/content/carousel/carousel.component.ts new file mode 100644 index 000000000..6061f9e59 --- /dev/null +++ b/tedi/components/content/carousel/carousel.component.ts @@ -0,0 +1,19 @@ +import { + ChangeDetectionStrategy, + Component, + contentChild, + ViewEncapsulation, +} from "@angular/core"; +import { CarouselContentComponent } from "./carousel-content/carousel-content.component"; + +@Component({ + standalone: true, + selector: "tedi-carousel", + templateUrl: "./carousel.component.html", + styleUrl: "./carousel.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CarouselComponent { + carouselContent = contentChild.required(CarouselContentComponent); +} diff --git a/tedi/components/content/carousel/carousel.stories.ts b/tedi/components/content/carousel/carousel.stories.ts new file mode 100644 index 000000000..5a1816d3d --- /dev/null +++ b/tedi/components/content/carousel/carousel.stories.ts @@ -0,0 +1,689 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { CarouselComponent } from "./carousel.component"; +import { CarouselHeaderComponent } from "./carousel-header/carousel-header.component"; +import { CarouselNavigationComponent } from "./carousel-navigation/carousel-navigation.component"; +import { CarouselFooterComponent } from "./carousel-footer/carousel-footer.component"; +import { CarouselContentComponent } from "./carousel-content/carousel-content.component"; +import { + CarouselIndicatorsComponent, + CarouselIndicatorsVariant, +} from "./carousel-indicators/carousel-indicators.component"; +import { CarouselSlideDirective } from "./carousel-slide.directive"; +import { TextComponent } from "../../base/text/text.component"; +import { BreakpointInput } from "../../../services/breakpoint/breakpoint.service"; +import { IconComponent } from "../../base/icon/icon.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; + +/** + * Figma ↗
+ * Zeroheight ↗ + */ + +export default { + title: "TEDI-Ready/Content/Carousel", + component: CarouselComponent, + decorators: [ + moduleMetadata({ + imports: [ + CarouselComponent, + CarouselSlideDirective, + CarouselHeaderComponent, + CarouselFooterComponent, + CarouselNavigationComponent, + CarouselContentComponent, + CarouselIndicatorsComponent, + TextComponent, + IconComponent, + ButtonComponent, + ], + }), + ], + argTypes: { + slidesPerView: { + description: + "Slides per view (minimum 1, can be fractional, e.g. 1.25 for peeking)", + control: { + type: "object", + }, + table: { + category: "Carousel Content", + type: { + summary: "number | BreakpointObject", + detail: + "number | { \n xs: number; \n sm?: number; \n md?: number; \n lg?: number; \n xl?: number; \n xxl?: number \n}", + }, + defaultValue: { summary: "1" }, + }, + }, + gap: { + description: "Gap between slides in px", + control: { + type: "object", + }, + table: { + category: "Carousel Content", + type: { + summary: "number | BreakpointObject", + detail: + "number | { \n xs: number; \n sm?: number; \n md?: number; \n lg?: number; \n xl?: number; \n xxl?: number \n}", + }, + defaultValue: { summary: "16" }, + }, + }, + fade: { + description: + "Should carousel have fade? In mobile both left and right, in desktop only right.", + control: { + type: "boolean", + }, + table: { + category: "Carousel Content", + type: { + summary: "boolean", + }, + defaultValue: { summary: "false" }, + }, + }, + transitionMs: { + description: "Transition duration in ms", + control: { + type: "number", + }, + table: { + category: "Carousel Content", + type: { + summary: "number", + }, + defaultValue: { summary: "400" }, + }, + }, + withArrows: { + description: + "Should show indicators with arrows? If yes, don't use carousel-navigation component", + control: { + type: "boolean", + }, + table: { + category: "Carousel Indicators", + type: { + summary: "boolean", + }, + defaultValue: { summary: "false" }, + }, + }, + variant: { + description: "Variant of indicators (dots and numbers)", + control: "radio", + options: ["dots", "numbers"], + table: { + category: "Carousel Indicators", + type: { + summary: "CarouselIndicatorsVariant", + detail: "'dots' | 'numbers'", + }, + defaultValue: { summary: "dots" }, + }, + }, + }, +} as Meta; + +type CarouselType = CarouselComponent & { + slidesPerView: BreakpointInput; + gap: BreakpointInput; + fade: boolean; + transitionMs: number; + withArrows: boolean; + variant: CarouselIndicatorsVariant; +}; + +export const Default: StoryObj = { + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + gap: { xs: 16 }, + fade: false, + transitionMs: 400, + withArrows: false, + variant: "dots", + }, + render: (args) => ({ + props: args, + template: ` + + +
+

Title

+

Description

+
+ +
+ + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const TopPaginationArrowsOnly: StoryObj = { + name: "Top pagination - arrows only", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+ +
+ + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+
+ `, + }), +}; + +export const SeparatedBottomPaginationHasDots: StoryObj = { + name: "Separated bottom pagination - has dots", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + + +
+ `, + }), +}; + +export const SeparatedBottomPaginationHasNumbers: StoryObj = { + name: "Separated bottom pagination - has numbers", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + + +
+ `, + }), +}; + +export const CenteredBottomPaginationHasDots: StoryObj = { + name: "Centered bottom pagination - has dots", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2, 3, 4, 5]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const CenteredBottomPaginationHasNumbers: StoryObj = { + name: "Centered bottom pagination - has numbers", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2, 3, 4, 5]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const CombinationsTopNavigationBottomDots: StoryObj = { + name: "Combinations - top navigation, bottom dots", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+ +
+ + @for (i of [0, 1, 2, 3, 4, 5]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const CenteredHasDots: StoryObj = { + name: "Centered - has dots", + args: { + slidesPerView: { xs: 1 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const CenteredHasNumbers: StoryObj = { + name: "Centered - has numbers", + args: { + slidesPerView: { xs: 1 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const SeparatedHasDots: StoryObj = { + name: "Separated - has dots", + args: { + slidesPerView: { xs: 1 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + + +
+ `, + }), +}; + +export const SeparatedHasNumbers: StoryObj = { + name: "Separated - has numbers", + args: { + slidesPerView: { xs: 1 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + + +
+ `, + }), +}; + +export const Combination: StoryObj = { + name: "Combination", + args: { + slidesPerView: { xs: 1 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+ +
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+ `, + }), +}; + +export const Fade: StoryObj = { + name: "Fade", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + fade: true, + }, + render: (args) => ({ + props: args, + template: ` +
+ + +

Title

+
+ + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + + +
+ + + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + +
+
+ `, + }), +}; + +export const WithTopNavigation: StoryObj = { + name: "With top navigation", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+ +
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+
+ `, + }), +}; + +export const WithTopAction: StoryObj = { + name: "With top action", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

+ +
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+ + + + +
+ `, + }), +}; + +export const WithDescription: StoryObj = { + name: "With description", + args: { + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, + }, + render: (args) => ({ + props: args, + template: ` + + +
+

Title

+

Description

+
+ +
+ + @for (i of [0, 1, 2]; track $index) { + +
+ +
Replace with your own content
+
+
+ } +
+
+ `, + }), +}; diff --git a/tedi/components/content/carousel/index.ts b/tedi/components/content/carousel/index.ts new file mode 100644 index 000000000..75a91f45d --- /dev/null +++ b/tedi/components/content/carousel/index.ts @@ -0,0 +1,7 @@ +export * from "./carousel.component"; +export * from "./carousel-slide.directive"; +export * from "./carousel-content/carousel-content.component"; +export * from "./carousel-footer/carousel-footer.component"; +export * from "./carousel-header/carousel-header.component"; +export * from "./carousel-indicators/carousel-indicators.component"; +export * from "./carousel-navigation/carousel-navigation.component"; diff --git a/tedi/components/content/index.ts b/tedi/components/content/index.ts index de5d9d3e8..b60963560 100644 --- a/tedi/components/content/index.ts +++ b/tedi/components/content/index.ts @@ -1,2 +1,3 @@ export * from "./list/list.component"; export * from "./text-group/"; +export * from "./carousel"; diff --git a/tedi/services/breakpoint/breakpoint.service.ts b/tedi/services/breakpoint/breakpoint.service.ts index 8e53ab6bc..5abdf65a5 100644 --- a/tedi/services/breakpoint/breakpoint.service.ts +++ b/tedi/services/breakpoint/breakpoint.service.ts @@ -1,4 +1,10 @@ -import { computed, Injectable, InputSignal, Signal, signal } from "@angular/core"; +import { + computed, + Injectable, + InputSignal, + Signal, + signal, +} from "@angular/core"; import { BreakpointObserver } from "@angular/cdk/layout"; export const BREAKPOINTS = { @@ -21,6 +27,11 @@ export type BreakpointInputs = { export type BreakpointInputsWithoutSignals = TInputs & Partial>; +export type BreakpointObject = { xs: T } & Partial< + Record, T> +>; +export type BreakpointInput = T | BreakpointObject; + @Injectable({ providedIn: "root", }) @@ -118,3 +129,13 @@ export class BreakpointService { }); } } + +export function breakpointInput( + input: BreakpointInput, +): BreakpointObject { + if (typeof input === "object" && input !== null && "xs" in input) { + return input; + } + + return { xs: input }; +} diff --git a/tedi/services/translation/translation.service.ts b/tedi/services/translation/translation.service.ts index d55df2584..282fa72af 100644 --- a/tedi/services/translation/translation.service.ts +++ b/tedi/services/translation/translation.service.ts @@ -1,69 +1,67 @@ import { computed, Injectable, isSignal, signal, Signal } from "@angular/core"; -import { translationsMap, TranslationMap, TediTranslationsMap } from "./translations"; +import { + translationsMap, + TranslationMap, + TediTranslationsMap, +} from "./translations"; export type Language = "en" | "et" | "ru"; @Injectable({ providedIn: "root" }) export class TediTranslationService { - private currentLang = signal("et"); - private translations = signal(translationsMap); + private currentLang = signal("et"); + private translations = signal(translationsMap); - getLanguage = this.currentLang.asReadonly(); - - setLanguage(lang: Language) { - this.currentLang.set(lang); - } - - translate< - TLang extends Language, - TKey extends keyof TediTranslationsMap | (string & {}), - TArgs extends TKey extends keyof TediTranslationsMap - ? TediTranslationsMap[TKey] extends (...args: infer P) => string - ? P - : [] - : unknown[] - >( - key: TKey, - ...args: TArgs - ): string { - const lang = this.currentLang(); - const entry = this.translations()[key]; + getLanguage = this.currentLang.asReadonly(); - if (!entry || !(lang in entry)) { - return key; - } - - const value = entry[lang]; + setLanguage(lang: Language) { + this.currentLang.set(lang); + } - if (typeof value === "function") { - return value(...args); - } + translate< + TLang extends Language, + TKey extends keyof TediTranslationsMap | (string & {}), + TArgs extends TKey extends keyof TediTranslationsMap + ? TediTranslationsMap[TKey] extends (...args: infer P) => string + ? P + : [] + : unknown[], + >(key: TKey, ...args: TArgs): string { + const lang = this.currentLang(); + const entry = this.translations()[key]; - return value; + if (!entry || !(lang in entry)) { + return key; } - track< - TLang extends Language, - TKey extends keyof TediTranslationsMap | (string & {}), - TArgs extends TKey extends keyof TediTranslationsMap - ? TediTranslationsMap[TKey] extends (...args: infer P) => string - ? P - : [] - : unknown[] - >( - key: TKey, - ...args: Signal[] - ) { - return computed(() => { - const resolvedArgs = args.map(arg => - isSignal(arg) ? arg() : arg - ) as TArgs; + const value = entry[lang]; - return this.translate(key, ...resolvedArgs); - }); + if (typeof value === "function") { + return value(...args); } - addTranslations(newTranslations: TranslationMap) { - this.translations.update(prev => ({ ...prev, ...newTranslations })); - } -} \ No newline at end of file + return value; + } + + track< + TLang extends Language, + TKey extends keyof TediTranslationsMap | (string & {}), + TArgs extends TKey extends keyof TediTranslationsMap + ? TediTranslationsMap[TKey] extends (...args: infer P) => string + ? P + : [] + : unknown[], + >(key: TKey, ...args: (TArgs[number] | Signal)[]) { + return computed(() => { + const resolvedArgs = args.map((arg) => + isSignal(arg) ? arg() : arg, + ) as TArgs; + + return this.translate(key, ...resolvedArgs); + }); + } + + addTranslations(newTranslations: TranslationMap) { + this.translations.update((prev) => ({ ...prev, ...newTranslations })); + } +} diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 70a2edfaa..7472c1742 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -575,6 +575,44 @@ export const translationsMap = { en: (isOpen: boolean) => (isOpen ? "Close menu" : "Open menu"), ru: (isOpen: boolean) => (isOpen ? "Закрыть меню" : "Открыть меню"), }, + carousel: { + description: "Label for carousel", + components: ["CarouselContent"], + et: "Karussell", + en: "Carousel", + ru: "Карусель", + }, + "carousel.slide": { + description: "Label for carousel slide", + components: ["CarouselContent"], + et: (slideNumber: number, totalNumber: number) => + `Slaid ${slideNumber} / ${totalNumber}`, + en: (slideNumber: number, totalNumber: number) => + `Slide ${slideNumber} of ${totalNumber}`, + ru: (slideNumber: number, totalNumber: number) => + `Слайд ${slideNumber} из ${totalNumber}`, + }, + "carousel.moveForward": { + description: "Label for carousel next button", + components: ["CarouselIndicators", "CarouselNavigation"], + et: "Liigu edasi", + en: "Move forward", + ru: "Двигаться вперед", + }, + "carousel.moveBack": { + description: "Label for carousel previous button", + components: ["CarouselIndicators", "CarouselNavigation"], + et: "Liigu tagasi", + en: "Move back", + ru: "Двигаться назад", + }, + "carousel.showSlide": { + description: "Label for carousel slide indicator", + components: ["CarouselIndicators"], + et: (slideNumber: number) => `Vaata slaidi ${slideNumber}`, + en: (slideNumber: number) => `Show slide ${slideNumber}`, + ru: (slideNumber: number) => `Показать слайд ${slideNumber}`, + }, }; export type TediTranslationsMap = {