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