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 76ed5c4bf..fc8b41ff8 100644
--- a/tedi/components/content/carousel/carousel-content/carousel-content.component.html
+++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.html
@@ -6,14 +6,16 @@
>
@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
index eb19154dc..eafe8a1a5 100644
--- a/tedi/components/content/carousel/carousel-content/carousel-content.component.scss
+++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.scss
@@ -2,6 +2,7 @@
position: relative;
width: 100%;
overflow: hidden;
+ overscroll-behavior: contain;
touch-action: pan-y;
cursor: grab;
@@ -33,5 +34,16 @@
.tedi-carousel__slide {
user-select: none;
+ scroll-snap-align: none;
+ scroll-margin: 0;
-webkit-user-drag: none;
+
+ &:focus {
+ outline: none;
+ }
+
+ &:focus-visible {
+ outline: var(--borders-02) solid var(--tedi-primary-500);
+ outline-offset: calc(-1 * var(--borders-03));
+ }
}
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 71799b59b..256d15d9b 100644
--- a/tedi/components/content/carousel/carousel-content/carousel-content.component.ts
+++ b/tedi/components/content/carousel/carousel-content/carousel-content.component.ts
@@ -7,6 +7,7 @@ import {
contentChildren,
signal,
viewChild,
+ viewChildren,
AfterViewInit,
OnDestroy,
input,
@@ -14,6 +15,7 @@ import {
HostListener,
} from "@angular/core";
import { NgTemplateOutlet } from "@angular/common";
+import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CarouselSlideDirective } from "../carousel-slide.directive";
import {
breakpointInput,
@@ -61,8 +63,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
readonly translationService = inject(TediTranslationService);
private readonly breakpointService = inject(BreakpointService);
private readonly host = inject>(ElementRef);
+ private readonly liveAnnouncer = inject(LiveAnnouncer);
readonly track = viewChild.required>("track");
+ readonly slideElements = viewChildren>("slide");
readonly slides = contentChildren(CarouselSlideDirective);
readonly trackIndex = signal(0);
@@ -147,6 +151,16 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
return this.trackIndex() - this.windowBase() + this.buffer();
});
+ /**
+ * Checks if a slide at the given rendered index is currently visible in the viewport.
+ * Used to determine which slides should be accessible to screen readers.
+ */
+ isSlideVisible(renderedIndex: number): boolean {
+ const activeIndex = this.renderedActiveIndex();
+ const slidesPerView = Math.ceil(this.currentSlidesPerView());
+ return renderedIndex >= activeIndex && renderedIndex < activeIndex + slidesPerView;
+ }
+
readonly renderedIndices = computed(() => {
const slidesCount = this.slides().length;
@@ -211,12 +225,20 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
locked = false;
dragging = false;
+ private pendingFocus = false;
private startX = 0;
private startIndex = 0;
private ro?: ResizeObserver;
private wheelTimeout?: ReturnType;
private scrollDelta = 0;
+ @HostListener("scroll")
+ onScroll() {
+ // Prevent any scroll triggered by focus (e.g., VoiceOver navigation)
+ this.host.nativeElement.scrollLeft = 0;
+ this.host.nativeElement.scrollTop = 0;
+ }
+
@HostListener("wheel", ["$event"])
onWheel(event: WheelEvent) {
const slidesCount = this.slides().length;
@@ -395,6 +417,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
this.animate.set(true);
this.trackIndex.update((i) => i + 1);
this.lockNavigation();
+ this.announceSlideChange();
}
prev(): void {
@@ -405,9 +428,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
this.animate.set(true);
this.trackIndex.update((i) => i - 1);
this.lockNavigation();
+ this.announceSlideChange();
}
- goToIndex(index: number) {
+ goToIndex(index: number, options?: { focusSlide?: boolean }) {
const slidesCount = this.slides().length;
if (!slidesCount || this.locked) {
@@ -419,6 +443,27 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
const delta = normalized - current;
this.animate.set(true);
this.trackIndex.update((i) => i + delta);
+
+ if (options?.focusSlide) {
+ // Focus after transition completes so DOM positions are stable
+ this.pendingFocus = true;
+ } else {
+ this.announceSlideChange();
+ }
+ }
+
+ /**
+ * Focuses the currently active slide for screen reader users.
+ * Uses preventScroll to avoid breaking carousel layout.
+ */
+ focusActiveSlide(): void {
+ setTimeout(() => {
+ const activeIndex = this.renderedActiveIndex();
+ const slideElement = this.slideElements()[activeIndex];
+ if (slideElement) {
+ slideElement.nativeElement.focus({ preventScroll: true });
+ }
+ });
}
onTransitionEnd(e: TransitionEvent) {
@@ -432,10 +477,33 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
this.animate.set(false);
this.windowBase.set(Math.floor(this.trackIndex()));
+
+ if (this.pendingFocus) {
+ this.pendingFocus = false;
+ this.focusActiveSlide();
+ }
}
lockNavigation() {
this.locked = true;
setTimeout(() => (this.locked = false), this.transitionMs());
}
+
+ /**
+ * Announces the current slide position to screen readers via LiveAnnouncer.
+ * Called after navigation to inform users of the slide change.
+ */
+ announceSlideChange(): void {
+ setTimeout(() => {
+ const slideNumber = this.slideIndex() + 1;
+ const totalSlides = this.slides().length;
+ const message = this.translationService.translate(
+ "carousel.slide",
+ slideNumber,
+ totalSlides
+ );
+
+ this.liveAnnouncer.announce(message, "polite");
+ }, 100);
+ }
}
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 e18874c82..23c6000f6 100644
--- a/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts
+++ b/tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts
@@ -57,6 +57,6 @@ export class CarouselIndicatorsComponent {
}
handleIndicatorClick(index: number) {
- this.carousel.carouselContent().goToIndex(index);
+ this.carousel.carouselContent().goToIndex(index, { focusSlide: true });
}
}
diff --git a/tedi/components/content/carousel/carousel.component.spec.ts b/tedi/components/content/carousel/carousel.component.spec.ts
index ce0784896..df21b9920 100644
--- a/tedi/components/content/carousel/carousel.component.spec.ts
+++ b/tedi/components/content/carousel/carousel.component.spec.ts
@@ -1,6 +1,8 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
import { Component, ElementRef, signal } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
+import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CarouselContentComponent } from "./carousel-content/carousel-content.component";
import {
Breakpoint,
@@ -32,10 +34,9 @@ describe("CarouselContentComponent", () => {
let component: CarouselContentComponent;
let hostElement: HTMLElement;
- let mockBreakpointService: {
- isAboveBreakpoint: () => ReturnType;
- };
- let mockTranslationService: { track: jest.Mock };
+ let mockBreakpointService: any;
+ let mockTranslationService: { track: jest.Mock; translate: jest.Mock };
+ let mockLiveAnnouncer: { announce: jest.Mock };
let fakeViewport: HTMLDivElement;
beforeEach(async () => {
@@ -61,6 +62,11 @@ describe("CarouselContentComponent", () => {
mockTranslationService = {
track: jest.fn((key: string) => () => key),
+ translate: jest.fn((key: string) => key),
+ };
+
+ mockLiveAnnouncer = {
+ announce: jest.fn().mockResolvedValue(undefined),
};
await TestBed.configureTestingModule({
@@ -69,6 +75,7 @@ describe("CarouselContentComponent", () => {
{ provide: BreakpointService, useValue: mockBreakpointService },
{ provide: TediTranslationService, useValue: mockTranslationService },
{ provide: ElementRef, useValue: new ElementRef(fakeViewport) },
+ { provide: LiveAnnouncer, useValue: mockLiveAnnouncer },
],
}).compileComponents();
@@ -314,7 +321,7 @@ describe("CarouselContentComponent", () => {
expect(component.classes()).toContain("tedi-carousel__content--fade-x");
});
- it("should respect breakpoint priority for currentSlidesPerView", () => {
+ describe("breakpoint-specific currentSlidesPerView", () => {
const slidesPerView: Record = {
xs: 1,
sm: 2,
@@ -323,19 +330,74 @@ describe("CarouselContentComponent", () => {
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 return xxl value when above xxl breakpoint", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "xxl" || bp === "xl" || bp === "lg" || bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("slidesPerView", slidesPerView);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentSlidesPerView()).toBe(6);
+ });
+
+ it("should return xl value when above xl but not xxl", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "xl" || bp === "lg" || bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("slidesPerView", slidesPerView);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentSlidesPerView()).toBe(5);
+ });
+
+ it("should return lg value when above lg but not xl", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "lg" || bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("slidesPerView", slidesPerView);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentSlidesPerView()).toBe(4);
+ });
+
+ it("should return md value when above md but not lg", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("slidesPerView", slidesPerView);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentSlidesPerView()).toBe(3);
+ });
+
+ it("should return sm value when above sm but not md", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "sm");
+
+ fixture.componentRef.setInput("slidesPerView", slidesPerView);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentSlidesPerView()).toBe(2);
+ });
+
+ it("should return xs value when below sm", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = () => signal(false);
+
+ fixture.componentRef.setInput("slidesPerView", slidesPerView);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentSlidesPerView()).toBe(1);
});
});
- it("should respect breakpoint priority for currentGap", () => {
+ describe("breakpoint-specific currentGap", () => {
const gaps: Record = {
xs: 2,
sm: 4,
@@ -344,15 +406,70 @@ describe("CarouselContentComponent", () => {
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 return xxl gap when above xxl breakpoint", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "xxl" || bp === "xl" || bp === "lg" || bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("gap", gaps);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentGap()).toBe(12);
+ });
+
+ it("should return xl gap when above xl but not xxl", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "xl" || bp === "lg" || bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("gap", gaps);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentGap()).toBe(10);
+ });
+
+ it("should return lg gap when above lg but not xl", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "lg" || bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("gap", gaps);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentGap()).toBe(8);
+ });
+
+ it("should return md gap when above md but not lg", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "md" || bp === "sm");
+
+ fixture.componentRef.setInput("gap", gaps);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentGap()).toBe(6);
+ });
+
+ it("should return sm gap when above sm but not md", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = (bp: Breakpoint) =>
+ signal(bp === "sm");
+
+ fixture.componentRef.setInput("gap", gaps);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentGap()).toBe(4);
+ });
+
+ it("should return xs gap when below sm", async () => {
+ (mockBreakpointService as any).isAboveBreakpoint = () => signal(false);
+
+ fixture.componentRef.setInput("gap", gaps);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(component.currentGap()).toBe(2);
});
});
@@ -378,6 +495,604 @@ describe("CarouselContentComponent", () => {
expect(preventDefaultSpy).toHaveBeenCalled();
expect(component.trackIndex()).not.toBe(0);
});
+
+ describe("announceSlideChange", () => {
+ it("should call announceSlideChange when next() is called", () => {
+ const spy = jest.spyOn(component, "announceSlideChange");
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.next();
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("should call announceSlideChange when prev() is called", () => {
+ const spy = jest.spyOn(component, "announceSlideChange");
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.trackIndex.set(2);
+ component.prev();
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("should call announceSlideChange when goToIndex() is called", () => {
+ const spy = jest.spyOn(component, "announceSlideChange");
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.goToIndex(2);
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("should call announceSlideChange on keyboard navigation", () => {
+ const spy = jest.spyOn(component, "announceSlideChange");
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ const event = new KeyboardEvent("keydown", { key: "ArrowRight" });
+ component.onKeyDown(event);
+ expect(spy).toHaveBeenCalled();
+ });
+ });
+
+ describe("isSlideVisible", () => {
+ it("should return true for slides within the visible range", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}, {}, {}],
+ });
+ fixture.componentRef.setInput("slidesPerView", { xs: 3 });
+ fixture.detectChanges();
+
+ const activeIndex = component.renderedActiveIndex();
+
+ // Slides at activeIndex, activeIndex+1, activeIndex+2 should be visible
+ expect(component.isSlideVisible(activeIndex)).toBe(true);
+ expect(component.isSlideVisible(activeIndex + 1)).toBe(true);
+ expect(component.isSlideVisible(activeIndex + 2)).toBe(true);
+
+ // Slide before activeIndex should not be visible
+ expect(component.isSlideVisible(activeIndex - 1)).toBe(false);
+
+ // Slide after the visible range should not be visible
+ expect(component.isSlideVisible(activeIndex + 3)).toBe(false);
+ });
+
+ it("should handle fractional slidesPerView by rounding up", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}, {}, {}],
+ });
+ fixture.componentRef.setInput("slidesPerView", { xs: 2.5 });
+ fixture.detectChanges();
+
+ const activeIndex = component.renderedActiveIndex();
+
+ // With 2.5 slides per view, Math.ceil(2.5) = 3 slides should be visible
+ expect(component.isSlideVisible(activeIndex)).toBe(true);
+ expect(component.isSlideVisible(activeIndex + 1)).toBe(true);
+ expect(component.isSlideVisible(activeIndex + 2)).toBe(true);
+ expect(component.isSlideVisible(activeIndex + 3)).toBe(false);
+ });
+
+ it("should work correctly with single slide per view", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+ fixture.componentRef.setInput("slidesPerView", { xs: 1 });
+ fixture.detectChanges();
+
+ const activeIndex = component.renderedActiveIndex();
+
+ expect(component.isSlideVisible(activeIndex)).toBe(true);
+ expect(component.isSlideVisible(activeIndex + 1)).toBe(false);
+ expect(component.isSlideVisible(activeIndex - 1)).toBe(false);
+ });
+ });
+
+ describe("onScroll", () => {
+ it("should reset scroll position to 0", () => {
+ hostElement.scrollLeft = 100;
+ hostElement.scrollTop = 50;
+
+ component.onScroll();
+
+ expect(hostElement.scrollLeft).toBe(0);
+ expect(hostElement.scrollTop).toBe(0);
+ });
+ });
+
+ describe("focusActiveSlide", () => {
+ it("should focus the slide element at renderedActiveIndex", () => {
+ jest.useFakeTimers();
+
+ const mockSlideElement = {
+ nativeElement: { focus: jest.fn() },
+ };
+
+ Object.defineProperty(component, "slideElements", {
+ configurable: true,
+ value: () => [mockSlideElement, mockSlideElement, mockSlideElement],
+ });
+
+ Object.defineProperty(component, "renderedActiveIndex", {
+ configurable: true,
+ value: () => 1,
+ });
+
+ component.focusActiveSlide();
+ jest.runAllTimers();
+
+ expect(mockSlideElement.nativeElement.focus).toHaveBeenCalledWith({
+ preventScroll: true,
+ });
+
+ jest.useRealTimers();
+ });
+
+ it("should not throw if slide element does not exist", () => {
+ jest.useFakeTimers();
+
+ Object.defineProperty(component, "slideElements", {
+ configurable: true,
+ value: () => [],
+ });
+
+ Object.defineProperty(component, "renderedActiveIndex", {
+ configurable: true,
+ value: () => 5,
+ });
+
+ expect(() => {
+ component.focusActiveSlide();
+ jest.runAllTimers();
+ }).not.toThrow();
+
+ jest.useRealTimers();
+ });
+ });
+
+ describe("goToIndex with focusSlide option", () => {
+ it("should set pendingFocus when focusSlide is true", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.goToIndex(1, { focusSlide: true });
+
+ expect(component["pendingFocus"]).toBe(true);
+ });
+
+ it("should not set pendingFocus when focusSlide is false or undefined", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.goToIndex(1);
+
+ expect(component["pendingFocus"]).toBe(false);
+ });
+ });
+
+ describe("onTransitionEnd with pendingFocus", () => {
+ it("should call focusActiveSlide when pendingFocus is true", () => {
+ const focusSpy = jest.spyOn(component, "focusActiveSlide");
+ const fakeNative = {};
+
+ Object.defineProperty(component, "track", {
+ configurable: true,
+ value: () => ({ nativeElement: fakeNative }),
+ });
+
+ component["pendingFocus"] = true;
+ component.animate.set(true);
+
+ const evt = {
+ target: fakeNative,
+ propertyName: "transform",
+ } as TransitionEvent;
+
+ component.onTransitionEnd(evt);
+
+ expect(focusSpy).toHaveBeenCalled();
+ expect(component["pendingFocus"]).toBe(false);
+ });
+
+ it("should not call focusActiveSlide when pendingFocus is false", () => {
+ const focusSpy = jest.spyOn(component, "focusActiveSlide");
+ const fakeNative = {};
+
+ Object.defineProperty(component, "track", {
+ configurable: true,
+ value: () => ({ nativeElement: fakeNative }),
+ });
+
+ component["pendingFocus"] = false;
+ component.animate.set(true);
+
+ const evt = {
+ target: fakeNative,
+ propertyName: "transform",
+ } as TransitionEvent;
+
+ component.onTransitionEnd(evt);
+
+ expect(focusSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("announceSlideChange", () => {
+ it("should call liveAnnouncer.announce with translated message", () => {
+ jest.useFakeTimers();
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.trackIndex.set(1);
+ component.announceSlideChange();
+ jest.runAllTimers();
+
+ expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith(
+ "carousel.slide",
+ "polite"
+ );
+
+ jest.useRealTimers();
+ });
+ });
+
+ describe("renderedIndices", () => {
+ it("should return empty array when no slides", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [],
+ });
+
+ expect(component.renderedIndices()).toEqual([]);
+ });
+ });
+
+ describe("pointer events", () => {
+ it("should update trackIndex on pointermove when dragging", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.viewportWidth.set(1000);
+ component.dragging = true;
+ component["startX"] = 500;
+ component["startIndex"] = 0;
+
+ const event = { clientX: 400 } as PointerEvent;
+ component.onPointerMove(event);
+
+ expect(component.trackIndex()).not.toBe(0);
+ });
+
+ it("should not update trackIndex on pointermove when not dragging", () => {
+ component.dragging = false;
+ const initialIndex = component.trackIndex();
+
+ const event = { clientX: 400 } as PointerEvent;
+ component.onPointerMove(event);
+
+ expect(component.trackIndex()).toBe(initialIndex);
+ });
+ });
+
+ describe("wheel event edge cases", () => {
+ it("should not handle wheel when no slides", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [],
+ });
+
+ const event = new WheelEvent("wheel", { deltaX: 100 });
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault");
+
+ component.onWheel(event);
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+ });
+
+ it("should not handle wheel when delta is 0", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ const event = new WheelEvent("wheel", { deltaX: 0, deltaY: 0 });
+ const preventDefaultSpy = jest.spyOn(event, "preventDefault");
+
+ component.onWheel(event);
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("keyboard navigation edge cases", () => {
+ it("should handle PageDown same as ArrowRight", () => {
+ const spy = jest.spyOn(component, "next");
+ const event = new KeyboardEvent("keydown", { key: "PageDown" });
+ component.onKeyDown(event);
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("should handle PageUp same as ArrowLeft", () => {
+ const spy = jest.spyOn(component, "prev");
+ const event = new KeyboardEvent("keydown", { key: "PageUp" });
+ component.onKeyDown(event);
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("should not handle unrecognized keys", () => {
+ const nextSpy = jest.spyOn(component, "next");
+ const prevSpy = jest.spyOn(component, "prev");
+ const goToIndexSpy = jest.spyOn(component, "goToIndex");
+
+ const event = new KeyboardEvent("keydown", { key: "Enter" });
+ component.onKeyDown(event);
+
+ expect(nextSpy).not.toHaveBeenCalled();
+ expect(prevSpy).not.toHaveBeenCalled();
+ expect(goToIndexSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("wheel timeout and snap behavior", () => {
+ it("should snap to nearest slide after wheel timeout", () => {
+ jest.useFakeTimers();
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}, {}, {}],
+ });
+
+ component.viewportWidth.set(1000);
+
+ const event = new WheelEvent("wheel", { deltaX: 50 });
+ component.onWheel(event);
+
+ jest.advanceTimersByTime(120);
+
+ expect(component.animate()).toBe(true);
+ expect(Number.isInteger(component.trackIndex())).toBe(true);
+
+ jest.useRealTimers();
+ });
+
+ it("should snap in scroll direction when scrollDelta > 0.3", () => {
+ jest.useFakeTimers();
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}, {}, {}],
+ });
+
+ component.viewportWidth.set(1000);
+ component.trackIndex.set(0);
+
+ // Large positive delta should snap forward
+ const event = new WheelEvent("wheel", { deltaX: 500 });
+ component.onWheel(event);
+
+ jest.advanceTimersByTime(120);
+
+ expect(component.trackIndex()).toBeGreaterThanOrEqual(1);
+
+ jest.useRealTimers();
+ });
+
+ it("should handle clamped wheel at min boundary", () => {
+ jest.useFakeTimers();
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.viewportWidth.set(1000);
+ component.trackIndex.set(0);
+
+ // Large negative delta should be clamped
+ const event = new WheelEvent("wheel", { deltaX: -5000 });
+ component.onWheel(event);
+
+ jest.advanceTimersByTime(120);
+
+ // Should snap to valid index
+ expect(Number.isInteger(component.trackIndex())).toBe(true);
+
+ jest.useRealTimers();
+ });
+
+ it("should handle clamped wheel at max boundary", () => {
+ jest.useFakeTimers();
+
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.viewportWidth.set(1000);
+ component.trackIndex.set(0);
+
+ // Large positive delta should be clamped
+ const event = new WheelEvent("wheel", { deltaX: 5000 });
+ component.onWheel(event);
+
+ jest.advanceTimersByTime(120);
+
+ // Should snap to valid index
+ expect(Number.isInteger(component.trackIndex())).toBe(true);
+
+ jest.useRealTimers();
+ });
+ });
+
+
+ describe("onTransitionEnd edge cases", () => {
+ it("should ignore transition events from other elements", () => {
+ const fakeNative = {};
+ const otherElement = {};
+
+ Object.defineProperty(component, "track", {
+ configurable: true,
+ value: () => ({ nativeElement: fakeNative }),
+ });
+
+ component.animate.set(true);
+
+ const evt = {
+ target: otherElement,
+ propertyName: "transform",
+ } as TransitionEvent;
+
+ component.onTransitionEnd(evt);
+
+ // animate should not change because event target doesn't match
+ expect(component.animate()).toBe(true);
+ });
+
+ it("should ignore transition events for non-transform properties", () => {
+ const fakeNative = {};
+
+ Object.defineProperty(component, "track", {
+ configurable: true,
+ value: () => ({ nativeElement: fakeNative }),
+ });
+
+ component.animate.set(true);
+
+ const evt = {
+ target: fakeNative,
+ propertyName: "opacity",
+ } as TransitionEvent;
+
+ component.onTransitionEnd(evt);
+
+ // animate should not change because propertyName is not transform
+ expect(component.animate()).toBe(true);
+ });
+
+ it("should ignore transition events while dragging", () => {
+ const fakeNative = {};
+
+ Object.defineProperty(component, "track", {
+ configurable: true,
+ value: () => ({ nativeElement: fakeNative }),
+ });
+
+ component.animate.set(true);
+ component.dragging = true;
+
+ const evt = {
+ target: fakeNative,
+ propertyName: "transform",
+ } as TransitionEvent;
+
+ component.onTransitionEnd(evt);
+
+ // animate should not change because dragging is true
+ expect(component.animate()).toBe(true);
+ });
+ });
+
+ describe("navigation when no slides", () => {
+ it("should not navigate next when no slides", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [],
+ });
+
+ const initialIndex = component.trackIndex();
+ component.next();
+ expect(component.trackIndex()).toBe(initialIndex);
+ });
+
+ it("should not navigate prev when no slides", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [],
+ });
+
+ const initialIndex = component.trackIndex();
+ component.prev();
+ expect(component.trackIndex()).toBe(initialIndex);
+ });
+
+ it("should not goToIndex when no slides", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [],
+ });
+
+ const initialIndex = component.trackIndex();
+ component.goToIndex(2);
+ expect(component.trackIndex()).toBe(initialIndex);
+ });
+
+ it("should not goToIndex when locked", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [{}, {}, {}],
+ });
+
+ component.locked = true;
+ const initialIndex = component.trackIndex();
+ component.goToIndex(2);
+ expect(component.trackIndex()).toBe(initialIndex);
+ });
+ });
+
+ describe("pointerdown when no slides", () => {
+ it("should not start dragging when no slides", () => {
+ Object.defineProperty(component, "slides", {
+ configurable: true,
+ value: () => [],
+ });
+
+ hostElement.setPointerCapture = jest.fn();
+
+ dispatchPointerLike(hostElement, "pointerdown", {
+ clientX: 120,
+ pointerId: 42,
+ });
+
+ expect(component.dragging).toBe(false);
+ });
+ });
+
+ describe("onPointerUp when not dragging", () => {
+ it("should do nothing when not dragging", () => {
+ component.dragging = false;
+ component.animate.set(false);
+ const initialAnimate = component.animate();
+
+ component.onPointerUp();
+
+ expect(component.animate()).toBe(initialAnimate);
+ });
+ });
});
@Component({
@@ -399,9 +1114,7 @@ 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 };
@@ -473,9 +1186,9 @@ describe("CarouselIndicatorsComponent", () => {
expect(mockCarouselContent.prev).toHaveBeenCalled();
});
- it("should call carouselContent.goToIndex() when handleIndicatorClick() is triggered", () => {
+ it("should call carouselContent.goToIndex() with focusSlide when handleIndicatorClick() is triggered", () => {
component.handleIndicatorClick(2);
- expect(mockCarouselContent.goToIndex).toHaveBeenCalledWith(2);
+ expect(mockCarouselContent.goToIndex).toHaveBeenCalledWith(2, { focusSlide: true });
});
});
@@ -490,9 +1203,7 @@ 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 };