From fe193a97bf566b94f127aefd38ea870cbef11245 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Sat, 18 Oct 2025 01:50:32 +0300 Subject: [PATCH 1/8] feat(carousel): add carousel component #151 --- .../carousel-content.component.html | 23 ++ .../carousel-content.component.scss | 27 ++ .../carousel-content.component.ts | 248 ++++++++++++++++++ .../carousel-footer.component.scss | 7 + .../carousel-footer.component.ts | 15 ++ .../carousel-header.component.scss | 9 + .../carousel-header.component.ts | 15 ++ .../carousel-indicators.component.html | 40 +++ .../carousel-indicators.component.scss | 21 ++ .../carousel-indicators.component.ts | 66 +++++ .../carousel-navigation.component.html | 18 ++ .../carousel-navigation.component.scss | 8 + .../carousel-navigation.component.ts | 30 +++ .../carousel/carousel-slide.directive.ts | 9 + .../content/carousel/carousel.component.html | 3 + .../content/carousel/carousel.component.scss | 8 + .../carousel/carousel.component.spec.ts | 23 ++ .../content/carousel/carousel.component.ts | 19 ++ .../content/carousel/carousel.stories.ts | 194 ++++++++++++++ .../services/breakpoint/breakpoint.service.ts | 23 +- 20 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 tedi/components/content/carousel/carousel-content/carousel-content.component.html create mode 100644 tedi/components/content/carousel/carousel-content/carousel-content.component.scss create mode 100644 tedi/components/content/carousel/carousel-content/carousel-content.component.ts create mode 100644 tedi/components/content/carousel/carousel-footer/carousel-footer.component.scss create mode 100644 tedi/components/content/carousel/carousel-footer/carousel-footer.component.ts create mode 100644 tedi/components/content/carousel/carousel-header/carousel-header.component.scss create mode 100644 tedi/components/content/carousel/carousel-header/carousel-header.component.ts create mode 100644 tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html create mode 100644 tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss create mode 100644 tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts create mode 100644 tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html create mode 100644 tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.scss create mode 100644 tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts create mode 100644 tedi/components/content/carousel/carousel-slide.directive.ts create mode 100644 tedi/components/content/carousel/carousel.component.html create mode 100644 tedi/components/content/carousel/carousel.component.scss create mode 100644 tedi/components/content/carousel/carousel.component.spec.ts create mode 100644 tedi/components/content/carousel/carousel.component.ts create mode 100644 tedi/components/content/carousel/carousel.stories.ts 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..059e92d4d --- /dev/null +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.html @@ -0,0 +1,23 @@ + 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..d363cb774 --- /dev/null +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss @@ -0,0 +1,27 @@ +tedi-carousel-content { + width: 100%; + position: relative; + overflow: hidden; + + .tedi-carousel__viewport { + width: 100%; + position: relative; + overflow: hidden; + touch-action: pan-y; + cursor: grab; + } + + .tedi-carousel__viewport:active { + cursor: grabbing; + } + + .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..5328c45ea --- /dev/null +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -0,0 +1,248 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + ViewEncapsulation, + computed, + contentChildren, + signal, + viewChild, + AfterViewInit, + OnDestroy, + input, + inject, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { CarouselSlideDirective } from "../carousel-slide.directive"; +import { + breakpointInput, + BreakpointInput, + BreakpointService, +} from "../../../../services/breakpoint/breakpoint.service"; + +@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], +}) +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, md: 4 }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + + private readonly breakpointService = inject(BreakpointService); + + readonly viewport = + viewChild.required>("viewport"); + readonly track = viewChild.required>("track"); + + readonly slides = contentChildren(CarouselSlideDirective); + + readonly trackIndex = signal(0); + readonly animate = signal(false); + private readonly windowBase = signal(0); + + private 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; + } + }); + + private 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 transform = computed(() => { + const cellPercent = 100 / this.currentSlidesPerView(); + const offsetSlides = this.trackIndex() - this.windowBase() + this.buffer(); + return `translate3d(${-offsetSlides * cellPercent}%, 0, 0)`; + }); + + readonly transitionStyle = computed(() => + this.animate() ? `transform ${this.transitionMs}ms ease` : "none", + ); + + readonly slideFlex = computed( + () => `0 0 ${100 / this.currentSlidesPerView()}%`, + ); + + private locked = false; + private dragging = false; + private startX = 0; + private startIndex = 0; + private viewportWidth = 0; + private readonly transitionMs = 400; + private ro?: ResizeObserver; + + ngAfterViewInit(): void { + const viewport = this.viewport().nativeElement; + this.viewportWidth = viewport.clientWidth; + + this.ro = new ResizeObserver((entries) => { + for (const e of entries) { + if (e.target === viewport) { + this.viewportWidth = viewport.clientWidth; + } + } + }); + + this.ro.observe(viewport); + } + + ngOnDestroy(): void { + this.ro?.disconnect(); + } + + private lockNavigation() { + this.locked = true; + setTimeout(() => (this.locked = false), this.transitionMs); + } + + 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); + } + + onPointerDown(ev: PointerEvent) { + if (!this.slides().length) { + return; + } + + this.viewport().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)); + } + + onPointerMove(ev: PointerEvent) { + if (!this.dragging) { + return; + } + + const dx = ev.clientX - this.startX; + const cellWidth = this.viewportWidth / this.currentSlidesPerView(); + + 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); + } + + onPointerUp() { + if (!this.dragging) { + return; + } + + this.dragging = false; + this.animate.set(true); + this.trackIndex.set(Math.round(this.trackIndex())); + } + + 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())); + } +} 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..957acec25 --- /dev/null +++ b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss @@ -0,0 +1,9 @@ +@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); +} 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..5b6e2ef92 --- /dev/null +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html @@ -0,0 +1,40 @@ +@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..ab0dde92a --- /dev/null +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss @@ -0,0 +1,21 @@ +@use "@tedi-design-system/core/mixins"; + +tedi-carousel-indicators { + display: flex; + align-items: center; + + @include mixins.responsive-styles(gap, layout-grid-gutters-04); +} + +.tedi-carousel__indicator { + width: 22px; + height: 8px; + border-radius: 100px; + border: 1px solid #005aa3; + background: transparent; + cursor: pointer; + + &--active { + background: #005aa3; + } +} 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..d91dd88cd --- /dev/null +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts @@ -0,0 +1,66 @@ +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"; + +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"); + + carousel = inject(CarouselComponent); + + readonly indicatorsArray = computed(() => + Array.from( + { length: this.carousel.carouselContent()?.slides().length ?? 0 }, + (_, i) => ({ + index: i, + active: this.carousel.carouselContent()?.slideIndex() === i, + }), + ), + ); + + readonly activeSlideNumber = computed(() => { + const currentIndex = this.carousel.carouselContent()?.slideIndex(); + + if (currentIndex !== undefined) { + return currentIndex + 1; + } + + return 0; + }); + + 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..fe6f3437c --- /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..026fcbbd5 --- /dev/null +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts @@ -0,0 +1,30 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + ViewEncapsulation, +} from "@angular/core"; +import { ButtonComponent } from "../../../buttons"; +import { IconComponent } from "../../../base"; +import { CarouselComponent } from "../carousel.component"; + +@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 { + 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.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..3df330840 --- /dev/null +++ b/tedi/components/content/carousel/carousel.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CarouselComponent } from './carousel.component'; + +describe('CarouselComponent', () => { + let component: CarouselComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CarouselComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CarouselComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/tedi/components/content/carousel/carousel.component.ts b/tedi/components/content/carousel/carousel.component.ts new file mode 100644 index 000000000..8ca639099 --- /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(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..c2a96f709 --- /dev/null +++ b/tedi/components/content/carousel/carousel.stories.ts @@ -0,0 +1,194 @@ +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 } 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"; + +export default { + title: "TEDI-Ready/Content/Carousel", + component: CarouselComponent, + decorators: [ + moduleMetadata({ + imports: [ + CarouselComponent, + CarouselSlideDirective, + CarouselHeaderComponent, + CarouselFooterComponent, + CarouselNavigationComponent, + CarouselContentComponent, + CarouselIndicatorsComponent, + TextComponent, + IconComponent, + ], + }), + ], + 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" }, + }, + }, + }, +} as Meta; + +type CarouselType = CarouselComponent & { + slidesPerView: BreakpointInput; +}; + +export const Default: StoryObj = { + args: { + slidesPerView: { xs: 1 }, + }, + render: (args) => ({ + props: args, + template: ` + + +
+

Title

+

Description

+
+
+ + + +
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+
+ + +
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+
+ + +
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+
+ + +
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+
+ + +
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+ +
Replace with your own content
+
+
+
+
+ + + + + +
+ `, + }), +}; 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 }; +} From 21b872f3cc9d55b62bd0b7d6d1e65c98f2b016c3 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Mon, 20 Oct 2025 09:03:28 +0300 Subject: [PATCH 2/8] feat(carousel): add carousel #151 --- .../carousel-content.component.html | 38 +- .../carousel-content.component.scss | 38 +- .../carousel-content.component.ts | 303 +++++++++-- .../carousel-header.component.scss | 1 + .../carousel-indicators.component.html | 8 +- .../carousel-indicators.component.scss | 21 +- .../carousel-indicators.component.ts | 2 + .../carousel-navigation.component.html | 4 +- .../carousel-navigation.component.ts | 2 + .../content/carousel/carousel.stories.ts | 511 +++++++++++++++++- tedi/components/content/carousel/index.ts | 7 + tedi/components/content/index.ts | 1 + tedi/services/translation/translations.ts | 38 ++ 13 files changed, 846 insertions(+), 128 deletions(-) create mode 100644 tedi/components/content/carousel/index.ts diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.html b/tedi/components/content/carousel/carousel-content/carousel-content.component.html index 059e92d4d..a784470cf 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.html +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.html @@ -1,23 +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 index d363cb774..5ecffae1e 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.scss +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss @@ -1,27 +1,37 @@ -tedi-carousel-content { +.tedi-carousel__content { width: 100%; position: relative; overflow: hidden; + touch-action: pan-y; + cursor: grab; - .tedi-carousel__viewport { - 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)); } - .tedi-carousel__viewport:active { + &:active { cursor: grabbing; } - .tedi-carousel__track { - display: flex; - will-change: transform; + &--fade-right { + mask-image: linear-gradient(to right, black 90%, transparent 100%); } - .tedi-carousel__slide { - user-select: none; - -webkit-user-drag: none; + &--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 index 5328c45ea..de77eceaa 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -11,6 +11,7 @@ import { OnDestroy, input, inject, + HostListener, } from "@angular/core"; import { NgTemplateOutlet } from "@angular/common"; import { CarouselSlideDirective } from "../carousel-slide.directive"; @@ -19,6 +20,7 @@ import { BreakpointInput, BreakpointService, } from "../../../../services/breakpoint/breakpoint.service"; +import { TediTranslationService } from "../../../../services"; @Component({ standalone: true, @@ -28,27 +30,43 @@ import { 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, md: 4 }, + { xs: 1 }, { transform: (v: BreakpointInput) => breakpointInput(v) }, ); + /** Gap between slides in px */ + readonly gap = input( + { xs: 16 }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + + /** Should fade at the end of carousel? Great to use when slidesPerView is fractional (peeking effect) */ + readonly fade = input(false); + + readonly translationService = inject(TediTranslationService); private readonly breakpointService = inject(BreakpointService); + private readonly host = inject>(ElementRef); - readonly viewport = - viewChild.required>("viewport"); readonly track = viewChild.required>("track"); - readonly slides = contentChildren(CarouselSlideDirective); readonly trackIndex = signal(0); readonly animate = signal(false); private readonly windowBase = signal(0); - private readonly currentSlidesPerView = computed(() => { + readonly currentSlidesPerView = computed(() => { if ( this.slidesPerView().xxl && this.breakpointService.isAboveBreakpoint("xxl") @@ -79,6 +97,34 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { } }); + 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; + } + }); + private readonly buffer = computed(() => this.slides().length); readonly slideIndex = computed(() => { @@ -108,19 +154,46 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { ); }); - readonly transform = computed(() => { - const cellPercent = 100 / this.currentSlidesPerView(); - const offsetSlides = this.trackIndex() - this.windowBase() + this.buffer(); - return `translate3d(${-offsetSlides * cellPercent}%, 0, 0)`; + readonly slideFlex = computed(() => { + const slidesPerView = this.currentSlidesPerView(); + const gap = this.currentGap(); + return `0 0 calc((100% - ${(slidesPerView - 1) * gap}px) / ${slidesPerView})`; }); - readonly transitionStyle = computed(() => - this.animate() ? `transform ${this.transitionMs}ms ease` : "none", - ); + readonly classes = computed(() => { + const classList = ["tedi-carousel__content"]; - readonly slideFlex = computed( - () => `0 0 ${100 / this.currentSlidesPerView()}%`, - ); + 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 || this.host.nativeElement.clientWidth; + + 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: viewportWidth + ? `translate3d(${translateX}px, 0, 0)` + : "translate3d(0, 0, 0)", + transition: this.animate() + ? `transform ${this.transitionMs}ms ease` + : "none", + }; + }); private locked = false; private dragging = false; @@ -129,71 +202,117 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { private viewportWidth = 0; private readonly transitionMs = 400; private ro?: ResizeObserver; + private wheelTimeout?: ReturnType; + private scrollDelta = 0; - ngAfterViewInit(): void { - const viewport = this.viewport().nativeElement; - this.viewportWidth = viewport.clientWidth; - - this.ro = new ResizeObserver((entries) => { - for (const e of entries) { - if (e.target === viewport) { - this.viewportWidth = viewport.clientWidth; - } - } - }); - - this.ro.observe(viewport); - } + @HostListener("wheel", ["$event"]) + onWheel(event: WheelEvent) { + const slidesCount = this.slides().length; - ngOnDestroy(): void { - this.ro?.disconnect(); - } + if (!slidesCount) { + return; + } - private lockNavigation() { - this.locked = true; - setTimeout(() => (this.locked = false), this.transitionMs); - } + const delta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.shiftKey + ? event.deltaY + : 0; - next(): void { - if (!this.slides().length || this.locked) { + if (!delta) { return; } - this.animate.set(true); - this.trackIndex.update((i) => i + 1); - this.lockNavigation(); - } + event.preventDefault(); - prev(): void { - if (!this.slides().length || this.locked) { + const cellWidth = + (this.viewportWidth - + this.currentGap() * (this.currentSlidesPerView() - 1)) / + this.currentSlidesPerView() + + this.currentGap(); + + if (!cellWidth) { return; } - this.animate.set(true); - this.trackIndex.update((i) => i - 1); - this.lockNavigation(); + 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); } - goToIndex(index: number) { - const slidesCount = this.slides().length; + @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; + } - if (!slidesCount || this.locked) { - return; + default: + break; } - - const current = this.slideIndex(); - const normalized = ((index % slidesCount) + slidesCount) % slidesCount; - const delta = normalized - current; - this.animate.set(true); - this.trackIndex.update((i) => i + delta); } + @HostListener("pointerdown", ["$event"]) onPointerDown(ev: PointerEvent) { if (!this.slides().length) { return; } - this.viewport().nativeElement.setPointerCapture(ev.pointerId); + this.host.nativeElement.setPointerCapture(ev.pointerId); this.dragging = true; this.animate.set(false); this.startX = ev.clientX; @@ -201,13 +320,18 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { 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.currentSlidesPerView(); + const cellWidth = + (this.viewportWidth - + this.currentGap() * (this.currentSlidesPerView() - 1)) / + this.currentSlidesPerView() + + this.currentGap(); if (!cellWidth) { return; @@ -223,6 +347,9 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.trackIndex.set(clamped); } + @HostListener("pointerup") + @HostListener("pointercancel") + @HostListener("lostpointercapture") onPointerUp() { if (!this.dragging) { return; @@ -233,6 +360,59 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.trackIndex.set(Math.round(this.trackIndex())); } + ngAfterViewInit(): void { + const viewport = this.host.nativeElement; + this.viewportWidth = viewport.clientWidth; + + this.ro = new ResizeObserver((entries) => { + for (const e of entries) { + if (e.target === viewport) { + this.viewportWidth = 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 || @@ -245,4 +425,9 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.animate.set(false); this.windowBase.set(Math.floor(this.trackIndex())); } + + private lockNavigation() { + this.locked = true; + setTimeout(() => (this.locked = false), this.transitionMs); + } } diff --git a/tedi/components/content/carousel/carousel-header/carousel-header.component.scss b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss index 957acec25..179163e66 100644 --- a/tedi/components/content/carousel/carousel-header/carousel-header.component.scss +++ b/tedi/components/content/carousel/carousel-header/carousel-header.component.scss @@ -6,4 +6,5 @@ tedi-carousel-header { 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-indicators/carousel-indicators.component.html b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html index 5b6e2ef92..83b4c0be4 100644 --- a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.html @@ -3,7 +3,7 @@ tedi-button type="button" variant="neutral" - aria-label="Liigu tagasi" + [attr.aria-label]="translationService.track('carousel.moveBack')()" (click)="handlePrev()" > @@ -15,7 +15,9 @@ @@ -32,7 +34,7 @@ tedi-button type="button" variant="neutral" - aria-label="Liigu edasi" + [attr.aria-label]="translationService.track('carousel.moveForward')()" (click)="handleNext()" > diff --git a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss index ab0dde92a..186681b0c 100644 --- a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.scss @@ -3,6 +3,7 @@ tedi-carousel-indicators { display: flex; align-items: center; + min-height: 24px; @include mixins.responsive-styles(gap, layout-grid-gutters-04); } @@ -11,11 +12,25 @@ tedi-carousel-indicators { width: 22px; height: 8px; border-radius: 100px; - border: 1px solid #005aa3; - background: transparent; + 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: #005aa3; + 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 index d91dd88cd..d6ace7b9f 100644 --- a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts @@ -11,6 +11,7 @@ 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"; @@ -30,6 +31,7 @@ export class CarouselIndicatorsComponent { /** Variant of indicators (dots and numbers) */ readonly variant = input("dots"); + translationService = inject(TediTranslationService); carousel = inject(CarouselComponent); readonly indicatorsArray = computed(() => diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html index fe6f3437c..c0f1019b1 100644 --- a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.html @@ -2,7 +2,7 @@ tedi-button type="button" variant="secondary" - aria-label="Liigu tagasi" + [attr.aria-label]="translationService.track('carousel.moveBack')()" (click)="handlePrev()" > @@ -11,7 +11,7 @@ tedi-button type="button" variant="secondary" - aria-label="Liigu edasi" + [attr.aria-label]="translationService.track('carousel.moveForward')()" (click)="handleNext()" > diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts index 026fcbbd5..02fbbf282 100644 --- a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts @@ -7,6 +7,7 @@ import { import { ButtonComponent } from "../../../buttons"; import { IconComponent } from "../../../base"; import { CarouselComponent } from "../carousel.component"; +import { TediTranslationService } from "../../../../services"; @Component({ standalone: true, @@ -18,6 +19,7 @@ import { CarouselComponent } from "../carousel.component"; imports: [ButtonComponent, IconComponent], }) export class CarouselNavigationComponent { + readonly translationService = inject(TediTranslationService); carousel = inject(CarouselComponent); handleNext() { diff --git a/tedi/components/content/carousel/carousel.stories.ts b/tedi/components/content/carousel/carousel.stories.ts index c2a96f709..8ab373c2a 100644 --- a/tedi/components/content/carousel/carousel.stories.ts +++ b/tedi/components/content/carousel/carousel.stories.ts @@ -9,6 +9,7 @@ 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"; export default { title: "TEDI-Ready/Content/Carousel", @@ -25,6 +26,7 @@ export default { CarouselIndicatorsComponent, TextComponent, IconComponent, + ButtonComponent, ], }), ], @@ -45,16 +47,49 @@ export default { 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 fade at the end of carousel? Great to use when slidesPerView is fractional (peeking effect)", + control: { + type: "boolean", + }, + table: { + category: "Carousel Content", + type: { + summary: "boolean", + }, + defaultValue: { summary: "false" }, + }, + }, }, } as Meta; type CarouselType = CarouselComponent & { slidesPerView: BreakpointInput; + gap: BreakpointInput; + fade: boolean; }; export const Default: StoryObj = { args: { slidesPerView: { xs: 1 }, + gap: { xs: 16 }, + fade: false, }, render: (args) => ({ props: args, @@ -65,127 +100,549 @@ export const Default: StoryObj = {

Title

Description

+ + + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ @for (j of [0, 1, 2]; track $index) { +
+ +
Replace with your own content
+
+ } +
+
+ } +
+ + + + + `, + }), +}; +export const TopPaginationArrowsOnly: StoryObj = { + name: "Top pagination - arrows only", + args: { + slidesPerView: { xs: 3 }, + }, + 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: 2.8 }, + }, + render: (args) => ({ + props: args, + template: ` + + +

Title

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

Title

+
+ + @for (i of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; track $index) { + +
+ @for (j of [0, 1, 2]; track $index) { +
+ +
Replace with your own content
+
+ }
-
-
+ + } +
+ + + + +
+ `, + }), +}; - -
+export const CenteredBottomPaginationHasDots: StoryObj = { + name: "Centered bottom pagination - has dots", + args: { + slidesPerView: { xs: 3 }, + }, + 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: 3 }, + }, + 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: 2.5 }, + }, + 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 }, + fade: true, + }, + render: (args) => ({ + props: args, + template: ` +
+ + +

Title

+
+ + @for (i of [0, 1, 2, 3, 4]; track $index) { + +
+ @for (j of [0, 1, 2]; track $index) { +
+ +
Replace with your own content
+
+ } +
+
+ } +
+ + + + +
+ + + @for (i of [0, 1, 2, 3, 4, 5]; track $index) { +
Replace with your own content
-
- + + } + + + + + +
+ `, + }), +}; - -
+export const WithTopNavigation: StoryObj = { + name: "With top navigation", + args: { + slidesPerView: { xs: 3 }, + }, + 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: 3 }, + }, + 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: 3 }, + }, + 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/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 = { From 9fea3e3df392c71115326c9b3845e78197f35b47 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Mon, 20 Oct 2025 15:48:31 +0300 Subject: [PATCH 3/8] feat(carousel): code improvement #151 --- .../carousel-content.component.ts | 41 +++---- .../carousel-indicators.component.ts | 26 ++--- .../carousel-navigation.component.ts | 6 +- .../carousel-slide.component.scss | 4 + .../content/carousel/carousel.component.ts | 2 +- .../content/carousel/carousel.stories.ts | 63 ++++++----- .../translation/translation.service.ts | 106 +++++++++--------- 7 files changed, 128 insertions(+), 120 deletions(-) create mode 100644 tedi/components/content/carousel/carousel-slide/carousel-slide.component.scss diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts index de77eceaa..f25465698 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -52,9 +52,12 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { { transform: (v: BreakpointInput) => breakpointInput(v) }, ); - /** Should fade at the end of carousel? Great to use when slidesPerView is fractional (peeking effect) */ + /** 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); @@ -65,6 +68,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { readonly trackIndex = signal(0); readonly animate = signal(false); private readonly windowBase = signal(0); + private readonly viewportWidth = signal(0); readonly currentSlidesPerView = computed(() => { if ( @@ -175,8 +179,15 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { readonly trackStyle = computed(() => { const slidesPerView = this.currentSlidesPerView(); const gap = this.currentGap(); - const viewportWidth = - this.viewportWidth || this.host.nativeElement.clientWidth; + 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; @@ -186,11 +197,9 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { return { gap: `${gap}px`, - transform: viewportWidth - ? `translate3d(${translateX}px, 0, 0)` - : "translate3d(0, 0, 0)", + transform: `translate3d(${translateX}px, 0, 0)`, transition: this.animate() - ? `transform ${this.transitionMs}ms ease` + ? `transform ${this.transitionMs()}ms ease` : "none", }; }); @@ -199,8 +208,6 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { private dragging = false; private startX = 0; private startIndex = 0; - private viewportWidth = 0; - private readonly transitionMs = 400; private ro?: ResizeObserver; private wheelTimeout?: ReturnType; private scrollDelta = 0; @@ -227,7 +234,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { event.preventDefault(); const cellWidth = - (this.viewportWidth - + (this.viewportWidth() - this.currentGap() * (this.currentSlidesPerView() - 1)) / this.currentSlidesPerView() + this.currentGap(); @@ -328,7 +335,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { const dx = ev.clientX - this.startX; const cellWidth = - (this.viewportWidth - + (this.viewportWidth() - this.currentGap() * (this.currentSlidesPerView() - 1)) / this.currentSlidesPerView() + this.currentGap(); @@ -362,14 +369,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { ngAfterViewInit(): void { const viewport = this.host.nativeElement; - this.viewportWidth = viewport.clientWidth; + this.viewportWidth.set(viewport.clientWidth); - this.ro = new ResizeObserver((entries) => { - for (const e of entries) { - if (e.target === viewport) { - this.viewportWidth = viewport.clientWidth; - } - } + this.ro = new ResizeObserver(() => { + this.viewportWidth.set(viewport.clientWidth); }); this.ro.observe(viewport); @@ -428,6 +431,6 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { private lockNavigation() { this.locked = true; - setTimeout(() => (this.locked = false), this.transitionMs); + setTimeout(() => (this.locked = false), this.transitionMs()); } } diff --git a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts index d6ace7b9f..e18874c82 100644 --- a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts +++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts @@ -31,38 +31,32 @@ export class CarouselIndicatorsComponent { /** Variant of indicators (dots and numbers) */ readonly variant = input("dots"); - translationService = inject(TediTranslationService); - carousel = inject(CarouselComponent); + readonly translationService = inject(TediTranslationService); + readonly carousel = inject(CarouselComponent); readonly indicatorsArray = computed(() => Array.from( - { length: this.carousel.carouselContent()?.slides().length ?? 0 }, + { length: this.carousel.carouselContent().slides().length }, (_, i) => ({ index: i, - active: this.carousel.carouselContent()?.slideIndex() === i, + active: this.carousel.carouselContent().slideIndex() === i, }), ), ); - readonly activeSlideNumber = computed(() => { - const currentIndex = this.carousel.carouselContent()?.slideIndex(); - - if (currentIndex !== undefined) { - return currentIndex + 1; - } - - return 0; - }); + readonly activeSlideNumber = computed( + () => this.carousel.carouselContent().slideIndex() + 1, + ); handleNext() { - this.carousel.carouselContent()?.next(); + this.carousel.carouselContent().next(); } handlePrev() { - this.carousel.carouselContent()?.prev(); + this.carousel.carouselContent().prev(); } handleIndicatorClick(index: number) { - this.carousel.carouselContent()?.goToIndex(index); + this.carousel.carouselContent().goToIndex(index); } } diff --git a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts index 02fbbf282..309bb60b7 100644 --- a/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts +++ b/tedi/components/content/carousel/carousel-navigation/carousel-navigation.component.ts @@ -20,13 +20,13 @@ import { TediTranslationService } from "../../../../services"; }) export class CarouselNavigationComponent { readonly translationService = inject(TediTranslationService); - carousel = inject(CarouselComponent); + private readonly carousel = inject(CarouselComponent); handleNext() { - this.carousel.carouselContent()?.next(); + this.carousel.carouselContent().next(); } handlePrev() { - this.carousel.carouselContent()?.prev(); + this.carousel.carouselContent().prev(); } } 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.ts b/tedi/components/content/carousel/carousel.component.ts index 8ca639099..6061f9e59 100644 --- a/tedi/components/content/carousel/carousel.component.ts +++ b/tedi/components/content/carousel/carousel.component.ts @@ -15,5 +15,5 @@ import { CarouselContentComponent } from "./carousel-content/carousel-content.co changeDetection: ChangeDetectionStrategy.OnPush, }) export class CarouselComponent { - carouselContent = contentChild(CarouselContentComponent); + carouselContent = contentChild.required(CarouselContentComponent); } diff --git a/tedi/components/content/carousel/carousel.stories.ts b/tedi/components/content/carousel/carousel.stories.ts index 8ab373c2a..7f748edda 100644 --- a/tedi/components/content/carousel/carousel.stories.ts +++ b/tedi/components/content/carousel/carousel.stories.ts @@ -64,7 +64,7 @@ export default { }, fade: { description: - "Should fade at the end of carousel? Great to use when slidesPerView is fractional (peeking effect)", + "Should carousel have fade? In mobile both left and right, in desktop only right.", control: { type: "boolean", }, @@ -76,6 +76,19 @@ export default { defaultValue: { summary: "false" }, }, }, + transitionMs: { + description: "Transition duration in ms", + control: { + type: "number", + }, + table: { + category: "Carousel Content", + type: { + summary: "number", + }, + defaultValue: { summary: "400" }, + }, + }, }, } as Meta; @@ -83,6 +96,7 @@ type CarouselType = CarouselComponent & { slidesPerView: BreakpointInput; gap: BreakpointInput; fade: boolean; + transitionMs: number; }; export const Default: StoryObj = { @@ -90,6 +104,7 @@ export const Default: StoryObj = { slidesPerView: { xs: 1 }, gap: { xs: 16 }, fade: false, + transitionMs: 400, }, render: (args) => ({ props: args, @@ -103,7 +118,7 @@ export const Default: StoryObj = { - @for (i of [0, 1, 2, 3, 4]; track $index) { + @for (i of [0, 1, 2, 3, 4]; track $index) {
@for (j of [0, 1, 2]; track $index) { @@ -111,7 +126,7 @@ export const Default: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
}
@@ -146,9 +161,9 @@ export const TopPaginationArrowsOnly: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
-
+ }
@@ -175,7 +190,7 @@ export const SeparatedBottomPaginationHasDots: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -210,7 +225,7 @@ export const SeparatedBottomPaginationHasNumbers: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -245,7 +260,7 @@ export const CenteredBottomPaginationHasDots: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -277,7 +292,7 @@ export const CenteredBottomPaginationHasNumbers: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -310,7 +325,7 @@ export const CombinationsTopNavigationBottomDots: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -342,7 +357,7 @@ export const CenteredHasDots: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -368,13 +383,13 @@ export const CenteredHasNumbers: StoryObj = {

Title

- @for (i of [0, 1, 2]; track $index) { + @for (i of [0, 1, 2]; track $index) {
-
Replace with your own content
+
Replace with your own content
} @@ -406,7 +421,7 @@ export const SeparatedHasDots: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -439,7 +454,7 @@ export const SeparatedHasNumbers: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -473,7 +488,7 @@ export const Combination: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -509,7 +524,7 @@ export const Fade: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -524,14 +539,12 @@ export const Fade: StoryObj = { @for (i of [0, 1, 2, 3, 4, 5]; track $index) { -
-
Replace with your own content
+
Replace with your own content
-
}
@@ -563,7 +576,7 @@ export const WithTopNavigation: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -596,7 +609,7 @@ export const WithTopAction: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
} @@ -636,15 +649,11 @@ export const WithDescription: StoryObj = { style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--general-border-primary); border-radius: 4px; height: 10rem; padding: 1rem; flex: 1;" > -
Replace with your own content
+
Replace with your own content
}
- - - - `, }), 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 })); + } +} From d5dbabb785af16bc083372b9e00e4dc9a07be434 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Mon, 20 Oct 2025 16:42:06 +0300 Subject: [PATCH 4/8] feat(carousel): add tests #151 --- .../carousel-content.component.ts | 2 +- .../carousel/carousel.component.spec.ts | 337 +++++++++++++++++- 2 files changed, 327 insertions(+), 12 deletions(-) diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts index f25465698..579c20db2 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -129,7 +129,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { } }); - private readonly buffer = computed(() => this.slides().length); + readonly buffer = computed(() => this.slides().length); readonly slideIndex = computed(() => { const slidesCount = this.slides().length; diff --git a/tedi/components/content/carousel/carousel.component.spec.ts b/tedi/components/content/carousel/carousel.component.spec.ts index 3df330840..872bcb03c 100644 --- a/tedi/components/content/carousel/carousel.component.spec.ts +++ b/tedi/components/content/carousel/carousel.component.spec.ts @@ -1,23 +1,338 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Component, ElementRef, input } 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 { 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"; -import { CarouselComponent } from './carousel.component'; +@Component({ + standalone: true, + imports: [CarouselContentComponent], + template: ` + + `, +}) +class TestHostComponent { + slidesPerView = input({ xs: 1 }); + gap = input({ xs: 16 }); + fade = input(false); + transitionMs = input(400); +} -describe('CarouselComponent', () => { - let component: CarouselComponent; - let fixture: ComponentFixture; +describe("CarouselContentComponent", () => { + let fixture: ComponentFixture; + let component: CarouselContentComponent; + let hostElement: HTMLElement; + + let mockBreakpointService: { isAboveBreakpoint: jest.Mock }; + 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: jest.fn().mockReturnValue(false), + }; + + mockTranslationService = { + track: jest.fn((key: string) => () => key), + }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: BreakpointService, useValue: mockBreakpointService }, + { provide: TediTranslationService, useValue: mockTranslationService }, + { provide: ElementRef, useValue: new ElementRef(fakeViewport) }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + const carouselDebug = fixture.debugElement.query( + By.directive(CarouselContentComponent), + ); + component = carouselDebug.componentInstance; + hostElement = carouselDebug.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: () => [{}, {}, {}], + }); + + (component as any).viewportWidth.set(1000); + + 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 as any).dragging = false; + component.animate.set(true); + component.trackIndex.set(2); + + const evt = { + target: fakeNative, + propertyName: "transform", + } as unknown 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 }) as any, + }); + + (component as any).dragging = false; + component.animate.set(true); + component.trackIndex.set(2); + + const event = { + target: fakeNative, + propertyName: "transform", + } as unknown as TransitionEvent; + + component.onTransitionEnd(event); + expect(component.animate()).toBe(false); + }); +}); + +@Component({ + standalone: true, + imports: [CarouselIndicatorsComponent], + template: ` + + `, +}) +class TestIndicatorsHostComponent { + withArrows = false; + variant: "dots" | "numbers" = "dots"; +} + +describe("CarouselIndicatorsComponent", () => { + let fixture: ComponentFixture; + let component: CarouselIndicatorsComponent; + + let mockCarouselContent: 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: [CarouselComponent] - }) - .compileComponents(); + imports: [TestIndicatorsHostComponent], + providers: [ + { provide: CarouselComponent, useValue: mockCarousel }, + { provide: TediTranslationService, useValue: mockTranslationService }, + ], + }).compileComponents(); - fixture = TestBed.createComponent(CarouselComponent); - component = fixture.componentInstance; + fixture = TestBed.createComponent(TestIndicatorsHostComponent); fixture.detectChanges(); + + const indicatorsDebug = fixture.debugElement.query( + By.directive(CarouselIndicatorsComponent), + ); + component = indicatorsDebug.componentInstance; }); - it('should create', () => { + 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; + + let mockCarouselContent: 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); + }); }); From 0e0f53968b5f280443d666b2e10ffc3047c83499 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Tue, 21 Oct 2025 12:15:08 +0300 Subject: [PATCH 5/8] feat(carousel): add more tests #151 --- .../carousel-content.component.ts | 8 +- .../carousel/carousel.component.spec.ts | 272 +++++++++++++++--- 2 files changed, 239 insertions(+), 41 deletions(-) diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts index 579c20db2..d5cda2b0b 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -67,8 +67,8 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { readonly trackIndex = signal(0); readonly animate = signal(false); + readonly viewportWidth = signal(0); private readonly windowBase = signal(0); - private readonly viewportWidth = signal(0); readonly currentSlidesPerView = computed(() => { if ( @@ -204,8 +204,8 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { }; }); - private locked = false; - private dragging = false; + locked = false; + dragging = false; private startX = 0; private startIndex = 0; private ro?: ResizeObserver; @@ -429,7 +429,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { this.windowBase.set(Math.floor(this.trackIndex())); } - private lockNavigation() { + lockNavigation() { this.locked = true; setTimeout(() => (this.locked = false), this.transitionMs()); } diff --git a/tedi/components/content/carousel/carousel.component.spec.ts b/tedi/components/content/carousel/carousel.component.spec.ts index 872bcb03c..c657a1b34 100644 --- a/tedi/components/content/carousel/carousel.component.spec.ts +++ b/tedi/components/content/carousel/carousel.component.spec.ts @@ -1,35 +1,34 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Component, ElementRef, input } from "@angular/core"; +import { Component, ElementRef } 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 { BreakpointService } from "../../../services/breakpoint/breakpoint.service"; +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"; -@Component({ - standalone: true, - imports: [CarouselContentComponent], - template: ` - - `, -}) -class TestHostComponent { - slidesPerView = input({ xs: 1 }); - gap = input({ xs: 16 }); - fade = input(false); - transitionMs = input(400); +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 fixture: ComponentFixture; let component: CarouselContentComponent; let hostElement: HTMLElement; @@ -63,7 +62,7 @@ describe("CarouselContentComponent", () => { }; await TestBed.configureTestingModule({ - imports: [TestHostComponent], + imports: [CarouselContentComponent], providers: [ { provide: BreakpointService, useValue: mockBreakpointService }, { provide: TediTranslationService, useValue: mockTranslationService }, @@ -71,14 +70,11 @@ describe("CarouselContentComponent", () => { ], }).compileComponents(); - fixture = TestBed.createComponent(TestHostComponent); + fixture = TestBed.createComponent(CarouselContentComponent); fixture.detectChanges(); - const carouselDebug = fixture.debugElement.query( - By.directive(CarouselContentComponent), - ); - component = carouselDebug.componentInstance; - hostElement = carouselDebug.nativeElement; + component = fixture.componentInstance; + hostElement = fixture.nativeElement; }); it("should create component", () => { @@ -121,11 +117,8 @@ describe("CarouselContentComponent", () => { value: () => [{}, {}, {}], }); - (component as any).viewportWidth.set(1000); - const event = new WheelEvent("wheel", { deltaX: 120 }); const preventDefaultSpy = jest.spyOn(event, "preventDefault"); - component.onWheel(event); expect(preventDefaultSpy).toHaveBeenCalled(); @@ -139,17 +132,15 @@ describe("CarouselContentComponent", () => { value: () => ({ nativeElement: fakeNative }), }); - (component as any).dragging = false; component.animate.set(true); component.trackIndex.set(2); const evt = { target: fakeNative, propertyName: "transform", - } as unknown as TransitionEvent; + } as TransitionEvent; component.onTransitionEnd(evt); - expect(component.animate()).toBe(false); }); @@ -157,21 +148,224 @@ describe("CarouselContentComponent", () => { const fakeNative = {}; Object.defineProperty(component, "track", { configurable: true, - value: () => ({ nativeElement: fakeNative }) as any, + value: () => ({ nativeElement: fakeNative }), }); - (component as any).dragging = false; component.animate.set(true); component.trackIndex.set(2); const event = { target: fakeNative, propertyName: "transform", - } as unknown as TransitionEvent; + } 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) => { + mockBreakpointService.isAboveBreakpoint.mockReturnValueOnce(true); + fixture.whenStable().then(() => { + 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.mockReturnValueOnce(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({ @@ -193,7 +387,9 @@ 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 }; @@ -282,7 +478,9 @@ 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 }; From e138f9ace9fcb2f6107ef3d067fbc8ce51357c4d Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Tue, 21 Oct 2025 12:17:46 +0300 Subject: [PATCH 6/8] feat(carousel): add figma link to story file #151 --- tedi/components/content/carousel/carousel.stories.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tedi/components/content/carousel/carousel.stories.ts b/tedi/components/content/carousel/carousel.stories.ts index 7f748edda..5f8c51634 100644 --- a/tedi/components/content/carousel/carousel.stories.ts +++ b/tedi/components/content/carousel/carousel.stories.ts @@ -11,6 +11,11 @@ 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, From 61ebe4d78104a0530aaf5d1dc38ae05ab7cc5699 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Tue, 21 Oct 2025 12:42:43 +0300 Subject: [PATCH 7/8] feat(carousel): story improvements #151 --- .../carousel-content.component.ts | 20 ++-- .../content/carousel/carousel.stories.ts | 110 +++++++++++------- 2 files changed, 77 insertions(+), 53 deletions(-) diff --git a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts index d5cda2b0b..36c11c0e4 100644 --- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts +++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts @@ -73,27 +73,27 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { readonly currentSlidesPerView = computed(() => { if ( this.slidesPerView().xxl && - this.breakpointService.isAboveBreakpoint("xxl") + this.breakpointService.isAboveBreakpoint("xxl")() ) { return this.slidesPerView().xxl as number; } else if ( this.slidesPerView().xl && - this.breakpointService.isAboveBreakpoint("xl") + this.breakpointService.isAboveBreakpoint("xl")() ) { return this.slidesPerView().xl as number; } else if ( this.slidesPerView().lg && - this.breakpointService.isAboveBreakpoint("lg") + this.breakpointService.isAboveBreakpoint("lg")() ) { return this.slidesPerView().lg as number; } else if ( this.slidesPerView().md && - this.breakpointService.isAboveBreakpoint("md") + this.breakpointService.isAboveBreakpoint("md")() ) { return this.slidesPerView().md as number; } else if ( this.slidesPerView().sm && - this.breakpointService.isAboveBreakpoint("sm") + this.breakpointService.isAboveBreakpoint("sm")() ) { return this.slidesPerView().sm as number; } else { @@ -102,26 +102,26 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy { }); readonly currentGap = computed(() => { - if (this.gap().xxl && this.breakpointService.isAboveBreakpoint("xxl")) { + if (this.gap().xxl && this.breakpointService.isAboveBreakpoint("xxl")()) { return this.gap().xxl as number; } else if ( this.gap().xl && - this.breakpointService.isAboveBreakpoint("xl") + this.breakpointService.isAboveBreakpoint("xl")() ) { return this.gap().xl as number; } else if ( this.gap().lg && - this.breakpointService.isAboveBreakpoint("lg") + this.breakpointService.isAboveBreakpoint("lg")() ) { return this.gap().lg as number; } else if ( this.gap().md && - this.breakpointService.isAboveBreakpoint("md") + this.breakpointService.isAboveBreakpoint("md")() ) { return this.gap().md as number; } else if ( this.gap().sm && - this.breakpointService.isAboveBreakpoint("sm") + this.breakpointService.isAboveBreakpoint("sm")() ) { return this.gap().sm as number; } else { diff --git a/tedi/components/content/carousel/carousel.stories.ts b/tedi/components/content/carousel/carousel.stories.ts index 5f8c51634..5a1816d3d 100644 --- a/tedi/components/content/carousel/carousel.stories.ts +++ b/tedi/components/content/carousel/carousel.stories.ts @@ -4,7 +4,10 @@ import { CarouselHeaderComponent } from "./carousel-header/carousel-header.compo 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 } from "./carousel-indicators/carousel-indicators.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"; @@ -94,6 +97,33 @@ export default { 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; @@ -102,14 +132,18 @@ type CarouselType = CarouselComponent & { gap: BreakpointInput; fade: boolean; transitionMs: number; + withArrows: boolean; + variant: CarouselIndicatorsVariant; }; export const Default: StoryObj = { args: { - slidesPerView: { xs: 1 }, + 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, @@ -125,21 +159,17 @@ export const Default: StoryObj = { @for (i of [0, 1, 2, 3, 4]; track $index) { -
- @for (j of [0, 1, 2]; track $index) { -
- -
Replace with your own content
-
- } +
+ +
Replace with your own content
} - + `, @@ -149,7 +179,7 @@ export const Default: StoryObj = { export const TopPaginationArrowsOnly: StoryObj = { name: "Top pagination - arrows only", args: { - slidesPerView: { xs: 3 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -179,7 +209,7 @@ export const TopPaginationArrowsOnly: StoryObj = { export const SeparatedBottomPaginationHasDots: StoryObj = { name: "Separated bottom pagination - has dots", args: { - slidesPerView: { xs: 2.8 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -212,7 +242,7 @@ export const SeparatedBottomPaginationHasDots: StoryObj = { export const SeparatedBottomPaginationHasNumbers: StoryObj = { name: "Separated bottom pagination - has numbers", args: { - slidesPerView: { xs: 1.5 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -224,15 +254,11 @@ export const SeparatedBottomPaginationHasNumbers: StoryObj = { @for (i of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; track $index) { -
- @for (j of [0, 1, 2]; track $index) { -
- -
Replace with your own content
-
- } +
+ +
Replace with your own content
} @@ -249,7 +275,7 @@ export const SeparatedBottomPaginationHasNumbers: StoryObj = { export const CenteredBottomPaginationHasDots: StoryObj = { name: "Centered bottom pagination - has dots", args: { - slidesPerView: { xs: 3 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -281,7 +307,7 @@ export const CenteredBottomPaginationHasDots: StoryObj = { export const CenteredBottomPaginationHasNumbers: StoryObj = { name: "Centered bottom pagination - has numbers", args: { - slidesPerView: { xs: 3 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -313,7 +339,7 @@ export const CenteredBottomPaginationHasNumbers: StoryObj = { export const CombinationsTopNavigationBottomDots: StoryObj = { name: "Combinations - top navigation, bottom dots", args: { - slidesPerView: { xs: 2.5 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -509,7 +535,7 @@ export const Combination: StoryObj = { export const Fade: StoryObj = { name: "Fade", args: { - slidesPerView: { xs: 1 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, fade: true, }, render: (args) => ({ @@ -520,18 +546,14 @@ export const Fade: StoryObj = {

Title

- + @for (i of [0, 1, 2, 3, 4]; track $index) { -
- @for (j of [0, 1, 2]; track $index) { -
- -
Replace with your own content
-
- } +
+ +
Replace with your own content
} @@ -542,14 +564,16 @@ export const Fade: StoryObj = { - - @for (i of [0, 1, 2, 3, 4, 5]; track $index) { + + @for (i of [0, 1, 2, 3, 4]; track $index) { +
Replace with your own content
+
}
@@ -564,7 +588,7 @@ export const Fade: StoryObj = { export const WithTopNavigation: StoryObj = { name: "With top navigation", args: { - slidesPerView: { xs: 3 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -594,7 +618,7 @@ export const WithTopNavigation: StoryObj = { export const WithTopAction: StoryObj = { name: "With top action", args: { - slidesPerView: { xs: 3 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, @@ -631,7 +655,7 @@ export const WithTopAction: StoryObj = { export const WithDescription: StoryObj = { name: "With description", args: { - slidesPerView: { xs: 3 }, + slidesPerView: { xs: 1, sm: 2, md: 2.5, lg: 3, xl: 3.5, xxl: 4 }, }, render: (args) => ({ props: args, From 4bad166cd6bf90aee43c3f092a45c4f656590641 Mon Sep 17 00:00:00 2001 From: Romet Pastak Date: Tue, 21 Oct 2025 12:54:50 +0300 Subject: [PATCH 8/8] feat(carousel): fix test #151 --- .../content/carousel/carousel.component.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tedi/components/content/carousel/carousel.component.spec.ts b/tedi/components/content/carousel/carousel.component.spec.ts index c657a1b34..594a0cf9e 100644 --- a/tedi/components/content/carousel/carousel.component.spec.ts +++ b/tedi/components/content/carousel/carousel.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef } from "@angular/core"; +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"; @@ -32,7 +32,9 @@ describe("CarouselContentComponent", () => { let component: CarouselContentComponent; let hostElement: HTMLElement; - let mockBreakpointService: { isAboveBreakpoint: jest.Mock }; + let mockBreakpointService: { + isAboveBreakpoint: () => ReturnType; + }; let mockTranslationService: { track: jest.Mock }; let fakeViewport: HTMLDivElement; @@ -54,7 +56,7 @@ describe("CarouselContentComponent", () => { fakeViewport.style.width = "1000px"; mockBreakpointService = { - isAboveBreakpoint: jest.fn().mockReturnValue(false), + isAboveBreakpoint: () => signal(false), }; mockTranslationService = { @@ -316,8 +318,8 @@ describe("CarouselContentComponent", () => { const breakpoints = Object.keys(slidesPerView) as Breakpoint[]; breakpoints.forEach((bp) => { - mockBreakpointService.isAboveBreakpoint.mockReturnValueOnce(true); fixture.whenStable().then(() => { + mockBreakpointService.isAboveBreakpoint().set(true); expect(component.currentSlidesPerView()).toBe(slidesPerView[bp]); }); }); @@ -337,7 +339,7 @@ describe("CarouselContentComponent", () => { const breakpoints = Object.keys(gaps) as Breakpoint[]; breakpoints.forEach((bp) => { - mockBreakpointService.isAboveBreakpoint.mockReturnValueOnce(true); + mockBreakpointService.isAboveBreakpoint().set(true); fixture.whenStable().then(() => { expect(component.currentGap()).toBe(gaps[bp]); });